diff --git a/.claude/rules/generated.md b/.claude/rules/generated.md new file mode 100644 index 0000000..dcfada7 --- /dev/null +++ b/.claude/rules/generated.md @@ -0,0 +1,28 @@ +--- +paths: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "**/*.mocks.dart" + - "lib/core/localization/generated/**" +--- + +# Generated files — do not edit + +This file is generated. Never edit by hand. Edit the source and regenerate. + +## How to find the source +- `*.g.dart` → JSON-serializable / retrofit / envied / hive source in the sibling file. Look for `@JsonSerializable`, `@RestApi`, `@Envied`, `@HiveType`. +- `*.freezed.dart` → freezed model with `@freezed` in the sibling `.dart` file. +- `*.mocks.dart` → mockito `@GenerateMocks([...])` annotation in the sibling test file. +- `lib/core/localization/generated/**` → generated by `flutter gen-l10n` from `.arb` files (Flutter `generate: true`). + +## Regenerate +- One-shot: `dart run build_runner build --delete-conflicting-outputs` +- Watch: `dart run build_runner watch --delete-conflicting-outputs` +- Localization: `flutter gen-l10n` (or `flutter pub get` triggers it when `generate: true`). +- Always format after generation: `dart format lib/ test/`. + +## Hard rules +- Never paste fixes into a generated file — they are overwritten on next codegen. +- If a generated file is missing, run build_runner — do not create it by hand. +- Generated files are excluded from `flutter analyze` via `analysis_options.yaml`, so analyzer silence is not a green light to edit them. diff --git a/.claude/rules/state.md b/.claude/rules/state.md new file mode 100644 index 0000000..eca8995 --- /dev/null +++ b/.claude/rules/state.md @@ -0,0 +1,29 @@ +--- +paths: + - "lib/**/*_cubit.dart" + - "lib/**/*_state.dart" + - "lib/**/presentation/cubits/**/*.dart" +--- + +# State management — flutter_bloc (Cubit) + +This repo uses **Cubit** from `flutter_bloc` 9. Do not introduce `Bloc`, `ChangeNotifier`, `ValueNotifier`, Riverpod, or GetX. + +## File / class layout +- File: `_cubit.dart` in `lib/features//presentation/cubits/`. +- Companion state: `_state.dart` declared as `part of '_cubit.dart'` (see `sign_in_cubit.dart` / `sign_in_state.dart`). +- Cubit class: `final class Cubit extends Cubit<State>`. +- State: `@freezed` sealed class with named factory constructors (`initial`, `inProgress`, `succeed`, `failed`, …). After editing, run build_runner. +- Each cubit constructor takes its dependencies (repository / service) as positional `final` fields. DI wires them in `lib/core/di/di.dart`. + +## Inside a cubit +- Returns from repositories are `Result`. Switch on `Success(:final data)` / `Failure(:final error)` and emit the matching state — see `sign_in_cubit.dart`. +- Guard re-entrancy: check `state.maybeWhen(inProgress: () => true, orElse: () => false)` before starting an async op. +- After `await`, check `if (isClosed) return;` before `emit`. +- Do NOT perform navigation, snackbars, or dialogs inside the cubit — UI listens to state and reacts. +- Do NOT call `dio` / HTTP directly — go through a repository in `data/repositories/`. + +## Tests +- Use `bloc_test` (already a dev dep). One file per cubit in `test/features//presentation/cubits/`. +- Mock repositories with `mockito` (`@GenerateMocks([AuthRepository])` → `.mocks.dart`). +- Cover happy path + each `Failure` branch. diff --git a/.claude/rules/tests.md b/.claude/rules/tests.md new file mode 100644 index 0000000..80bafcf --- /dev/null +++ b/.claude/rules/tests.md @@ -0,0 +1,30 @@ +--- +paths: + - "test/**/*.dart" +--- + +# Test rules + +## Layout +- `test/` mirrors `lib/` 1:1 (e.g. `lib/features/auth/presentation/cubits/sign_in_cubit.dart` → `test/features/auth/presentation/cubits/sign_in_cubit_test.dart`). +- Shared fixtures per feature in `test/features//support/_dto_fixtures.dart`. +- One test file per production file, suffix `_test.dart`. + +## Frameworks +- `flutter_test` for widget tests. +- `mockito` 5 for mocks (this repo does **not** use `mocktail`). Declare via `@GenerateMocks([Foo, Bar])` at the top of the test file; mocks land in the sibling `_test.mocks.dart`. Regenerate with `dart run build_runner build --delete-conflicting-outputs`. +- `bloc_test` 10 for cubits — use `blocTest(...)` with `build`, `act`, `expect`. +- `fake_async` for time-sensitive code instead of real delays. + +## What to cover +- Every cubit: happy path + each `Failure` branch from the repository. +- Every repository: success + each `Failure` mapping from `DioException` / parsing errors. +- Every mapper: DTO ↔ domain round-trip. +- Validators (`presentation/validators/`): valid + each invalid branch. +- Network interceptors / mappers: see existing tests under `test/core/network/` for the pattern. + +## Don'ts +- No `Future.delayed` for timing tests — use `fake_async` or pump helpers. +- No real network — mock the API client (`*_api.dart` retrofit interface). +- No real `FlutterSecureStorage` / `Hive` — mock the service wrapper from `lib/core/services/`. +- Do not edit `*.mocks.dart` — regenerate via build_runner. diff --git a/.claude/rules/widgets.md b/.claude/rules/widgets.md new file mode 100644 index 0000000..9992384 --- /dev/null +++ b/.claude/rules/widgets.md @@ -0,0 +1,40 @@ +--- +paths: + - "lib/**/*_page.dart" + - "lib/**/*_widget.dart" + - "lib/**/presentation/**/*.dart" + - "lib/uikit/**/*.dart" +--- + +# Widget rules + +## Naming +- Screens are `_page.dart` in `lib/features//presentation/pages/` (this repo does **not** use `_screen.dart`). +- Reusable widgets local to a feature: `lib/features//presentation/widgets/_widget.dart`. +- Cross-feature reusable widgets / design-system: `lib/uikit//` (`buttons/`, `dialogs/`, `inputs/`, `cards/`, `images/`, `menus/`, `themes/`). +- A page that needs DI/BlocProvider wiring uses a separate `_page_builder.dart` next to the page (see `sign_up_page_builder.dart`, `verify_reset_code_page_builder.dart`). +- Route argument holders live next to the page: `_route_args.dart`. + +## Widget construction +- `StatelessWidget` by default. Use `StatefulWidget` only for local-only state that can't live in a cubit (focus nodes, controllers, animations). +- `const` constructors everywhere possible — analyzer warns on `prefer_const_constructors` / `prefer_const_literals_to_create_immutables`. +- Use `super.key` (`use_super_parameters` lint enforced). +- Constructor params: named, `required` for non-nullable, no positional booleans (`avoid_positional_boolean_parameters`). +- Single quotes for strings (`prefer_single_quotes`). +- Imports inside `lib/` are relative (`prefer_relative_imports`). +- Every public member needs a dartdoc (`///`) — `public_member_api_docs` is enabled. +- `flutter_hooks` is **not** a dependency — do not use hooks. + +## Composition +- If a sub-widget is used in only one place, make it a private `class _Foo extends StatelessWidget` in the same file. +- Split `build()` when it exceeds ~100 lines into private sub-widgets or factory methods. +- Wire cubits with `BlocProvider` / `BlocBuilder` / `BlocListener` / `BlocConsumer`. Read repositories from `di()` (get_it) inside the page builder, not inside `build()`. +- Do not pass `BuildContext` through fields. Do not capture it across `await` without an `if (!context.mounted) return;` check. + +## Navigation +- Use `context.go(...)` / `context.push(...)` / `context.pop()` only. Route paths come from `AppRoutePaths` in `lib/core/router/router_paths.dart`. +- New routes go through `lib/core/router/router.dart` — do not call `Navigator` directly. + +## Theming / assets +- Colors, gradients, text styles live under `lib/uikit/themes/`. Don't hardcode `Color(0x...)` or `TextStyle(...)` in feature widgets. +- Image assets via `flutter_svg` (`SvgPicture.asset`) or `cached_network_image` for network. Asset paths come from `lib/core/constants/`. diff --git a/.gitignore b/.gitignore index a421189..b48bfde 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,6 @@ android/app/google-services.json # Generated localization lib/core/localization/generated/ + +# Claude Code personal config +CLAUDE.local.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a04b931..d8ec747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Profile saved cards section for the authenticated `/profile` tab, including dedicated cards API/repository flow, saved-cards list rendering, add-card dialog with manual card form, default-card command, delete-card confirmation, and local refresh after successful actions. - Authenticated subscriptions catalog screen, including dedicated subscriptions route, catalog Cubit, card UI with normalized remote images, and a profile CTA for opening available subscription plans. - Authenticated subscription details and payment flow, including a dedicated details route, catalog-backed item resolution, manual-card payment dialog, and redirect to `/profile` after successful purchase. +- Claude Code project config: `CLAUDE.md` with stack, commands, conventions, hard rules and PR workflow; path-scoped rules for generated files, state management, widgets, and tests; `docs/architecture.md` with full architecture reference. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ffcd637 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# moveUP — mobile fitness app + +Mobile client for the moveUP fitness platform. Targets iOS and Android. + +## Stack +- Flutter 3.44.0 (3.41.0 in CI), Dart SDK `>=3.10.0 <4.0.0`, channel stable +- State: `flutter_bloc` 9 (Cubit pattern, not Bloc) +- DI: `get_it` 9 + `provider` 6 (Provider used for widget-tree DI only, not state) +- Routing: `go_router` 17 (paths in `lib/core/router/router_paths.dart`) +- HTTP: `dio` 5 + `retrofit` 4 + `dio_cookie_manager` + `cookie_jar` +- Models / codegen: `freezed` 3 + `json_serializable` 6 + `retrofit_generator` + `envied_generator` +- Local storage: `hive_ce_flutter` 2 + `flutter_secure_storage` 10 +- Env: `envied` (obfuscated, reads `.env` at root) +- Tests: `flutter_test` + `mockito` 5 + `bloc_test` 10 + `fake_async` +- Logging: `logger` + +## Commands +- `flutter pub get` — install deps +- `dart run build_runner build --delete-conflicting-outputs` — codegen one-shot +- `dart run build_runner watch --delete-conflicting-outputs` — codegen watch +- `dart format lib/ test/` — format (page width 100, trailing commas preserved) +- `flutter analyze --fatal-infos` — lints (CI fails on infos) +- `flutter test` / `flutter test --coverage` — unit + widget tests +- `flutter run` — run on attached device / simulator +- `dart doc --output doc/api` — generate dartdoc + +## Repo layout +- `lib/core/` — `di/`, `router/`, `env/`, `network/`, `services/`, `failures/`, `result/`, `constants/`, `utils/` +- `lib/features//` — feature-first, layers `data/` + `domain/` + `presentation/` (+ optional `support/`) +- `lib/features//presentation/` — `pages/`, `cubits/`, `widgets/`, `validators/` +- `lib/uikit/` — shared `buttons/`, `dialogs/`, `images/`, `themes/`, `inputs/`, `cards/`, `menus/` +- `lib/main.dart` → `lib/runner.dart` — single entrypoint, no flavors +- `test/` — mirror of `lib/` +- `assets/` — `icons/`, `images/`, `splash/`, `legal/`, `fonts/montserrat/` + +## Conventions +- Files: `snake_case`. Suffixes used in this repo: `_page.dart` (screens, **not** `_screen.dart`), `_widget.dart`, `_cubit.dart`, `_state.dart`, `_repository.dart`, `_dto.dart`, `_mapper.dart`, `_failure.dart`, `_api_client.dart`. +- State files: `part of` cubit, generated as `_state.dart`; cubit class often `final class`. +- Imports: relative (`prefer_relative_imports` enabled) within `lib/`. +- Strings: single quotes (`prefer_single_quotes`). +- `const` everywhere possible (warning if missed). +- All public members require dartdoc (`public_member_api_docs`). +- Commits: Conventional Commits (`feat(auth): ...`, `fix(...): ...`, `chore(...): ...`). +- Branches: `feat/`, `fix/`, `chore/`. Default branch is `develop`; release PRs target `main`. + +## Hard rules +- Never edit generated: `**/*.g.dart`, `**/*.freezed.dart`, `**/*.mocks.dart`, `lib/core/localization/generated/**`. After changing a `@freezed` / `@JsonSerializable` / `@RestApi` / `@Envied` source → run `dart run build_runner build --delete-conflicting-outputs`. +- Don't add or upgrade dependencies in `pubspec.yaml` without explicit request. +- Don't touch `ios/`, `android/`, `Dockerfile`, `.github/workflows/**` without explicit request. +- Don't use `Navigator.push` / `Navigator.pop` directly — use `context.go` / `context.push` / `context.pop` (go_router). Add new routes in `lib/core/router/router_paths.dart` + `router.dart`. +- Don't introduce a new state-management lib. Use Cubit from `flutter_bloc`. +- Don't bypass the `Result` / `Failure` pattern in `lib/core/result/` + `lib/core/failures/` — repositories return `Result`, cubits switch on `Success` / `Failure`. + +## Scope discipline +- Modify only files relevant to the current task. +- See an unrelated issue? Mention it in the final message — don't fix it. If user wants leave `// TODO(claude): `. +- Large refactor (>100 lines or 3+ files): outline plan → wait for confirmation → code. +- Don't mass-rename or reformat without explicit request. + +## When to ask +- Ambiguous requirements (more than one reasonable interpretation). +- Before adding any new package to `pubspec.yaml`. +- Before changing a public API of a widget / cubit / repository used in 3+ places. +- Before changing the navigation graph (new top-level route, redirect logic). +- Task touches >5 files and the plan is non-obvious. + +## PR +- Before `gh pr create`: `dart format lib/ test/ && flutter analyze --fatal-infos && flutter test`. +- Target branch: `develop` (CI runs `develop-analysis.yml`). Release PRs target `main` (`main.yml`, full Android build). +- Title: Conventional Commits, under 70 chars, without body (1 line only). +- Body: **Why** / **What** / **How to test**, 1–3 lines each. Link issue `Closes #N`. +- Don't merge yourself. + +## Keeping docs current +- New feature establishes a pattern → add/update the relevant `.claude/rules/*.md`. +- Dependency or layer changes → update `Stack` above and `docs/architecture.md`. +- One fact, one place: if a rule lives in `CLAUDE.md` and in a rules file, remove one and link. +- If you corrected Claude on the same thing twice — that's a signal to write a rule. + +## Extended docs (NOT auto-loaded) +- `docs/architecture.md` — feature-first layers, data flow +- `README.md` — project overview, setup, Docker build +- `CHANGELOG.md` — release history + +## Env notes +- `.env` at repo root (gitignored). Required: `API_URL` (e.g. `http://127.0.0.1:8000/`). CI falls back to `http://127.0.0.1:8000/` if secret missing. +- Envied is obfuscated — after changing `.env` regenerate via build_runner. +- No flavors. Single `main.dart` → `runner.dart` bootstrap. +- Local extras live in `CLAUDE.local.md` (gitignored). diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..1b696be --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,154 @@ +# Architecture + +## Overview +moveUP is a Flutter mobile client for the moveUP fitness platform (iOS + Android). +The codebase is organized **feature-first with Clean Architecture** inside each feature: `data → domain → presentation` layers. Cross-cutting concerns (DI, router, network, storage, errors) live in `lib/core/`. + +## Project structure +```text +lib/ +├── core/ +│ ├── constants/ # shared strings + asset references +│ ├── di/ # get_it container setup +│ ├── env/ # envied-based config (.env) +│ ├── failures/ # network + feature Failure hierarchies (freezed) +│ ├── network/ # dio setup, interceptors, mappers, error DTOs +│ ├── result/ # Result sealed type +│ ├── router/ # go_router config + AppRoutePaths +│ ├── services/ # cross-feature services (token storage, network status, hive boxes) +│ └── utils/ # logger, analytics +├── features/ +│ └── / +│ ├── data/ # *_dto.dart, *_api.dart (retrofit), repositories/, mappers/ +│ ├── domain/ # entities, repository contracts +│ ├── presentation/ # pages/, cubits/, widgets/, validators/ +│ └── support/ # feature-internal helpers +├── uikit/ +│ ├── buttons/ dialogs/ images/ inputs/ cards/ menus/ themes/ +└── main.dart → runner.dart (single entrypoint, no flavors) +``` + +## Layers +Each feature has three layers; each layer only knows about what is below it: + +```text +Presentation → Domain ← Data + (Cubit, Page) (Entity, (DTO, ApiClient, + Repo iface) RepoImpl, Mapper) +``` + +- **Domain** — pure entities and abstract repository interfaces. No knowledge of Dio, DI, or Cubit. +- **Data** — repository implementations. Depends on a retrofit client and the domain interface. Maps DTO ↔ Entity and `DioException` → `Failure`. +- **Presentation** — Cubit + Page/Widget. The Cubit calls `repository.method()`, receives `Result`, and switches state via `switch`. +- There is no Use Case layer — the Cubit calls the repository directly. If logic grows complex, extract it into a separate class in `domain/` manually. + +## State management +Uses **Cubit** from `flutter_bloc`. Not Bloc, not Riverpod, not ChangeNotifier. + +```dart +// typical pattern (see sign_in_cubit.dart as a reference) +emit(const State.inProgress()); +final result = await _repository.call(); +if (isClosed) return; +switch (result) { + case Success(:final data): emit(State.succeed(data)); + case Failure(:final error): emit(State.failed(error)); +} +``` + +- State — `@freezed` sealed class in a separate file `_state.dart` (`part of '_cubit.dart'`). +- Before async: guard against re-entrancy via `state.maybeWhen(inProgress: () => true, orElse: () => false)`. +- After `await`: always `if (isClosed) return;` before `emit`. +- Navigation, snackbars, and dialogs happen in the UI layer only; the Cubit only emits state. + +**Global singletons in DI** (alive for the entire app lifetime): +- `AuthSessionCubit` — manages session state: `initial → checking → authenticated | unauthenticated | guestResumeAvailable | guest | guestCompletedOnboarding | restoreFailed`. +- `NetworkCubit` — listens to `NetworkService` (connectivity_plus), emits `initial | connected | disconnected`. +- `ProfileRefreshCubit` — workaround: the shared `/profile` endpoint is used by multiple features; this cubit acts as a refresh signal without creating direct dependencies between features. + +All other cubits are created in `*_page_builder.dart` via `BlocProvider` and live as long as the widget tree. + +## Navigation + +Router — **GoRouter 17**, config in `lib/core/router/router.dart`, paths in `AppRoutePaths`. + +Redirect runs on every emission from `AuthSessionCubit.stream` or `NetworkCubit.stream` (`CombinedRouterRefreshListenable`). Logic is two-tiered: + +**1. Startup splash lock** — no redirects until `startupSplashDuration` (1500 ms) elapses. `completeStartupSplash()` is called from `runner.dart` via `addPostFrameCallback`. + +**2. Network gate** — if `disconnected`, any path → `/offline`. On reconnect, redirect is determined by session state. + +**3. Auth gate** by `AuthSessionState`: + +| AuthState | Redirects to | +|---|---| +| `initial` / `checking` | `/splash` | +| `unauthenticated` / `restoreFailed` / `guestResumeAvailable` | `/auth/sign-in` | +| `guest` | `/fitness-start/quiz` | +| `guestCompletedOnboarding` | `/auth/sign-up` | +| `authenticated` | `/workouts` (or stays if the path is allowed) | + +The root shell (`StatefulShellRoute.indexedStack`) has three tabs: `/tests`, `/workouts`, `/profile`. + +Adding a new route: +1. Add a constant in `AppRoutePaths`. +2. Add a `GoRoute` in `router.dart`. +3. If it needs guarding, add a condition in `_redirectByAuth` / `_redirectFromOffline`. + +## Data / API +**Network layer:** +- One `Dio` instance for all requests; `refreshDio` is a separate instance used only for `/auth/refresh` (avoids interceptor loop). +- `AuthInterceptor` — attaches `Authorization: Bearer `, automatically refreshes on 401. +- `CookieManager` — manages guest cookies via `PersistCookieJar`. +- `LoggingInterceptor` — debug mode only. +- Timeouts: connect 10 s, receive 15 s, send 10 s. + +**Retrofit clients** (`*_api_client.dart`) — one client per feature (exception: `profile` is split into three clients — profile, parameters, statistics). + +**Error mapping** (`DioException` → `NetworkFailure`): + +| HTTP | Failure | +|---|---| +| 400 | `BadRequestFailure` | +| 401 | `UnauthorizedFailure` | +| 403 | `ForbiddenFailure` | +| 404 | `NotFoundFailure` | +| 409 | `ConflictFailure` | +| 422 | `ValidationFailure` (+ `errors: Map>`) | +| 429 | `RateLimitedFailure` | +| 5xx | `ServerErrorFailure` | +| timeout | `ConnectionTimeoutFailure` | +| no connection | `NoNetworkFailure` | +| else | `UnknownNetworkFailure` | + +Feature-specific failures (`AuthFailure`, …) add typed business-logic semantics on top of `NetworkFailure` where needed (e.g. `UnauthorizedAuthFailure` in `AuthSessionCubit`). + +## Dependency injection +Single container — `GetIt.instance` (`di`), configured in `setupDI()` before `runApp`. + +Registration order in `di.dart`: +1. Hive box (opened async before registrations) +2. Logger → AppLogger +3. Analytics +4. Connectivity → NetworkService → **NetworkCubit** (singleton) +5. TokenStorage, FitnessStartProgressStorage (Hive), CookieJar, GuestSessionStorage +6. Dio (AuthInterceptor + CookieManager + LoggingInterceptor) +7. ApiClients → Repositories (per feature) +8. **ProfileRefreshCubit** (singleton — workaround for profile refresh via shared endpoint) +9. **AuthSessionCubit** (singleton; depends on AuthRepository, TokenStorage, FitnessStartProgressStorage, GuestSessionStorage) +10. Tests, Workouts ApiClients → repositories + +Page-level cubits are created in `*_page_builder.dart`: +```dart +BlocProvider(create: (_) => MyFeatureCubit(di())) +``` + +## Storage +| What | Storage | Implementation | +|---|---|---| +| Access token | `flutter_secure_storage` | `SecureTokenStorage` | +| Guest onboarding progress | `hive_ce_flutter` (named box) | `HiveFitnessStartProgressStorage` | +| Guest backend session cookies | `PersistCookieJar` (file-based, app support dir) | `CookieJarGuestSessionStorage` | + +- Token is cleared on logout and when a 401 cannot be recovered after a refresh attempt. +- Guest data (Hive + cookies) is cleared on successful authentication and on explicit progress reset.