# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is NodeMaster Manager — a Flutter GUI client for the NodeMaster API, a Go/Beego backend that lives in the sibling repo `../nodemaster` (i.e. `/home/yves/Documents/code/PROJECTS/nodemaster/nodemaster` if this repo is checked out at `/home/yves/Documents/code/PROJECTS/nodemaster/manager`). NodeMaster manages a homelab-style host: docker-compose "services" (start/ stop/backup), restic/rsync backup targets, OS update history, and a fleet of other NodeMaster nodes. This app is the multi-platform (Linux/macOS/Windows/ web/Android/iOS) desktop-first UI over that API's REST endpoints under `/v1`. **The Go API is the source of truth for wire shapes.** Every Dart model in `lib/core/models/` mirrors a Go struct in `../nodemaster/models/`, field for field, including `@JsonKey` renames for snake_case. When the API changes, re-read the actual Go source (`models/*.go`, `controllers/*.go`) rather than trusting existing Dart models or prior assumptions — they can drift, and swagger docs in that repo have been observed stale relative to the controllers. Neither repo currently has authentication; the API is meant to sit behind a VPN/reverse proxy (see the `NoAuthCallout` banner on Settings). ## Commands ```bash flutter pub get # install deps dart run build_runner build --delete-conflicting-outputs # regenerate *.freezed.dart / *.g.dart dart run build_runner watch --delete-conflicting-outputs # regenerate on save, for long edit sessions flutter analyze # static analysis (must be clean) flutter test # full test suite flutter test test/core/models/service_test.dart # single test file flutter test --plain-name "parses a fully-populated service" # single test by name flutter run -d chrome # run as web app flutter run -d linux # run as Linux desktop app flutter build linux --debug # build the Linux bundle (build/linux/x64/debug/bundle/manager) ``` **Any change to a `@freezed` or `@riverpod`/`@Riverpod` class requires a `build_runner build` afterward** — the `.freezed.dart`/`.g.dart` companion files are committed generated code, not build artifacts recreated from scratch each time, so a source edit without regeneration leaves the app compiling against stale generated code (or plain compile errors if fields changed). If a diagnostics pass immediately after a `build_runner` run shows errors referencing symbols you didn't touch, it's very likely IDE/analyzer staleness from the file swap — rerun `flutter analyze` fresh before concluding something is actually broken. To run the app against a live API, start the sibling Go repo first (`cd ../nodemaster && go run main.go`, default port `8080`, config at `~/.nodemaster.conf`), then add a connection in Settings pointing at it (or a pre-seeded `SharedPreferences` `connections` value in tests — see `test/widget_test.dart`). ## Architecture ### Layering: `core/network` → `data/repositories` → `features/*/providers` → screens - **`lib/core/network/node_master_api_client.dart`** — one `NodeMasterApiClient` class, one method per REST endpoint, thin (dio call in, typed model out). Every method funnels through `_guard`, which enforces a hard 20s request budget (`Future.timeout`, not just Dio's own timeouts — needed because Dio's web/browser adapter has no connect-phase timeout hook) and maps every failure mode to a typed `ApiException` via `dio_error_mapper.dart`. This is the *only* place that touches `Dio`/`DioException` directly. - **`lib/data/repositories/*_repository.dart`** — one repository per REST resource group (`NodeRepository`, `ServicesRepository`, `NodesRepository`), each a thin pass-through over `NodeMasterApiClient`. Each repository has a paired `@riverpod` provider (e.g. `nodeRepositoryProvider`) that returns **null when there's no active connection** — this null-propagation pattern is used consistently up through `apiClientProvider` so every downstream provider gets the same "no connection" signal without re-deriving it. - **`lib/features//`** — one folder per nav destination (`dashboard`, `services`, `backups`, `fleet`, `updates`, `settings`), each with a top-level `_screen.dart`, a `widgets/` subfolder for feature-local dialogs/rows, and a `providers/` subfolder where the screen needs bespoke async state beyond a bare repository call. ### State management: Riverpod 3 with code generation All providers use `@riverpod`/`@Riverpod(...)` annotations + generated `.g.dart` companions (not the older manual `Provider`/`StateNotifierProvider` syntax). `main.dart` disables Riverpod 3's container-wide auto-retry (`retry: (retryCount, error) => null`) because the app already has its own explicit refresh model (manual pull-to-refresh + `PollingMixin` for two screens) — a blanket retry-with-backoff would silently fight that. Widget tests must pass the same `retry: null` override or a deliberately-unreachable test connection won't settle into `AsyncError` deterministically. `lib/core/polling/polling_async_notifier.dart`'s `PollingMixin` is mixed into an `AutoDisposeAsyncNotifier` to re-fetch on a timer while the provider has a listener (used by Dashboard's summary and Fleet's aggregated view — the API has no push/websocket, so this is a deliberate "poll while visible" model, not a live stream). It never emits an intermediate `AsyncLoading` on refresh ticks, so a background poll never flashes the UI back to a spinner. Other list-backed screens (Services, node backup config, updates) are manual-refresh only, on purpose — a silent background refresh could yank state out from under an in-progress edit dialog. ### Connections (multi-host support) `lib/core/connections/` models a list of saved API endpoints (`Connection { id, name, baseUrl }`), persisted as plain JSON in `SharedPreferences` via `ConnectionStorage` (explicitly *not* a secret store — the API has no auth, so a saved base URL isn't sensitive by itself). `activeConnectionProvider` (in `connection_providers.dart`) is the single source everything downstream derives from: `apiClientProvider` rebuilds (never mutates) a fresh `Dio`/`NodeMasterApiClient` whenever it changes, which cascades through every repository and feature provider automatically. ### Routing `lib/core/routing/app_router.dart` uses `go_router` with a single `@Riverpod(keepAlive: true)` `GoRouter`. A `_ConnectionRefreshListenable` bridges `activeConnectionProvider` into go_router's `refreshListenable` so removing the active connection redirects to Settings immediately rather than waiting for the next navigation. All feature screens sit inside one `ShellRoute` wrapping `NavRailShell` (the persistent side nav). ### Models (`lib/core/models/`) `@freezed` sealed classes + `json_serializable` (`explicit_to_json: true` in `build.yaml`), one file per Go struct, with `part 'x.freezed.dart'` / `part 'x.g.dart'`. Field renames to match the API's snake_case use `@JsonKey(name: '...')`. Write-only fields the API never echoes back (e.g. a password) should default to `null`/omitted-on-null (`includeIfNull: false`) rather than round-tripping a value the server will never actually send. `lib/core/models/models.dart` is the barrel export — import that, not individual model files, from feature code. ### Theming `lib/core/theme/` defines two custom `ThemeExtension`s — `StatusColors` (good/warning/serious/critical, reserved exclusively for status semantics, never reused as a brand color) and `AppDataStyles` (monospace styles for numeric/data display). Access via the `BuildContext` extension in `theme_x.dart`: `context.status.good`, `context.dataStyles.dataMono`, `context.colors`, `context.text` — prefer these over raw `Theme.of(context).extension<...>()!` calls. `StatusPill` (`core/widgets/`) is the shared good/warning/serious/critical/neutral badge — reuse it rather than building ad hoc colored chips. ### Error handling `ApiException` (`core/network/api_exception.dart`) is a sealed/freezed-style type with variants for timeout, network, cancelled, server (with status code + message), parse, and unknown. UI code catches `ApiException` and reads `.userMessage` for display; it should never need to inspect `DioException` or raw status codes directly. ## Testing conventions Widget tests spin up a real `ProviderScope` with `retry: null` and `SharedPreferences.setMockInitialValues(...)` to seed connection state (see `test/widget_test.dart`) rather than mocking providers individually — this exercises the actual router-redirect and provider-null-propagation logic. A deliberately unreachable connection (`http://127.0.0.1:1`) is used to get a fast, deterministic connection-refused error without depending on real network access. `mocktail`/`fake_async` are available as dev dependencies for narrower unit tests (e.g. `dio_error_mapper_test.dart`).