# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview Lichess Mobile is a Flutter-based mobile application (iOS/Android) for lichess.org. The app uses: - **Flutter**: Cross-platform UI framework (Flutter 3.38.0+, Dart 3.10.0+) - **Riverpod**: State management with providers - **Freezed**: Immutable data classes - **Code generation**: For data classes, JSON serialization, and localization - **Firebase**: Crashlytics and messaging - **Stockfish**: Chess engine integration via `multistockfish` package ## Development Setup ### Initial Setup ```bash # Install dependencies flutter pub get # Generate code (required before first run) dart run build_runner build # For continuous development dart run build_runner watch & flutter analyze --watch & ``` ### Running the App ```bash # Run on all devices (uses lichess.dev server by default) flutter run -d all # Run with custom server flutter run \ --dart-define=LICHESS_HOST=localhost:8080 \ --dart-define=LICHESS_WS_HOST=localhost:8080 ``` **Note**: Do not include scheme (https:// or ws://) in host values. ### For Android with local lila-docker ```bash # Map ports adb reverse tcp:8080 tcp:8080 # Run app flutter run --dart-define=LICHESS_HOST=localhost:8080 --dart-define=LICHESS_WS_HOST=localhost:8080 ``` ## Testing & Quality ### Run Tests ```bash # All tests flutter test # Single test file flutter test test/model/engine/engine_test.dart # Specific test flutter test test/model/engine/engine_test.dart --name "test name" ``` ### Testing Strategy: Prefer HTTP Mocking Over Provider Overrides **Always mock at the HTTP layer** (override `httpClientFactoryProvider`) rather than overriding Riverpod providers directly. Reasons: 1. **`autoDispose` + `ref.read` interaction**: Many providers are `FutureProvider.autoDispose` and use `ref.withClientCacheFor` which calls `keepAlive()` to prevent premature disposal. Overriding the provider directly bypasses `keepAlive()`, so the provider can be disposed before its future resolves — causing silent test failures where navigation never happens. 2. **Tests provider logic, not mocks**: Most providers contain real logic (caching, fallback, data transformation) that should be exercised in tests. Replacing a provider with `(_) async => fakeValue` skips all that logic. **Pattern to use:** ```dart overrides: { httpClientFactoryProvider: httpClientFactoryProvider.overrideWith((ref) { return FakeHttpClientFactory( () => MockClient((request) async { if (request.url.path == '/api/puzzle/daily') { return http.Response(mockDailyPuzzleResponse, 200); } return http.Response('', 404); }), ); }), }, ``` Direct provider overrides are acceptable for **non-network providers** (repositories backed by mocks, services with no HTTP, etc.) where the provider has no `keepAlive` dependency and the override doesn't skip meaningful logic. ### Chessboard Testing Patterns Since chessground v10, pieces and highlights are rendered by `CustomPainter`s, not as individual widgets. **Do not use `find.byKey(Key('e2-whitepawn'))` or `find.byKey(ValueKey('${sq}-highlight'))` — those keys no longer exist.** Use the helpers in `test/test_helpers.dart` instead: ```dart // Check pieces on any Chessboard (interactive or Chessboard.fixed) getBoardPieces(tester) // Map boardHasPiece(tester, Square.f3, Piece.whiteKnight) // bool // Check square highlights (squareHighlights prop on Chessboard.fixed) boardHasHighlight(tester, square) // bool // Check premove highlight boardHasPremove(tester, move) // bool // Tap/move at a board square squareOffset(square, tester.getRect(find.byType(Chessboard))) ``` For `ChessboardEditor` (board editor screen), read pieces directly from the widget: ```dart tester.widget(find.byType(ChessboardEditor)).pieces ``` ### Analysis Rules (CRITICAL) **Always run `flutter analyze` on every file you edit, including test files, before finishing.** Two rules the analyzer enforces that are easy to miss: - **`const` constructors**: use `const` (not `final`) when constructing a const-capable class. The analyzer will flag `prefer_const_constructors`. This applies everywhere, including test files. When the enclosing expression is not `const` (e.g. a non-const record or class), constructor calls inside it still need their own explicit `const` — e.g. `(time: const Duration(minutes: 3), increment: const Duration(seconds: 2))`. - **No leading underscores for local identifiers**: local variables and functions must not start with `_`. Reserve `_` for library-private top-level or class members. ### Code Quality Checks ```bash # Static analysis flutter analyze # Riverpod linting dart run custom_lint # Format check (files to format) dart format --output=none --set-exit-if-changed $(find lib/src -name "*.dart" -not \( -name "*.*freezed.dart" -o -name "*.*g.dart" -o -name "*lichess_icons.dart" \) ) dart format --output=none --set-exit-if-changed $(find test -name "*.dart" -not \( -name "*.*freezed.dart" -o -name "*.*g.dart" \) ) # Format all code dart format lib/src test ``` ### Formatting Rules (CRITICAL) **Always run `dart format` on every file you edit before finishing.** CI will fail if formatting is wrong. ```bash dart format path/to/file.dart ``` The formatter is configured via `analysis_options.yaml` (`formatter: page_width: 100`) and `dart format` picks this up automatically. Key rules enforced by the formatter: - **Page width: 100 characters** — lines exceeding 100 chars will be reflowed - **Trailing commas drive formatting**: a trailing comma after the last argument/parameter forces the formatter to expand the list to one-item-per-line; omitting it allows the formatter to keep items on one line if they fit within 100 chars - **Do not manually wrap lines** — let the formatter decide based on trailing commas and line length; hand-wrapping without trailing commas will be reformatted by the tool - The formatter may reformat code you didn't touch in the same expression if you change surrounding structure ## Translations (i18n) **CRITICAL**: Never manually edit `lib/l10n/app_*.arb` files - they are generated. ### Adding New Translations 1. Edit `translation/source/mobile.xml` for mobile-specific strings 2. Regenerate everything: ```bash ./scripts/gen-translations.sh ``` This runs `gen-arb.mjs`, `flutter gen-l10n`, and `gen-widget-strings.mjs` in order. Mobile-specific translations get a `mobile` prefix (e.g., "foo" becomes `mobileFoo` in Dart). ## Architecture ### Directory Structure ``` lib/src/ ├── model/ # Business logic, state management (Riverpod providers) │ ├── account/ │ ├── analysis/ │ ├── auth/ │ ├── challenge/ │ ├── common/ # Shared models and utilities │ ├── engine/ # Stockfish integration │ ├── game/ │ ├── puzzle/ │ ├── settings/ │ └── ... ├── view/ # UI screens and pages │ ├── account/ │ ├── analysis/ │ ├── game/ │ ├── home/ │ ├── play/ │ ├── puzzle/ │ ├── settings/ │ └── ... ├── widgets/ # Reusable UI components ├── network/ # HTTP client, WebSocket, connectivity ├── utils/ # Helper functions and utilities ├── styles/ # Theme, colors, icons ├── db/ # Local database (sqflite) ├── app.dart # Main app widget ├── binding.dart # Plugin/API abstraction layer └── constants.dart # App-wide constants ``` ### Key Architectural Patterns **State Management**: Riverpod providers throughout `lib/src/model/`. Controllers, repositories, and services are implemented as providers. State is immutable and managed with Freezed data classes. **Riverpod version: 3.x — API differences from 2.x (CRITICAL)** This project uses `flutter_riverpod 3.x` / `riverpod 3.x`. Several APIs changed from 2.x: - **`AsyncValue.value`** (not `valueOrNull`): In 3.x, `AsyncValue.value` returns `T?` (null while loading/error). `valueOrNull` no longer exists. - **`ProviderListenable` is not exported**: Do not use `ProviderListenable` as a type annotation — it is an internal interface. The return type of `.select()` is also internal (`_ProviderSelector`). `ProviderListenableSelect` is exported but is an *extension* on `ProviderListenable`, not a class — it cannot be used as a type. - When you need to pass a provider or select result to `ref.watch`/`ref.read`/`ref.listenManual`, just pass it directly without naming the type. If a return type annotation is required (e.g. an abstract method), use two concrete methods (`readCurrentState()` + `listenToStateChanges()`) instead of trying to name the provider type. - **`ref.listenManual` type inference**: When the selected type is nullable (e.g. `provider.select((v) => v.value)` → `T?`), Dart cannot infer `StateT` because the callback signature is `void Function(StateT?, StateT)`. Always add an explicit type: `ref.listenManual(provider.select(...), listener)`. - **`listenManual` in `ConsumerState`**: Subscriptions set up in `initState()` via `ref.listenManual` are automatically cancelled when the widget is disposed. No need to store or cancel manually. **Binding Layer**: `LichessBinding` (in `binding.dart`) provides a testable abstraction for plugins and external APIs: - SharedPreferences - Firebase (messaging, crashlytics) - Stockfish factory Use `AppLichessBinding.ensureInitialized()` in production, `TestLichessBinding` in tests. **Network Layer**: - HTTP: `lib/src/network/http.dart` - Platform-specific clients (Cronet for Android, Cupertino for iOS) with authentication, caching, and retry logic - WebSocket: `lib/src/network/socket.dart` - Handles ping/pong, message acks, auto-reconnection, event versioning - Helper: `lichessUri(path, queryParams)` for HTTP, `lichessWSUri(path, queryParams)` for WebSocket **Services**: Long-running background services initialized in `app.dart`: - `AccountService`, `NotificationService`, `MessageService`, `ChallengeService`, `CorrespondenceService` - Start in `_AppState.initState()` **Navigation**: Uses Flutter's Navigator with custom route resolution via `app_links.dart` for deep linking. ## Dart Event Loop Execution Model Understanding Dart's event loop is critical for this codebase due to heavy async operations (network requests, WebSocket communication, Stockfish engine interaction). ### Event Loop Basics Dart is single-threaded and uses an event loop with two queues: 1. **Microtask Queue** (higher priority) - Executed before the event queue - Scheduled with `scheduleMicrotask()` or via `Future` completions - Used internally by `Future.then()`, `async`/`await` 2. **Event Queue** (lower priority) - I/O events, timers, user interactions - Scheduled with `Future()`, `Timer`, `Stream` events - UI rendering happens between event queue items **Execution order**: ``` 1. Execute current synchronous code 2. Process ALL microtasks (until microtask queue is empty) 3. Process ONE event from event queue 4. Repeat from step 2 ``` ### Async/Await Behavior ```dart // This does NOT block the event loop Future fetchData() async { final response = await http.get(uri); // Yields to event loop // Resumes here when response completes processData(response); } ``` When `await` is encountered: 1. Current function execution pauses 2. Control returns to event loop 3. Function resumes as a microtask when Future completes ### Common Patterns in This Codebase **Riverpod AsyncNotifier**: State updates are async but don't block UI: ```dart class MyController extends AsyncNotifier { @override Future build() async { // Fetches async, UI shows loading state return await repository.getData(); } } ``` **WebSocket Message Handling** (see `lib/src/network/socket.dart`): - Messages arrive as events - Processed in event queue - Microtasks schedule state updates **Stockfish Engine Communication**: - Engine runs in isolate (separate event loop) - Communication via `SendPort`/`ReceivePort` (event queue) ### Important Gotchas **Microtask Queue Starvation**: Never create infinite microtask loops - they block the event queue and freeze the UI: ```dart // BAD - Starves event queue void badLoop() { scheduleMicrotask(() { doWork(); badLoop(); // Immediately schedules another microtask }); } // GOOD - Allows event queue processing void goodLoop() { Future(() { // Uses event queue doWork(); goodLoop(); }); } ``` **Future vs Future.microtask**: - `Future(callback)` → event queue - `Future.microtask(callback)` → microtask queue - Prefer event queue for non-critical work **Stream Subscriptions**: Always cancel to prevent memory leaks: ```dart // In StatefulWidget or Riverpod final subscription = stream.listen(onData); @override void dispose() { subscription.cancel(); // Critical! super.dispose(); } ``` **Testing with fake_async**: Use `FakeAsync` for tests involving timers and microtasks to control time progression. ## Coding Standards ### Immutability (Required) All data structures must be immutable (all fields `final` or `late final`): - Use **Freezed** for data classes - Use **fast_immutable_collections** for collections in public APIs - Standard Dart collections (`List`, `Map`) are forbidden in public APIs but allowed in local scopes ### Strong Typing Prefer strong types over primitives (e.g., `Duration` instead of `int`). ### Dot Shorthand Syntax (Dart 3.10+) Use dot shorthand syntax (`.foo`) to write more concise code when the type can be inferred from context. This is especially useful for enums, named constructors, and static members. ```dart // Enums - use shorthand Status current = .running; // Good Status current = Status.running; // Verbose // Switch statements switch (status) { case .running: ... case .stopped: ... } // Named constructors Point p = .origin(); // Good Point p = Point.origin(); // Verbose // Widget properties MainAxisAlignment: .center, // Good MainAxisAlignment: MainAxisAlignment.center, // Verbose // Equality checks (shorthand on right side) if (color == .red) ... // Good if (.red == color) ... // Won't work ``` **Note**: Shorthand requires clear context type inference and cannot start expression statements. ### Functional Style Prefer functional constructs over imperative: ```dart // Good return [ if (check) Text('conditional'), for (el in items) Text(el.name), ]; // Bad final widgets = []; if (check) widgets.add(Text('conditional')); for (el in items) widgets.add(Text(el.name)); ``` ### Widget Guidelines - Avoid functions returning widgets (use `StatelessWidget` for reusables) - Don't create private widgets used only once - inline them - Write reusable widgets as classes even if single-screen scope ### Analysis - Strict mode enabled: `strict-casts`, `strict-inference`, `strict-raw-types` - Use single quotes for strings - Always use package imports (no relative imports) - Page width: 100 characters - Generated files (`*.g.dart`, `*.freezed.dart`) are excluded from analysis ## Code Generation This project heavily uses code generation. Always run `dart run build_runner build` (or `watch`) after: - Modifying Freezed classes - Adding JSON serialization - Changing models with code generation annotations Generated files are NOT committed to git. ## Common Gotchas - **Don't edit generated files**: Anything ending in `.g.dart`, `.freezed.dart`, or in `lib/l10n/` - **Translations**: Start with hardcoded English text for new features. Add translations after the feature is stable and in use - **Error messages**: Don't translate non-critical error messages (e.g., "could not load XY") - **Brand names**: Don't translate names like "Puzzle Storm" or "Puzzle Streak" - **FVM users**: Remember to prefix commands with `fvm` (e.g., `fvm flutter test`) ## iOS Home Screen Widgets (WidgetKit Extension) The app includes a native iOS WidgetKit extension (`ios/LichessWidgets/`) providing home screen widgets. See `ios/EXTENSIONS.md` for contributor setup instructions (requires Apple Developer account configuration). ### Architecture - **`LichessWidgetsBundle.swift`** — `@main` entry point registering all 4 widgets. - **`LichessAppGroup.swift`** — Reads shared `UserDefaults` from App Group `group.org.lichess.mobileV2.LichessWidgets`: - `lichessHost`, `boardTheme`, `pieceSet`, `isKidMode` - **`Deeplinks.swift`** — Custom URI scheme encoding for opening URLs in the in-app browser. - **Dependencies**: WidgetKit, ChessgroundAssets (Swift Package, shared with Dart), FeedKit, XMLKit. ### Flutter Integration The Flutter app (via `home_widget` package) writes to the shared App Group from `app.dart`: - `lichessHost` — server URL - `boardTheme` / `pieceSet` — board appearance (triggers `DailyPuzzleWidget` reload) - `isKidMode` — hides blog widgets when active (triggers blog widget reload) When modifying widget-related settings or board theme/piece set preferences in Dart, ensure the corresponding `HomeWidget.saveWidgetData` call and `HomeWidget.updateWidget` are kept in sync in `lib/src/app.dart`. Widget UI strings are translated via `ios/LichessWidgets/Localizable.xcstrings` (a String Catalog). To add a new translatable string, register it under `WIDGET_KEYS` in `scripts/gen-widget-strings.mjs` and run `./scripts/gen-translations.sh`. ### Code Signing The extension has its own bundle ID (`org.lichess.mobileV2.LichessWidgets`) and App Group entitlement. fastlane `sync_code_signing` handles provisioning for both targets (see `ios/fastlane/Matchfile`). ## Debugging ```bash # Start DevTools for logging dart devtools # Then run app and follow printed link flutter run ```