9.1 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project
Flutter frontend ("AC Control") for the AcAPI, a .NET Web API backend of the "Artificial Company". The UI shows AI agents, their tasks/availability, and the reports they produce.
The Flutter project lives in ac_web/, not at the repository root. All commands below must be run from ac_web/.
Commands
flutter pub get # install dependencies
flutter run -d chrome # run in the browser (primary target)
flutter run -d linux # run as a desktop app
flutter analyze # lint / static analysis (flutter_lints)
flutter test # run all tests
flutter test test/Api/ApiLayer_test.dart # single test file
flutter test test/Api/ApiLayer_test.dart --plain-name 'parses agents' # single test
flutter build web
Flutter 3.44 / Dart 3.12 (sdk: ^3.12.2).
Tests
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. 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.
CI (Gitea Actions)
.gitea/workflows/ holds one workflow per delivery target. All of them are triggered by release: types: [published], so they run when a release is published through "Releases -> New Release" and never on an ordinary push or on a tag pushed with plain git:
| Workflow | Builds | Result |
|---|---|---|
BuildLinux.yml |
flutter build linux --release |
ac_web-linux-x64-<tag>.tar.gz (the whole bundle directory) as an artifact |
BuildAndroid.yml |
flutter build apk --release |
ac_web-<tag>.apk as an artifact |
PublishDocker.yml |
ac_web/Dockerfile |
image pushed to the Gitea registry as <tag>, latest and the short sha |
Notes that are easy to get wrong:
- The act-runner of the gitea instance only supports actions up to
node20. Anything newer fails withThe runs.using key in action.yml must be one of: [...], got node24, which is why the Flutter sdk is unpacked from the official archive instead of throughsubosito/flutter-action(that one caches throughactions/cache@v5). Check theruns.usingof an action before adding it. - The Flutter version is pinned in three places (
FLUTTER_VERSIONin both build workflows,ARG FLUTTER_VERSIONinac_web/Dockerfile) and must be kept in sync. - Steps run with
working-directory: ac_web, butupload-artifactpaths are relative to the repository root. - The workflows are read from the tag the release points at, so they have to be committed before the release is created.
- Artifacts and the built files carry the tag name (
github.ref_name); the docker image is pushed as the tag, aslatestand as the short sha. - The apk is signed with a debug key, because
android/app/build.gradle.ktsstill has no release signing config. - The docker image is self contained: it compiles the web bundle itself, so
docker build -f ac_web/Dockerfile ac_webgives the same result locally. It serves the static bundle with nginx (ac_web/nginx.conf), which falls back toindex.htmlso deep links survive a reload. The backend address is compiled into the bundle, so the host inmain.dartmust be reachable from the browser of every visitor, not only from the container.
Backend connection
The backend host/port is configured once in lib/main.dart via AcApi.configure(const ApiConfig(host: 'localhost', port: 5118)). Plain http is intentional — the API is only reached inside a trusted network. There is no environment/config file; changing the backend address means editing main.dart.
Architecture
API layer (lib/Api/)
Three strictly separated levels — keep them that way:
ApiClient— transport only. Builds requests, applies the timeout, maps every failure onto a sealedApiExceptionsubtype (ApiHttpException,ApiNetworkException,ApiTimeoutException,ApiParsingException), decodes json. Knows nothing about controllers.Services/*Service extends ApiServiceBase— one class per backend controller, exposingcontrollerRoute(e.g.agents) and typed methods that callclient.getList/getObjectwith a dtofromJson.AcApi— the single entry point. Holds the shared instance (AcApi.instance), lazily creates services, and is reconfigurable viaAcApi.configure(...)/AcApi.reset()(tests inject aMockClientthrough thehttpClientparameter).
lib/Api/Api.dart is a barrel file; UI code should import 'package:ac_web/Api/Api.dart';.
Adding a backend controller: create the dtos under Api/Dtos, add a <Name>Service extends ApiServiceBase, expose it as a lazy getter on AcApi, and export both from Api.dart.
DTOs and .NET interop (lib/Api/Dtos/, lib/Api/JsonReader.dart)
The backend is still in development, so parsing is deliberately defensive. Never call json['key'] directly in a dto — always go through JsonReader, which resolves keys case-insensitively (PascalCase from .NET vs. camelCase), tolerates missing properties, and parses enums that may arrive as either a name or a numeric index.
Enums (EAgentType, EAgentStatus, EReportType) mirror .NET enums. The declaration order must match the .NET enum because values may be serialized as indices, and unknown is always the last value so it never shadows a real backend value and acts as the fallback for values this client does not know yet. EReportType.wireName carries the SCREAMING_SNAKE .NET spelling.
UI (lib/Layouts/, lib/Pages/, lib/Widgets/)
MainApppicks the layout by width:>= DesktopView.minimumWidth(900) →DesktopView(three columns side by side: Reports | LatestReports | Agents), otherwiseMobileView(same three pages behind aNavigationBar). 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<T> mXFutureassigned ininitStatefromAcApi.instance...→FutureBuilderwith explicit loading /snapshot.hasError/ empty-list branches. Reload = reassign the future insidesetState. Navigation is plainNavigator.of(context).push(MaterialPageRoute(...)). - Automatic refresh: every widget that shows api data polls the backend (it has no push channel) through the
AutoRefreshStatemixin (lib/Widgets/AutoRefreshState.dart): mix it into theStateand implementRefreshData, 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 inAutoRefreshState.refreshInterval. Because a refresh must not flash the spinner or drop the list, theFutureBuilders branch onsnapshot.hasData/hasError, not onconnectionState. A refresh the user did not ask for stays silent — no snackbars, no disabled buttons (seeAgentDetails._reloadAgent). - Icons per enum value live in extensions (
AgentTypeIcons,ReportTypeIcons—type.icon) so every page renders a type identically. Human labels come fromdisplayNameon the enum itself. MarkdownTextis a hand-written markdown renderer (noflutter_markdowndependency) 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.
Conventions
These deviate from stock Dart style; match the surrounding code rather than the default lints:
- Files and directories are PascalCase (
lib/Widgets/AgentTaskCard.dart), one public class per file, named after the file. - Instance fields are prefixed with
m(mAgent,mReportsFuture,mIsReloading); private helpers keep the leading underscore (_buildList,_openType). - Public methods on
Stateclasses are PascalCase (ReloadAgents,ReloadEntries); private build helpers are camelCase with_. - Enums are prefixed
E, dtos suffixedDto. - Comments explain why (e.g. why enum order matters, why http is fine) in full sentences — keep that density.