This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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
multistockfishpackage
# 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 &# 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:8080Note: Do not include scheme (https:// or ws://) in host values.
# 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# 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"Always mock at the HTTP layer (override httpClientFactoryProvider) rather than overriding Riverpod providers directly. Reasons:
-
autoDispose+ref.readinteraction: Many providers areFutureProvider.autoDisposeand useref.withClientCacheForwhich callskeepAlive()to prevent premature disposal. Overriding the provider directly bypasseskeepAlive(), so the provider can be disposed before its future resolves — causing silent test failures where navigation never happens. -
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 => fakeValueskips all that logic.
Pattern to use:
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.
Always run flutter analyze on every file you edit, including test files, before finishing.
Two rules the analyzer enforces that are easy to miss:
constconstructors: useconst(notfinal) when constructing a const-capable class. The analyzer will flagprefer_const_constructors. This applies everywhere, including test files. When the enclosing expression is notconst(e.g. a non-const record or class), constructor calls inside it still need their own explicitconst— 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.
# 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 testAlways run dart format on every file you edit before finishing. CI will fail if formatting is wrong.
dart format path/to/file.dartThe 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
CRITICAL: Never manually edit lib/l10n/app_*.arb files - they are generated.
- Edit
translation/source/mobile.xmlfor mobile-specific strings - Regenerate everything:
./scripts/gen-translations.shThis 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).
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
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.
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.
Understanding Dart's event loop is critical for this codebase due to heavy async operations (network requests, WebSocket communication, Stockfish engine interaction).
Dart is single-threaded and uses an event loop with two queues:
-
Microtask Queue (higher priority)
- Executed before the event queue
- Scheduled with
scheduleMicrotask()or viaFuturecompletions - Used internally by
Future.then(),async/await
-
Event Queue (lower priority)
- I/O events, timers, user interactions
- Scheduled with
Future(),Timer,Streamevents - 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
// This does NOT block the event loop
Future<void> fetchData() async {
final response = await http.get(uri); // Yields to event loop
// Resumes here when response completes
processData(response);
}When await is encountered:
- Current function execution pauses
- Control returns to event loop
- Function resumes as a microtask when Future completes
Riverpod AsyncNotifier: State updates are async but don't block UI:
class MyController extends AsyncNotifier<Data> {
@override
Future<Data> 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)
Microtask Queue Starvation: Never create infinite microtask loops - they block the event queue and freeze the UI:
// 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 queueFuture.microtask(callback)→ microtask queue- Prefer event queue for non-critical work
Stream Subscriptions: Always cancel to prevent memory leaks:
// 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.
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
Prefer strong types over primitives (e.g., Duration instead of int).
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.
// 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 workNote: Shorthand requires clear context type inference and cannot start expression statements.
Prefer functional constructs over imperative:
// Good
return [
if (check) Text('conditional'),
for (el in items) Text(el.name),
];
// Bad
final widgets = <Widget>[];
if (check) widgets.add(Text('conditional'));
for (el in items) widgets.add(Text(el.name));- Avoid functions returning widgets (use
StatelessWidgetfor reusables) - Don't create private widgets used only once - inline them
- Write reusable widgets as classes even if single-screen scope
- 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
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.
- Don't edit generated files: Anything ending in
.g.dart,.freezed.dart, or inlib/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)
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).
LichessWidgetsBundle.swift—@mainentry point registering all 4 widgets.LichessAppGroup.swift— Reads sharedUserDefaultsfrom App Groupgroup.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.
The Flutter app (via home_widget package) writes to the shared App Group from app.dart:
lichessHost— server URLboardTheme/pieceSet— board appearance (triggersDailyPuzzleWidgetreload)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.
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).
# Start DevTools for logging
dart devtools
# Then run app and follow printed link
flutter run