From 435698086dcc3c1ed0eb7f617224348dc2a92f75 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 1 Sep 2026 14:28:35 +0200 Subject: [PATCH] Introduced auto refresh for API Data --- CLAUDE.md | 3 +- ac_web/lib/Pages/AgentDetails.dart | 30 ++- .../lib/Widgets/AgentAvailabilityColumn.dart | 29 ++- ac_web/lib/Widgets/AutoRefreshState.dart | 68 ++++++ ac_web/lib/Widgets/LatestReportColumn.dart | 27 ++- ac_web/test/Widgets/AutoRefresh_test.dart | 205 ++++++++++++++++++ 6 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 ac_web/lib/Widgets/AutoRefreshState.dart create mode 100644 ac_web/test/Widgets/AutoRefresh_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index b5fbc45..8d2e1dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Flutter 3.44 / Dart 3.12 (`sdk: ^3.12.2`). Test files **must end in `_test.dart`** or `flutter test` silently ignores them. The project keeps its PascalCase file naming in front of that suffix: `test/Api/ApiLayer_test.dart`, `test/Layouts/MainApp_test.dart`. -Widget tests run against a mocked backend: build a `MockClient` that answers by route, hand it to `AcApi.configure(..., httpClient: client)` in `setUp`, and `AcApi.reset()` in `tearDown`. Do **not** use `pumpAndSettle` on pages that load data — the loading state is a `CircularProgressIndicator`, whose animation never settles; pump a couple of frames instead (see `settleApiCalls` in `MainApp_test.dart`). Layout tests set `tester.view.physicalSize` to cross the 900px desktop/mobile breakpoint. +Widget tests run against a mocked backend: build a `MockClient` that answers by route, hand it to `AcApi.configure(..., httpClient: client)` in `setUp`, and `AcApi.reset()` in `tearDown`. Do **not** use `pumpAndSettle` on pages that load data — the loading state is a `CircularProgressIndicator`, whose animation never settles; pump a couple of frames instead (see `settleApiCalls` in `MainApp_test.dart`). Layout tests set `tester.view.physicalSize` to cross the 900px desktop/mobile breakpoint. To test the automatic refresh, pump `AutoRefreshState.refreshInterval` and then a few frames (`test/Widgets/AutoRefresh_test.dart`). `flutter analyze` reports ~40 pre-existing `file_names` / `non_constant_identifier_names` infos caused by the naming conventions below. They are expected — only care about new warnings and errors. @@ -60,6 +60,7 @@ Enums (`EAgentType`, `EAgentStatus`, `EReportType`) mirror .NET enums. **The dec - `MainApp` picks the layout by width: `>= DesktopView.minimumWidth` (900) → `DesktopView` (three columns side by side: Reports | LatestReports | Agents), otherwise `MobileView` (same three pages behind a `NavigationBar`). Both layouts reuse the *same* page widgets, so pages must not assume they own the whole screen. - **No state management package and no routing package.** Data loading is consistently: `StatefulWidget` → `late Future mXFuture` assigned in `initState` from `AcApi.instance...` → `FutureBuilder` with explicit loading / `snapshot.hasError` / empty-list branches. Reload = reassign the future inside `setState`. Navigation is plain `Navigator.of(context).push(MaterialPageRoute(...))`. +- **Automatic refresh**: every widget that shows api data polls the backend (it has no push channel) through the `AutoRefreshState` mixin (`lib/Widgets/AutoRefreshState.dart`): mix it into the `State` and implement `RefreshData`, which fetches again and, only on success, assigns the result as an already completed future. The mixin owns the timer, guards against overlapping requests and swallows errors of a tick. The interval (30 s) lives once in `AutoRefreshState.refreshInterval`. Because a refresh must not flash the spinner or drop the list, the `FutureBuilder`s branch on `snapshot.hasData` / `hasError`, **not** on `connectionState`. A refresh the user did not ask for stays silent — no snackbars, no disabled buttons (see `AgentDetails._reloadAgent`). - Icons per enum value live in extensions (`AgentTypeIcons`, `ReportTypeIcons` — `type.icon`) so every page renders a type identically. Human labels come from `displayName` on the enum itself. - `MarkdownText` is a hand-written markdown renderer (no `flutter_markdown` dependency) covering the subset the reports use: headings, lists, block quotes, fenced code, rules, and inline bold/italic/strikethrough/code/links. Extend it rather than adding a package. - Dependencies are intentionally minimal (`http`, `cupertino_icons`). Prefer solving things with Flutter's own widgets before adding a package. diff --git a/ac_web/lib/Pages/AgentDetails.dart b/ac_web/lib/Pages/AgentDetails.dart index 733f859..a483a2c 100644 --- a/ac_web/lib/Pages/AgentDetails.dart +++ b/ac_web/lib/Pages/AgentDetails.dart @@ -4,6 +4,7 @@ import 'package:ac_web/Api/Dtos/AgentTaskDto.dart'; import 'package:ac_web/Widgets/AgentStatusIndicator.dart'; import 'package:ac_web/Widgets/AgentTaskCard.dart'; import 'package:ac_web/Widgets/AgentTypeIcons.dart'; +import 'package:ac_web/Widgets/AutoRefreshState.dart'; import 'package:flutter/material.dart'; /// Page that shows every detail of a single agent, including all of its tasks @@ -18,7 +19,8 @@ class AgentDetails extends StatefulWidget { State createState() => _AgentDetailsState(); } -class _AgentDetailsState extends State { +class _AgentDetailsState extends State + with AutoRefreshState { late AgentDto mAgent; @@ -30,17 +32,29 @@ class _AgentDetailsState extends State { mAgent = widget.agent; } + /// Reload triggered by the user through the app bar or by pulling the page + /// down. Reports what happened, even when nothing changed. + Future ReloadAgent() => _reloadAgent(silent: false); + + /// Automatic reload. It runs without the user asking for it, so it neither + /// disables the reload button nor pops up a message - the page simply keeps + /// showing the agent it already has. + @override + Future RefreshData() => _reloadAgent(silent: true); + /// The api only offers the whole list of agents, so the agent this page /// shows is looked up by its name again. - Future ReloadAgent() async { + Future _reloadAgent({required bool silent}) async { if (mIsReloading) { return; } - setState(() { - mIsReloading = true; - }); + mIsReloading = true; + + if (!silent) { + setState(() {}); + } try { final List agents = await AcApi.instance.agents.getAgents(); @@ -60,7 +74,7 @@ class _AgentDetailsState extends State { } }); - if (updated == null) { + if (updated == null && !silent) { _showMessage("This agent is no longer reported by the api."); } } catch (error) { @@ -73,7 +87,9 @@ class _AgentDetailsState extends State { mIsReloading = false; }); - _showMessage("Could not reload the agent: $error"); + if (!silent) { + _showMessage("Could not reload the agent: $error"); + } } } diff --git a/ac_web/lib/Widgets/AgentAvailabilityColumn.dart b/ac_web/lib/Widgets/AgentAvailabilityColumn.dart index 125b637..f6d803a 100644 --- a/ac_web/lib/Widgets/AgentAvailabilityColumn.dart +++ b/ac_web/lib/Widgets/AgentAvailabilityColumn.dart @@ -3,6 +3,7 @@ import 'package:ac_web/Api/Dtos/AgentDto.dart'; import 'package:ac_web/Pages/AgentDetails.dart'; import 'package:ac_web/Widgets/AgentStatusIndicator.dart'; import 'package:ac_web/Widgets/AgentTypeIcons.dart'; +import 'package:ac_web/Widgets/AutoRefreshState.dart'; import 'package:flutter/material.dart'; /// Fetches all agents from the AgentsController and lists them with a colored @@ -14,7 +15,8 @@ class AgentAvailabilityColumn extends StatefulWidget{ State createState() => _AgentAvailabilityColumnState(); } -class _AgentAvailabilityColumnState extends State{ +class _AgentAvailabilityColumnState extends State + with AutoRefreshState { late Future> mAgentsFuture; @@ -24,23 +26,44 @@ class _AgentAvailabilityColumnState extends State{ mAgentsFuture = AcApi.instance.agents.getAgents(); } + /// Reload triggered by the user. Shows the loading indicator again because + /// the user asked for the request and should see that it is running. void ReloadAgents(){ setState(() { mAgentsFuture = AcApi.instance.agents.getAgents(); }); } + /// Automatic reload. The agents that are already on screen stay visible + /// while the request runs, the future is only replaced once it succeeded. + @override + Future RefreshData() async { + + final List agents = await AcApi.instance.agents.getAgents(); + + if (!mounted) { + return; + } + + setState(() { + mAgentsFuture = Future>.value(agents); + }); + } + @override Widget build(BuildContext context) { return FutureBuilder>( future: mAgentsFuture, builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { + // Not `connectionState`: a refresh puts the builder back into the + // waiting state although the previous agents are still available, and + // those should stay on screen instead of flashing a spinner. + if (!snapshot.hasData && !snapshot.hasError) { return const Center(child: CircularProgressIndicator()); } - if (snapshot.hasError) { + if (snapshot.hasError && !snapshot.hasData) { return Center(child: Text('Could not load agents: ${snapshot.error}')); } diff --git a/ac_web/lib/Widgets/AutoRefreshState.dart b/ac_web/lib/Widgets/AutoRefreshState.dart new file mode 100644 index 0000000..3b61208 --- /dev/null +++ b/ac_web/lib/Widgets/AutoRefreshState.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Keeps the data of a widget up to date by reloading it every +/// [refreshInterval]. +/// +/// Mix this into the [State] of every widget that shows data of the backend and +/// implement [RefreshData]. The mixin only owns the timer, fetching the data and +/// putting it on screen stays in the widget itself. +/// +/// The backend has no push channel, so the only way to notice new agents, +/// tasks or reports is polling. The interval is therefore a compromise between +/// fresh data and load on the api. +/// +/// A refresh happens quietly: implementations are expected to keep the data +/// that is already on screen while the request runs, so that the user neither +/// sees a spinner nor loses the scroll position every half minute. Failures of +/// a refresh are swallowed on purpose - the data on screen simply stays as it +/// is and the next tick tries again. +mixin AutoRefreshState on State { + + /// Interval in which the api data of the whole app is refreshed. + static const Duration refreshInterval = Duration(seconds: 30); + + Timer? _refreshTimer; + + /// True while a refresh triggered by the timer is running. A slow api must + /// not pile up requests. + bool mIsAutoRefreshing = false; + + @override + void initState() { + super.initState(); + _refreshTimer = Timer.periodic(refreshInterval, (_) => _onTick()); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _refreshTimer = null; + super.dispose(); + } + + /// Fetches the data of this widget again. Called every [refreshInterval] as + /// long as the widget is mounted. + /// + /// Implementations may let api errors escape, [_onTick] catches them. + Future RefreshData(); + + Future _onTick() async { + + if (mIsAutoRefreshing || !mounted) { + return; + } + + mIsAutoRefreshing = true; + + try { + await RefreshData(); + } catch (error) { + // An automatic refresh must never replace data that is already on screen + // with an error message, so a failed tick is ignored. + } finally { + mIsAutoRefreshing = false; + } + } +} diff --git a/ac_web/lib/Widgets/LatestReportColumn.dart b/ac_web/lib/Widgets/LatestReportColumn.dart index 6f78602..cbbbf12 100644 --- a/ac_web/lib/Widgets/LatestReportColumn.dart +++ b/ac_web/lib/Widgets/LatestReportColumn.dart @@ -2,6 +2,7 @@ import 'package:ac_web/Api/AcApi.dart'; import 'package:ac_web/Api/Dtos/EReportType.dart'; import 'package:ac_web/Api/Dtos/ReportDto.dart'; import 'package:ac_web/Pages/ReportDetails.dart'; +import 'package:ac_web/Widgets/AutoRefreshState.dart'; import 'package:ac_web/Widgets/LatestReportEntry.dart'; import 'package:ac_web/Widgets/LatestReportHeader.dart'; import 'package:ac_web/Widgets/ReportTypeIcons.dart'; @@ -20,7 +21,8 @@ class LatestReportColumn extends StatefulWidget{ State createState() => _LatestReportColumnState(); } -class _LatestReportColumnState extends State{ +class _LatestReportColumnState extends State + with AutoRefreshState { late Future> mReportsFuture; @@ -30,17 +32,36 @@ class _LatestReportColumnState extends State{ mReportsFuture = AcApi.instance.reports.getReports(); } + /// Automatic reload. The reports that are already on screen stay visible + /// while the request runs, the future is only replaced once it succeeded. + @override + Future RefreshData() async { + + final List reports = await AcApi.instance.reports.getReports(); + + if (!mounted) { + return; + } + + setState(() { + mReportsFuture = Future>.value(reports); + }); + } + @override Widget build(BuildContext context) { return FutureBuilder>( future: mReportsFuture, builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { + // Not `connectionState`: a refresh puts the builder back into the + // waiting state although the previous reports are still available, and + // those should stay on screen instead of flashing a spinner. + if (!snapshot.hasData && !snapshot.hasError) { return const Center(child: CircularProgressIndicator()); } - if (snapshot.hasError) { + if (snapshot.hasError && !snapshot.hasData) { return Center(child: Text('Could not load reports: ${snapshot.error}')); } diff --git a/ac_web/test/Widgets/AutoRefresh_test.dart b/ac_web/test/Widgets/AutoRefresh_test.dart new file mode 100644 index 0000000..419b220 --- /dev/null +++ b/ac_web/test/Widgets/AutoRefresh_test.dart @@ -0,0 +1,205 @@ +import 'dart:convert'; + +import 'package:ac_web/Api/Api.dart'; +import 'package:ac_web/Pages/AgentDetails.dart'; +import 'package:ac_web/Widgets/AgentAvailabilityColumn.dart'; +import 'package:ac_web/Widgets/AutoRefreshState.dart'; +import 'package:ac_web/Widgets/LatestReportColumn.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// Backend that answers every request with the agent and the report of the +/// current "round", so that a refresh can be told apart from the first load. +class _RoundBasedBackend { + + int mRound = 0; + + /// When set, every request fails instead of answering. + bool mIsOffline = false; + + /// Number of requests the widgets sent, failing ones included. + int mRequestCount = 0; + + /// Names handed out to the single agent, one per round. + static const List agentNames = ['Ada', 'Grace', 'Alan']; + + http.Client createClient() { + return MockClient((request) async { + + mRequestCount++; + + if (mIsOffline) { + throw http.ClientException('offline'); + } + + final String name = agentNames[mRound % agentNames.length]; + final String path = request.url.path; + + if (path.endsWith('agents')) { + return _json([ + { + 'Name': name, + 'Type': 'CODER', + 'LastSeen': '2026-08-28T12:00:00Z', + 'Status': 'WORKING', + 'Tasks': [], + }, + ]); + } + + if (path.endsWith('reports')) { + return _json([ + { + 'Id': 'report-1', + 'Title': 'Report of $name', + 'Author': name, + 'Timestamp': '2026-08-27T10:00:00Z', + 'Type': 'LOG', + 'Text': 'All good.', + }, + ]); + } + + return http.Response('Not found', 404); + }); + } + + http.Response _json(Object body) { + return http.Response( + jsonEncode(body), + 200, + headers: {'content-type': 'application/json'}, + ); + } +} + +/// Lets the futures of the mocked api complete. `pumpAndSettle` cannot be used +/// here because the loading state shows an endlessly spinning indicator. +Future settleApiCalls(WidgetTester tester) async { + await tester.pump(); + await tester.pump(); +} + +/// Jumps to the next automatic refresh and lets its request complete. +Future passRefreshInterval(WidgetTester tester) async { + await tester.pump(AutoRefreshState.refreshInterval); + await settleApiCalls(tester); +} + +Widget wrap(Widget child) { + return MaterialApp(home: Scaffold(body: child)); +} + +void main() { + + late _RoundBasedBackend backend; + + setUp(() { + backend = _RoundBasedBackend(); + AcApi.configure( + const ApiConfig(host: 'api.local', port: 5000), + httpClient: backend.createClient(), + ); + }); + + tearDown(AcApi.reset); + + testWidgets('the agent list picks up changes of the backend on its own', (tester) async { + await tester.pumpWidget(wrap(const AgentAvailabilityColumn())); + await settleApiCalls(tester); + + expect(find.text('Ada'), findsOneWidget); + + backend.mRound = 1; + await passRefreshInterval(tester); + + expect(find.text('Grace'), findsOneWidget); + expect(find.text('Ada'), findsNothing); + }); + + testWidgets('the reports pick up changes of the backend on its own', (tester) async { + await tester.pumpWidget(wrap(const LatestReportColumn())); + await settleApiCalls(tester); + + expect(find.text('Report of Ada'), findsOneWidget); + + backend.mRound = 1; + await passRefreshInterval(tester); + + expect(find.text('Report of Grace'), findsOneWidget); + }); + + testWidgets('a refresh keeps the current data on screen instead of a spinner', (tester) async { + await tester.pumpWidget(wrap(const AgentAvailabilityColumn())); + await settleApiCalls(tester); + + backend.mRound = 1; + + // The frame in which the refresh got triggered still shows the old agent, + // the new one only replaces it once the request came back. + await tester.pump(AutoRefreshState.refreshInterval); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('Ada'), findsOneWidget); + }); + + testWidgets('a failing refresh keeps the data that is already shown', (tester) async { + await tester.pumpWidget(wrap(const AgentAvailabilityColumn())); + await settleApiCalls(tester); + + backend.mIsOffline = true; + await passRefreshInterval(tester); + + expect(find.text('Ada'), findsOneWidget); + expect(find.textContaining('Could not load agents'), findsNothing); + + // As soon as the api answers again, the refresh brings in the new data. + backend.mIsOffline = false; + backend.mRound = 2; + await passRefreshInterval(tester); + + expect(find.text('Alan'), findsOneWidget); + }); + + testWidgets('the details page refreshes its agent without reporting anything', (tester) async { + final AgentDto agent = AgentDto( + name: 'Ada', + type: EAgentType.coder, + status: EAgentStatus.working, + lastSeen: DateTime.utc(2026, 8, 28, 12), + tasks: const [], + ); + + await tester.pumpWidget(MaterialApp(home: AgentDetails(agent: agent))); + await settleApiCalls(tester); + + // The agent vanished from the api - the automatic refresh must not bother + // the user with a message about it. + backend.mRound = 1; + await passRefreshInterval(tester); + + expect(find.byType(SnackBar), findsNothing); + + // A reload the user asked for still reports it. + await tester.tap(find.byIcon(Icons.refresh)); + await settleApiCalls(tester); + + expect(find.byType(SnackBar), findsOneWidget); + }); + + testWidgets('the refresh stops when the widget is gone', (tester) async { + await tester.pumpWidget(wrap(const AgentAvailabilityColumn())); + await settleApiCalls(tester); + + await tester.pumpWidget(wrap(const SizedBox())); + + final int requestsBefore = backend.mRequestCount; + + // The timer of a disposed widget must not keep asking the api. + await passRefreshInterval(tester); + + expect(backend.mRequestCount, requestsBefore); + }); +}