Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ jobs:
- name: Analyze
run: flutter analyze

# dart_code_linter's per-function metrics (source-lines-of-code,
# cyclomatic-complexity, nesting, parameter/method counts) aren't surfaced
# by `flutter analyze` — run the CLI gate so metric breaches fail CI.
# no-magic-number is a plugin rule, already enforced by Analyze above.
- name: Metrics (dart_code_linter)
run: >
dart run dart_code_linter:metrics analyze lib
--set-exit-on-violation-level=warning

# Runs every *_test.dart under test/ recursively, including the
# Drift schema smoke/UNIQUE tests in test/database/. Generated
# helpers are committed, so no codegen step is required here.
Expand Down
27 changes: 13 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,25 @@ explanations — plus test and iOS/SPM build notes — lives in
- **Providers:** function-style `@riverpod` only; class-based `@riverpod` only when state is mutable
- **Tests:** unit tests in `test/unit/`; widget tests in `test/widget/`; integration tests in `integration_test/`
- **No print statements** — use `debugPrint` only in development guards; never in production paths
- **Lints:** `riverpod_lint` is active via the `plugins:` block in `analysis_options.yaml` (native analysis_server_plugin — not a dependency, not `custom_lint`)
- **Lints:** `very_good_analysis` baseline + `riverpod_lint` and `dart_code_linter` active via the `plugins:` block in `analysis_options.yaml` (native analysis_server_plugins — not `custom_lint`)

## Code Quality Rules

These are not all lint-enforceable. `riverpod_lint` 3.x **is** active (native
`analysis_server_plugin`, enabled via the `plugins:` block in
`analysis_options.yaml` — no `custom_lint`). But the stock linter still has no
rule for magic numbers, identifier length, or file/class size, and
`custom_lint`-based packs remain blocked (analyzer-8 cap). So the rules below
stay conventions — follow them on every new or modified file:

- **No magic numbers.** Extract meaningful or repeated literals to named
`static const` or theme tokens (`AppSpacing`, `AppColors`, `AppTypography`).
Only `0`/`1` may appear inline. Animation/layout constants get intent names
(`_stageSize`, `_captionGap`), not bare numbers in the widget tree.
Magic numbers and per-function size/complexity are now **lint-enforced** by
`dart_code_linter` (config + exclusions in `analysis_options.yaml`).
**Identifier length** and **per-file size** have no rule, so they stay
conventions — follow every rule below on each new or modified file:

- **No magic numbers** (lint-enforced). Extract meaningful or repeated literals
to named `static const` or theme tokens (`AppSpacing`, `AppColors`,
`AppTypography`); only `0`/`1`/`2` inline, with intent names (`_stageSize`),
never bare numbers in the widget tree.
- **Descriptive names.** No single-letter identifiers except trivial loop
indices (`i`). Animation/controller values are `progress`, not `t`; phase
fractions get real names (`normalizedPhase`, not `p`).
- **Small files & classes.** Soft cap ≈ 250 lines/file. When a file grows
multiple `State`/widget classes or mixes UI with logic, split it. Keep
- **Small files & classes.** Per-file size stays a convention (soft cap ≈ 250
lines/file — no rule); per-function size/complexity is lint-enforced. Split a
file that grows multiple `State`/widget classes or mixes UI with logic; keep
`build()` declarative — push math and derivations into named helpers.
- **Extract pure helpers.** Animation math, mapping, and derivations live in a
sibling pure-Dart file (e.g. `*_animation.dart`) as named top-level functions,
Expand Down
27 changes: 13 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,26 +62,25 @@ explanations — plus test and iOS/SPM build notes — lives in
- **Providers:** function-style `@riverpod` only; class-based `@riverpod` only when state is mutable
- **Tests:** unit tests in `test/unit/`; widget tests in `test/widget/`; integration tests in `integration_test/`
- **No print statements** — use `debugPrint` only in development guards; never in production paths
- **Lints:** `riverpod_lint` is active via the `plugins:` block in `analysis_options.yaml` (native analysis_server_plugin — not a dependency, not `custom_lint`)
- **Lints:** `very_good_analysis` baseline + `riverpod_lint` and `dart_code_linter` active via the `plugins:` block in `analysis_options.yaml` (native analysis_server_plugins — not `custom_lint`)

## Code Quality Rules

These are not all lint-enforceable. `riverpod_lint` 3.x **is** active (native
`analysis_server_plugin`, enabled via the `plugins:` block in
`analysis_options.yaml` — no `custom_lint`). But the stock linter still has no
rule for magic numbers, identifier length, or file/class size, and
`custom_lint`-based packs remain blocked (analyzer-8 cap). So the rules below
stay conventions — follow them on every new or modified file:

- **No magic numbers.** Extract meaningful or repeated literals to named
`static const` or theme tokens (`AppSpacing`, `AppColors`, `AppTypography`).
Only `0`/`1` may appear inline. Animation/layout constants get intent names
(`_stageSize`, `_captionGap`), not bare numbers in the widget tree.
Magic numbers and per-function size/complexity are now **lint-enforced** by
`dart_code_linter` (config + exclusions in `analysis_options.yaml`).
**Identifier length** and **per-file size** have no rule, so they stay
conventions — follow every rule below on each new or modified file:

- **No magic numbers** (lint-enforced). Extract meaningful or repeated literals
to named `static const` or theme tokens (`AppSpacing`, `AppColors`,
`AppTypography`); only `0`/`1`/`2` inline, with intent names (`_stageSize`),
never bare numbers in the widget tree.
- **Descriptive names.** No single-letter identifiers except trivial loop
indices (`i`). Animation/controller values are `progress`, not `t`; phase
fractions get real names (`normalizedPhase`, not `p`).
- **Small files & classes.** Soft cap ≈ 250 lines/file. When a file grows
multiple `State`/widget classes or mixes UI with logic, split it. Keep
- **Small files & classes.** Per-file size stays a convention (soft cap ≈ 250
lines/file — no rule); per-function size/complexity is lint-enforced. Split a
file that grows multiple `State`/widget classes or mixes UI with logic; keep
`build()` declarative — push math and derivations into named helpers.
- **Extract pure helpers.** Animation math, mapping, and derivations live in a
sibling pure-Dart file (e.g. `*_animation.dart`) as named top-level functions,
Expand Down
9 changes: 6 additions & 3 deletions coffee_quest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ time. Flutter, offline-first.
**Stack:** Flutter · Riverpod 3 (state) · go_router 17 (navigation) · Drift 2.33 /
SQLite (offline persistence) · Freezed 3 + json_serializable (content models).

**Toolchain:** analyzer 12 · `riverpod_lint` enabled via the `plugins:` block in
`analysis_options.yaml` (native analysis_server_plugin — not a dependency, no
`custom_lint`).
**Toolchain:** analyzer 12 · `very_good_analysis` (strict lint baseline) ·
`riverpod_lint` + `dart_code_linter` enabled via the `plugins:` block in
`analysis_options.yaml` (native analysis_server_plugins — not dependencies, no
`custom_lint`). `dart_code_linter` adds `no-magic-number` plus a CI metrics gate
(`dart run dart_code_linter:metrics analyze lib`) for per-function size &
complexity.

Architecture and conventions live in [`../CLAUDE.md`](../CLAUDE.md); deeper
design and milestone docs are in [`../docs/`](../docs/).
Expand Down
73 changes: 49 additions & 24 deletions coffee_quest/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,41 +1,66 @@
include: package:flutter_lints/flutter.yaml
include: package:very_good_analysis/analysis_options.yaml

# Native analysis_server_plugins (no custom_lint), resolved in isolated envs.
plugins:
riverpod_lint: ^3.1.3
dart_code_linter: ^4.1.2

analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
# Build artifacts — SwiftPM/CocoaPods copy plugin sources into build dirs
# at the package root (build/) and under platform dirs (e.g. ios/build/).
# Both globs are needed: "build/**" only matches the root, "**/build/**"
# only matches nested copies.
- "build/**"
- "**/build/**"
errors:
invalid_annotation_target: ignore

linter:
rules:
# Widget construction
- prefer_const_constructors
- prefer_const_declarations
- prefer_const_literals_to_create_immutables
- avoid_unnecessary_containers
- sized_box_for_whitespace
- use_key_in_widget_constructors
# Code quality
- avoid_print
- avoid_void_async
- cancel_subscriptions
- prefer_final_locals
- prefer_single_quotes
- prefer_is_not_empty
- unnecessary_lambdas
- avoid_relative_lib_imports
- require_trailing_commas
- always_declare_return_types
- prefer_const_constructors_in_immutables
- avoid_redundant_argument_values
- unawaited_futures
# very_good_analysis re-enables cascade_invocations; keep it off — it fights
# the CustomPainter draw sequences (roasty.dart) and isn't auto-fixable.
cascade_invocations: false
# pubspec deps are grouped by concern with explanatory comments (State / Nav
# / Persistence / Firebase …); alphabetical sorting would scatter the groups.
sort_pub_dependencies: false

# dart_code_linter — magic-number + per-function/class size & complexity that the
# stock linter can't enforce. CustomPainter / animation-geometry and theme-token
# files are excluded from no-magic-number (their literals are coordinates/tokens).
dart_code_linter:
metrics:
cyclomatic-complexity: 20
maximum-nesting-level: 5
number-of-parameters: 5
# 90 (not DCM's default 50): measured declarative `build` methods here — the
# collapsing profile header, preference/stat tiles — legitimately run 75–90
# lines of widget tree with no branching. cyclomatic-complexity (20) and
# maximum-nesting-level (5) stay strict; they guard real logic, SLOC doesn't.
source-lines-of-code: 90
number-of-methods: 12
metrics-exclude:
- test/**
- integration_test/**
# CustomPainter with many small _paintX helpers — method count is by design.
- lib/features/onboarding/presentation/widgets/roasty.dart
- lib/features/onboarding/presentation/widgets/roasty_body.dart
- lib/features/onboarding/presentation/widgets/roasty_faces.dart
- lib/features/onboarding/presentation/widgets/roasty_particles.dart
# Declarative GoRouter route table — long by nature, low logic complexity.
- lib/app/app_router.dart
rules:
- no-magic-number:
severity: warning
allowed: [0, 1, 2]
exclude:
- lib/features/onboarding/presentation/widgets/roasty.dart
- lib/features/onboarding/presentation/widgets/roasty_animation.dart
- lib/features/onboarding/presentation/widgets/roasty_body.dart
- lib/features/onboarding/presentation/widgets/roasty_faces.dart
- lib/features/onboarding/presentation/widgets/roasty_particles.dart
- lib/features/onboarding/presentation/loading/widgets/roasty_stage.dart
- lib/features/onboarding/presentation/loading/loading_animation.dart
- lib/shared/theme/app_spacing.dart
- lib/shared/theme/app_colors.dart
- lib/shared/theme/app_typography.dart
2 changes: 1 addition & 1 deletion coffee_quest/integration_test/smoke_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ void main() {
expect(find.text("Today's lesson"), findsOneWidget);
});

testWidgets('play today\'s lesson, complete, see XP on Profile', (
testWidgets("play today's lesson, complete, see XP on Profile", (
tester,
) async {
// Open today's lesson from the card (its subtitle is the lesson title).
Expand Down
7 changes: 6 additions & 1 deletion coffee_quest/lib/app/analytics_navigator_observer.dart
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import 'dart:async';

import 'package:coffee_quest/services/analytics/analytics_service.dart';
import 'package:flutter/widgets.dart';

/// Reports route changes to [AnalyticsService.logScreen]. Wired into go_router
/// via `observers`. The injected service is the NoOp impl until Firebase lands
/// in Phase 8, so this is currently inert but exercises the call path.
class AnalyticsNavigatorObserver extends NavigatorObserver {
/// Creates an [AnalyticsNavigatorObserver] over an [AnalyticsService].
AnalyticsNavigatorObserver(this._analytics);

final AnalyticsService _analytics;

void _log(Route<dynamic>? route) {
final name = route?.settings.name;
if (name != null) _analytics.logScreen(name);
if (name != null) {
unawaited(_analytics.logScreen(name));
}
}

@override
Expand Down
2 changes: 2 additions & 0 deletions coffee_quest/lib/app/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import 'package:coffee_quest/app/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// Root application widget — wires the router and theme into MaterialApp.
class CoffeeQuestApp extends ConsumerWidget {
/// Creates a [CoffeeQuestApp].
const CoffeeQuestApp({super.key});

@override
Expand Down
2 changes: 2 additions & 0 deletions coffee_quest/lib/app/app_bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import 'package:coffee_quest/services/remote_config/firebase_remote_config_servi
import 'package:coffee_quest/shared/storage/app_database.dart';
import 'package:firebase_core/firebase_core.dart';

/// One-time app initialization (database, and Firebase when enabled).
class AppBootstrap {
/// Initializes the database and, when [kUseFirebase] is set, Firebase.
static Future<void> initialize() async {
AppDatabaseService.instance = AppDatabase();

Expand Down
1 change: 1 addition & 0 deletions coffee_quest/lib/app/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ part 'app_router.g.dart';

final _rootKey = GlobalKey<NavigatorState>();

/// Provides the app's [GoRouter] (rebuilds on onboarding-gate changes).
@riverpod
GoRouter appRouter(Ref ref) {
// Ticks whenever the async onboarding gate resolves; passed to the router
Expand Down
4 changes: 4 additions & 0 deletions coffee_quest/lib/app/app_router.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions coffee_quest/lib/app/app_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import 'package:go_router/go_router.dart';
/// branch keeps its own navigator stack, so tab state and scroll position
/// survive switching tabs.
class AppShell extends StatelessWidget {
/// Creates an [AppShell] around [navigationShell].
const AppShell(this.navigationShell, {super.key});

/// The shell that manages the four bottom-nav branches.
final StatefulNavigationShell navigationShell;

void _onDestinationSelected(int index) {
Expand Down
2 changes: 2 additions & 0 deletions coffee_quest/lib/app/app_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import 'package:coffee_quest/shared/theme/app_colors.dart';
import 'package:coffee_quest/shared/theme/app_typography.dart';
import 'package:flutter/material.dart';

/// App-wide Material 3 theme (dark-roast palette).
abstract class AppTheme {
/// The app's theme data.
static ThemeData get lightTheme => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
Expand Down
3 changes: 3 additions & 0 deletions coffee_quest/lib/core/constants/ad_unit_ids.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Self-describing tokens / DTOs / storage infra; no per-member docs.
// ignore_for_file: public_member_api_docs

/// AdMob ad unit IDs. Use the test IDs during development; fill in the
/// production IDs from the AdMob console before release.
///
Expand Down
4 changes: 4 additions & 0 deletions coffee_quest/lib/core/constants/app_strings.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// Self-descriptive UI string constants — no per-member docs needed.
// ignore_for_file: public_member_api_docs

/// User-facing string constants.
abstract class AppStrings {
static const appName = 'Coffee Quest';
static const tabLearn = 'Learn';
Expand Down
14 changes: 14 additions & 0 deletions coffee_quest/lib/core/constants/route_names.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
/// Named-route path constants for the app's go_router configuration.
abstract class RouteNames {
/// Learn tab root — the module list.
static const learn = '/learn';

/// A single learning module's detail screen.
static const moduleDetail = '/learn/module/:moduleId';

/// An immersive single-lesson flow.
static const lesson = '/learn/lesson/:lessonId';

/// Path tab — the progress journey.
static const path = '/path';

/// Cards tab — the collected-cards grid.
static const cards = '/cards';

/// A single collected card's detail screen.
static const cardDetail = '/cards/:cardId';

/// Profile tab.
static const profile = '/profile';
}
3 changes: 3 additions & 0 deletions coffee_quest/lib/core/constants/xp_values.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Self-describing tokens / DTOs / storage infra; no per-member docs.
// ignore_for_file: public_member_api_docs

abstract class XpValues {
static const int perStep = 10;
static const int moduleCompletionBonus = 25;
Expand Down
8 changes: 8 additions & 0 deletions coffee_quest/lib/core/errors/app_exception.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
/// Base type for app-domain exceptions.
sealed class AppException implements Exception {
/// Creates an [AppException] with a human-readable [message].
const AppException(this.message);

/// Human-readable error message.
final String message;
}

/// Thrown when bundled content fails to load or parse.
final class ContentLoadException extends AppException {
/// Creates a [ContentLoadException].
const ContentLoadException(super.message);
}

/// Thrown when a persistence (Drift) operation fails.
final class PersistenceException extends AppException {
/// Creates a [PersistenceException].
const PersistenceException(super.message);
}
1 change: 1 addition & 0 deletions coffee_quest/lib/core/utils/date_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// local `DateTime` — the streak rule only cares about whole local days.
DateTime dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);

/// Whether [a] and [b] fall on the same local calendar day.
bool isSameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;

Expand Down
7 changes: 6 additions & 1 deletion coffee_quest/lib/core/widgets/error_view.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import 'package:flutter/material.dart';

/// Centered error message with an optional retry button.
class ErrorView extends StatelessWidget {
const ErrorView({super.key, required this.message, this.onRetry});
/// Creates an [ErrorView].
const ErrorView({required this.message, super.key, this.onRetry});

/// The error message to display.
final String message;

/// Optional retry handler; when non-null a "Retry" button is shown.
final VoidCallback? onRetry;

@override
Expand Down
Loading
Loading