Skip to content

Flutter Init Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Scaffold the Flutter app with FVM 3.35.7, feature-first architecture, a complete MEK crypto layer (Argon2id + AES-256-GCM), and auth screens (login/register) wired to the NestJS backend — the foundation for all Avelia Flutter features.

Architecture: FVM-pinned Flutter 3.35.7 (Dart 3.9.2), Riverpod 2 code-gen for all state, go_router for navigation with auth redirect guard, Dio + Retrofit for HTTP, package:cryptography for Argon2id MEK derivation and AES-256-GCM (used by future entry encryption), flutter_secure_storage for MEK and token persistence. Feature-first folder structure.

Tech Stack: Flutter 3.35.7 (FVM), Dart 3.9.2, flutter_riverpod 2.x, riverpod_annotation, go_router, dio, retrofit, freezed, json_serializable, drift (added to pubspec now, schema built in next feature), cryptography, flutter_secure_storage, mocktail, build_runner


Pre-flight

bash
# Verify FVM and Flutter 3.35.7
fvm list   # should show 3.35.7 as available
fvm use 3.35.7 --force   # if not already set

# Working directory for all tasks
cd /Volumes/exSSD/dev/baby-parents-app/avelia-flutter-app

Task 1: FVM flutter create + pubspec.yaml

Files:

  • Create: .fvm/fvm_config.json (via fvm use)
  • Modify: pubspec.yaml
  • Create: analysis_options.yaml

Step 1: Pin Flutter version with FVM

bash
cd /Volumes/exSSD/dev/baby-parents-app/avelia-flutter-app
fvm use 3.35.7

Expected: .fvm/fvm_config.json created with {"flutterSdkVersion":"3.35.7"}

Step 2: Create the Flutter project in the current directory

bash
fvm flutter create . \
  --org com.avelia \
  --project-name avelia \
  --platforms ios,android \
  --no-pub

Expected: Flutter project files created (lib/main.dart, test/, pubspec.yaml, android/, ios/, etc.)

Note: --no-pub skips flutter pub get so we configure pubspec first.

Step 3: Replace pubspec.yaml entirely

yaml
name: avelia
description: Privacy-first companion app for couples navigating the journey to family.
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.9.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  # State management
  flutter_riverpod: ^2.6.1
  riverpod_annotation: ^2.6.1

  # Navigation
  go_router: ^14.6.2

  # Networking
  dio: ^5.7.0
  retrofit: ^4.4.1
  json_annotation: ^4.9.0

  # Local database (schema built in next feature)
  drift: ^2.22.1
  sqlite3_flutter_libs: ^0.5.29
  path_provider: ^2.1.5
  path: ^1.9.0

  # Crypto & storage
  cryptography: ^2.7.0
  flutter_secure_storage: ^9.2.2
  convert: ^3.1.2

  # Models
  freezed_annotation: ^2.4.4

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

  # Code generation
  build_runner: ^2.4.14
  riverpod_generator: ^2.6.1
  freezed: ^2.5.7
  json_serializable: ^6.9.2
  retrofit_generator: ^9.1.4
  drift_dev: ^2.22.1

  # Testing
  mocktail: ^1.0.4

flutter:
  uses-material-design: true

Step 4: Run flutter pub get

bash
fvm flutter pub get

Expected: Got dependencies! — no errors.

Step 5: Create analysis_options.yaml

yaml
include: package:flutter_lints/flutter.yaml

analyzer:
  errors:
    missing_required_param: error
    missing_return: error
  strong-mode:
    implicit-casts: false
    implicit-dynamic: false
  language:
    strict-casts: true
    strict-raw-types: true

linter:
  rules:
    - always_declare_return_types
    - avoid_dynamic_calls
    - avoid_empty_else
    - avoid_print
    - avoid_relative_lib_imports
    - avoid_returning_null_for_future
    - avoid_unnecessary_containers
    - cancel_subscriptions
    - close_sinks
    - prefer_const_constructors
    - prefer_const_declarations
    - prefer_final_locals
    - unawaited_futures
    - unnecessary_lambdas
    - use_super_parameters

Step 6: Add FVM to .gitignore (project root already has it but ensure flutter-specific entries)

Verify /Volumes/exSSD/dev/baby-parents-app/.gitignore contains .fvm/flutter_sdk (it does from Task 1 of backend plan).

Step 7: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/
git commit -m "feat(flutter): create Flutter 3.35.7 project with FVM and full pubspec"

Task 2: Folder structure scaffolding

Step 1: Create the full directory tree

bash
cd /Volumes/exSSD/dev/baby-parents-app/avelia-flutter-app

# Features
mkdir -p lib/features/auth/data/dtos
mkdir -p lib/features/auth/domain/models
mkdir -p lib/features/auth/presentation/screens
mkdir -p lib/features/auth/presentation/widgets
mkdir -p lib/features/auth/presentation/providers

# Core
mkdir -p lib/core/crypto
mkdir -p lib/core/router
mkdir -p lib/core/network
mkdir -p lib/core/storage
mkdir -p lib/core/theme

# Shared
mkdir -p lib/shared/widgets
mkdir -p lib/shared/extensions

# Tests (mirrors lib/)
mkdir -p test/features/auth/data
mkdir -p test/features/auth/domain
mkdir -p test/features/auth/presentation
mkdir -p test/helpers

Step 2: Create placeholder .gitkeep files so empty dirs are tracked

bash
find lib test -type d -empty -exec touch {}/.gitkeep \;

Step 3: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/ avelia-flutter-app/test/
git commit -m "chore(flutter): scaffold feature-first folder structure"

Task 3: App theme

Files:

  • Create: lib/core/theme/app_colors.dart
  • Create: lib/core/theme/app_text_styles.dart
  • Create: lib/core/theme/app_theme.dart

Step 1: Create lib/core/theme/app_colors.dart

dart
import 'package:flutter/material.dart';

abstract final class AppColors {
  // Brand
  static const primary = Color(0xFF5B6AF0);
  static const primaryDark = Color(0xFF3D4ECC);
  static const accent = Color(0xFFE8A87C);

  // Neutrals
  static const background = Color(0xFFFAFAFC);
  static const surface = Color(0xFFFFFFFF);
  static const onSurface = Color(0xFF1A1A2E);
  static const subtle = Color(0xFF8B8FA8);
  static const divider = Color(0xFFE8E9F0);

  // Semantic
  static const error = Color(0xFFE53935);
  static const success = Color(0xFF43A047);

  // Dark mode
  static const backgroundDark = Color(0xFF0F0F1A);
  static const surfaceDark = Color(0xFF1A1A2E);
  static const onSurfaceDark = Color(0xFFF0F0F8);
}

Step 2: Create lib/core/theme/app_text_styles.dart

dart
import 'package:flutter/material.dart';
import 'app_colors.dart';

abstract final class AppTextStyles {
  static const displayLarge = TextStyle(
    fontSize: 32,
    fontWeight: FontWeight.w700,
    letterSpacing: -0.5,
    color: AppColors.onSurface,
  );

  static const headlineMedium = TextStyle(
    fontSize: 24,
    fontWeight: FontWeight.w600,
    color: AppColors.onSurface,
  );

  static const titleMedium = TextStyle(
    fontSize: 16,
    fontWeight: FontWeight.w600,
    color: AppColors.onSurface,
  );

  static const bodyLarge = TextStyle(
    fontSize: 16,
    fontWeight: FontWeight.w400,
    color: AppColors.onSurface,
  );

  static const bodyMedium = TextStyle(
    fontSize: 14,
    fontWeight: FontWeight.w400,
    color: AppColors.onSurface,
  );

  static const caption = TextStyle(
    fontSize: 12,
    fontWeight: FontWeight.w400,
    color: AppColors.subtle,
  );

  static const labelLarge = TextStyle(
    fontSize: 16,
    fontWeight: FontWeight.w600,
    letterSpacing: 0.3,
  );
}

Step 3: Create lib/core/theme/app_theme.dart

dart
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_text_styles.dart';

abstract final class AppTheme {
  static ThemeData get light => ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: AppColors.primary,
          brightness: Brightness.light,
        ),
        scaffoldBackgroundColor: AppColors.background,
        textTheme: const TextTheme(
          displayLarge: AppTextStyles.displayLarge,
          headlineMedium: AppTextStyles.headlineMedium,
          titleMedium: AppTextStyles.titleMedium,
          bodyLarge: AppTextStyles.bodyLarge,
          bodyMedium: AppTextStyles.bodyMedium,
          labelLarge: AppTextStyles.labelLarge,
        ),
        inputDecorationTheme: InputDecorationTheme(
          filled: true,
          fillColor: AppColors.surface,
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(12),
            borderSide: const BorderSide(color: AppColors.divider),
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(12),
            borderSide: const BorderSide(color: AppColors.divider),
          ),
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(12),
            borderSide: const BorderSide(color: AppColors.primary, width: 2),
          ),
          contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            backgroundColor: AppColors.primary,
            foregroundColor: Colors.white,
            minimumSize: const Size(double.infinity, 52),
            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
            textStyle: AppTextStyles.labelLarge,
          ),
        ),
      );

  static ThemeData get dark => ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: AppColors.primary,
          brightness: Brightness.dark,
        ),
        scaffoldBackgroundColor: AppColors.backgroundDark,
      );
}

Step 4: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/core/theme/
git commit -m "feat(flutter): add AppTheme, AppColors, AppTextStyles"

Task 4: SecureStorage service + provider

Files:

  • Create: lib/core/storage/secure_storage.dart
  • Create: lib/core/storage/secure_storage_provider.dart

Step 1: Create lib/core/storage/secure_storage.dart

dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {
  const SecureStorageService(this._storage);

  final FlutterSecureStorage _storage;

  static const _accessTokenKey = 'access_token';
  static const _refreshTokenKey = 'refresh_token';
  static const _userIdKey = 'user_id';

  static String _mekKey(String userId) => 'mek_$userId';

  // Tokens
  Future<void> saveTokens({
    required String accessToken,
    required String refreshToken,
    required String userId,
  }) async {
    await Future.wait([
      _storage.write(key: _accessTokenKey, value: accessToken),
      _storage.write(key: _refreshTokenKey, value: refreshToken),
      _storage.write(key: _userIdKey, value: userId),
    ]);
  }

  Future<String?> getAccessToken() => _storage.read(key: _accessTokenKey);
  Future<String?> getRefreshToken() => _storage.read(key: _refreshTokenKey);
  Future<String?> getUserId() => _storage.read(key: _userIdKey);

  Future<void> updateAccessToken(String token) =>
      _storage.write(key: _accessTokenKey, value: token);

  // MEK
  Future<void> saveMek(String userId, List<int> mekBytes) =>
      _storage.write(key: _mekKey(userId), value: String.fromCharCodes(mekBytes));

  Future<List<int>?> getMek(String userId) async {
    final raw = await _storage.read(key: _mekKey(userId));
    return raw?.codeUnits;
  }

  // Clear all on logout
  Future<void> clearAll() => _storage.deleteAll();
}

Step 2: Create lib/core/storage/secure_storage_provider.dart

dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'secure_storage.dart';

part 'secure_storage_provider.g.dart';

@riverpod
SecureStorageService secureStorage(SecureStorageRef ref) {
  const storage = FlutterSecureStorage(
    aOptions: AndroidOptions(encryptedSharedPreferences: true),
    iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
  );
  return SecureStorageService(storage);
}

Step 3: Run build_runner to generate the .g.dart file

bash
cd /Volumes/exSSD/dev/baby-parents-app/avelia-flutter-app
fvm dart run build_runner build --delete-conflicting-outputs

Expected: secure_storage_provider.g.dart generated.

Step 4: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/core/storage/
git commit -m "feat(flutter): add SecureStorageService and provider"

Task 5: CryptoService (MEK derivation + AES-256-GCM)

Files:

  • Create: lib/core/crypto/crypto_service.dart
  • Create: lib/core/crypto/crypto_provider.dart
  • Create: test/core/crypto/crypto_service_test.dart

Step 1: Write the failing unit test

Create test/core/crypto/crypto_service_test.dart (create dir first):

bash
mkdir -p test/core/crypto
dart
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:avelia/core/crypto/crypto_service.dart';

void main() {
  late CryptoService cryptoService;

  setUp(() {
    cryptoService = CryptoService();
  });

  group('CryptoService', () {
    group('deriveMek', () {
      test('returns 32-byte key for given password and salt', () async {
        const password = 'Password123!';
        const salt = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';

        final mek = await cryptoService.deriveMek(password: password, argon2Salt: salt);

        expect(mek.length, equals(32));
      });

      test('same inputs produce same MEK (deterministic)', () async {
        const password = 'Password123!';
        const salt = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';

        final mek1 = await cryptoService.deriveMek(password: password, argon2Salt: salt);
        final mek2 = await cryptoService.deriveMek(password: password, argon2Salt: salt);

        expect(mek1, equals(mek2));
      });

      test('different passwords produce different MEKs', () async {
        const salt = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';

        final mek1 = await cryptoService.deriveMek(password: 'Password1!', argon2Salt: salt);
        final mek2 = await cryptoService.deriveMek(password: 'Password2!', argon2Salt: salt);

        expect(mek1, isNot(equals(mek2)));
      });
    });

    group('encryptWithMek / decryptWithMek', () {
      test('round-trips plaintext correctly', () async {
        const password = 'Password123!';
        const salt = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';
        const plaintext = 'Sensitive journal entry';

        final mek = await cryptoService.deriveMek(password: password, argon2Salt: salt);
        final encrypted = await cryptoService.encryptWithMek(
          plaintext: utf8.encode(plaintext),
          mek: mek,
        );
        final decrypted = await cryptoService.decryptWithMek(encrypted: encrypted, mek: mek);

        expect(utf8.decode(decrypted), equals(plaintext));
      });

      test('ciphertext differs each call (random nonce)', () async {
        const password = 'Password123!';
        const salt = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2';

        final mek = await cryptoService.deriveMek(password: password, argon2Salt: salt);
        final e1 = await cryptoService.encryptWithMek(plaintext: [1, 2, 3], mek: mek);
        final e2 = await cryptoService.encryptWithMek(plaintext: [1, 2, 3], mek: mek);

        expect(e1, isNot(equals(e2)));
      });
    });
  });
}

Step 2: Run test to verify it fails

bash
fvm flutter test test/core/crypto/crypto_service_test.dart

Expected: FAIL — Target of URI doesn't exist: 'package:avelia/core/crypto/crypto_service.dart'

Step 3: Create lib/core/crypto/crypto_service.dart

dart
import 'dart:convert';
import 'package:convert/convert.dart';
import 'package:cryptography/cryptography.dart';

class EncryptedData {
  const EncryptedData({required this.nonce, required this.ciphertext, required this.mac});

  final List<int> nonce;
  final List<int> ciphertext;
  final List<int> mac;

  /// Serialise to bytes: [nonce_len(1)][nonce][mac_len(1)][mac][ciphertext]
  List<int> toBytes() => [
        nonce.length,
        ...nonce,
        mac.length,
        ...mac,
        ...ciphertext,
      ];

  factory EncryptedData.fromBytes(List<int> bytes) {
    var offset = 0;
    final nonceLen = bytes[offset++];
    final nonce = bytes.sublist(offset, offset += nonceLen);
    final macLen = bytes[offset++];
    final mac = bytes.sublist(offset, offset += macLen);
    final ciphertext = bytes.sublist(offset);
    return EncryptedData(nonce: nonce, ciphertext: ciphertext, mac: mac);
  }
}

class CryptoService {
  static final _aesGcm = AesGcm.with256bits();

  static final _argon2 = Argon2id(
    memory: 65536,    // 64 MB
    parallelism: 1,
    iterations: 3,
    hashLength: 32,
  );

  /// Derive a 32-byte MEK from password + server-provided argon2Salt (hex).
  Future<List<int>> deriveMek({
    required String password,
    required String argon2Salt,
  }) async {
    final saltBytes = hex.decode(argon2Salt);
    final secretKey = await _argon2.deriveKeyFromPassword(
      password: utf8.encode(password),
      nonce: saltBytes,
    );
    return secretKey.extractBytes();
  }

  /// Encrypt plaintext with MEK using AES-256-GCM. Returns [EncryptedData].
  Future<EncryptedData> encryptWithMek({
    required List<int> plaintext,
    required List<int> mek,
  }) async {
    final secretKey = SecretKey(mek);
    final nonce = _aesGcm.newNonce();
    final box = await _aesGcm.encrypt(plaintext, secretKey: secretKey, nonce: nonce);
    return EncryptedData(nonce: nonce, ciphertext: box.cipherText, mac: box.mac.bytes);
  }

  /// Decrypt EncryptedData with MEK. Returns plaintext bytes.
  Future<List<int>> decryptWithMek({
    required EncryptedData encrypted,
    required List<int> mek,
  }) async {
    final secretKey = SecretKey(mek);
    final box = SecretBox(
      encrypted.ciphertext,
      nonce: encrypted.nonce,
      mac: Mac(encrypted.mac),
    );
    return _aesGcm.decrypt(box, secretKey: secretKey);
  }
}

Step 4: Create lib/core/crypto/crypto_provider.dart

dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'crypto_service.dart';

part 'crypto_provider.g.dart';

@riverpod
CryptoService cryptoService(CryptoServiceRef ref) => CryptoService();

Step 5: Run build_runner

bash
fvm dart run build_runner build --delete-conflicting-outputs

Step 6: Run test to verify it passes

bash
fvm flutter test test/core/crypto/crypto_service_test.dart

Expected: PASS — 5 tests passing

Step 7: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/core/crypto/ avelia-flutter-app/test/core/
git commit -m "feat(flutter): add CryptoService with Argon2id MEK derivation and AES-256-GCM"

Task 6: Dio client + AuthInterceptor

Files:

  • Create: lib/core/network/dio_client.dart
  • Create: lib/core/network/auth_interceptor.dart
  • Create: lib/core/network/api_provider.dart

Step 1: Create lib/core/network/dio_client.dart

dart
import 'package:dio/dio.dart';

Dio createDioClient({required String baseUrl}) {
  return Dio(
    BaseOptions(
      baseUrl: baseUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 30),
      headers: {'Content-Type': 'application/json'},
    ),
  );
}

Step 2: Create lib/core/network/auth_interceptor.dart

dart
import 'package:dio/dio.dart';
import '../storage/secure_storage.dart';

class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._storage, this._dio);

  final SecureStorageService _storage;
  final Dio _dio;

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _storage.getAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  Future<void> onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    if (err.response?.statusCode != 401) {
      handler.next(err);
      return;
    }

    final refreshToken = await _storage.getRefreshToken();
    final userId = await _storage.getUserId();
    if (refreshToken == null || userId == null) {
      handler.next(err);
      return;
    }

    try {
      final response = await _dio.post(
        '/auth/refresh',
        data: {'refreshToken': refreshToken, 'deviceId': userId},
        options: Options(headers: {'Authorization': null}),
      );
      final newAccessToken = response.data['accessToken'] as String;
      final newRefreshToken = response.data['refreshToken'] as String;

      await _storage.saveTokens(
        accessToken: newAccessToken,
        refreshToken: newRefreshToken,
        userId: userId,
      );

      err.requestOptions.headers['Authorization'] = 'Bearer $newAccessToken';
      final retryResponse = await _dio.fetch(err.requestOptions);
      handler.resolve(retryResponse);
    } catch (_) {
      await _storage.clearAll();
      handler.next(err);
    }
  }
}

Step 3: Create lib/core/network/api_provider.dart

dart
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../storage/secure_storage_provider.dart';
import 'auth_interceptor.dart';
import 'dio_client.dart';

part 'api_provider.g.dart';

// Base URL injected via --dart-define=BASE_URL=...
// Falls back to localhost for development.
const _defaultBaseUrl = String.fromEnvironment(
  'BASE_URL',
  defaultValue: 'http://localhost:3000',
);

@riverpod
Dio dio(DioRef ref) {
  final storage = ref.watch(secureStorageProvider);
  final client = createDioClient(baseUrl: _defaultBaseUrl);
  client.interceptors.add(AuthInterceptor(storage, client));
  return client;
}

Step 4: Run build_runner

bash
fvm dart run build_runner build --delete-conflicting-outputs

Step 5: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/core/network/
git commit -m "feat(flutter): add Dio client with AuthInterceptor (JWT attach + refresh)"

Task 7: Auth domain models + DTOs

Files:

  • Create: lib/features/auth/domain/models/auth_user.dart
  • Create: lib/features/auth/domain/models/auth_tokens.dart
  • Create: lib/features/auth/data/dtos/register_request.dto.dart
  • Create: lib/features/auth/data/dtos/login_request.dto.dart
  • Create: lib/features/auth/data/dtos/auth_response.dto.dart

Step 1: Create domain models

lib/features/auth/domain/models/auth_user.dart:

dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_user.freezed.dart';

@freezed
class AuthUser with _$AuthUser {
  const factory AuthUser({
    required String id,
    required String email,
  }) = _AuthUser;
}

lib/features/auth/domain/models/auth_tokens.dart:

dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_tokens.freezed.dart';

@freezed
class AuthTokens with _$AuthTokens {
  const factory AuthTokens({
    required String accessToken,
    required String refreshToken,
    required String argon2Salt,
  }) = _AuthTokens;
}

Step 2: Create DTOs

lib/features/auth/data/dtos/register_request.dto.dart:

dart
import 'package:json_annotation/json_annotation.dart';

part 'register_request.dto.g.dart';

@JsonSerializable()
class RegisterRequestDto {
  const RegisterRequestDto({
    required this.email,
    required this.password,
    required this.deviceId,
  });

  final String email;
  final String password;
  final String deviceId;

  Map<String, dynamic> toJson() => _$RegisterRequestDtoToJson(this);
}

lib/features/auth/data/dtos/login_request.dto.dart:

dart
import 'package:json_annotation/json_annotation.dart';

part 'login_request.dto.g.dart';

@JsonSerializable()
class LoginRequestDto {
  const LoginRequestDto({
    required this.email,
    required this.password,
    required this.deviceId,
  });

  final String email;
  final String password;
  final String deviceId;

  Map<String, dynamic> toJson() => _$LoginRequestDtoToJson(this);
}

lib/features/auth/data/dtos/auth_response.dto.dart:

dart
import 'package:json_annotation/json_annotation.dart';

part 'auth_response.dto.g.dart';

@JsonSerializable()
class AuthResponseDto {
  const AuthResponseDto({
    required this.accessToken,
    required this.refreshToken,
    required this.argon2Salt,
  });

  final String accessToken;
  final String refreshToken;
  final String argon2Salt;

  factory AuthResponseDto.fromJson(Map<String, dynamic> json) =>
      _$AuthResponseDtoFromJson(json);

  Map<String, dynamic> toJson() => _$AuthResponseDtoToJson(this);
}

Step 3: Run build_runner

bash
fvm dart run build_runner build --delete-conflicting-outputs

Expected: .freezed.dart and .g.dart files generated, no errors.

Step 4: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/features/auth/
git commit -m "feat(flutter): add auth domain models (AuthUser, AuthTokens) and DTOs"

Task 8: AuthApi (Retrofit) + AuthRepository

Files:

  • Create: lib/features/auth/data/auth_api.dart
  • Create: lib/features/auth/domain/auth_repository.dart
  • Create: lib/features/auth/data/auth_repository_impl.dart

Step 1: Create lib/features/auth/data/auth_api.dart

dart
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import 'dtos/auth_response.dto.dart';
import 'dtos/login_request.dto.dart';
import 'dtos/register_request.dto.dart';

part 'auth_api.g.dart';

@RestApi()
abstract class AuthApi {
  factory AuthApi(Dio dio) = _AuthApi;

  @POST('/auth/register')
  Future<AuthResponseDto> register(@Body() RegisterRequestDto body);

  @POST('/auth/login')
  Future<AuthResponseDto> login(@Body() LoginRequestDto body);

  @POST('/auth/logout')
  Future<void> logout(@Body() Map<String, dynamic> body);
}

Step 2: Create lib/features/auth/domain/auth_repository.dart

dart
import 'models/auth_tokens.dart';

abstract class AuthRepository {
  Future<AuthTokens> register({
    required String email,
    required String password,
    required String deviceId,
  });

  Future<AuthTokens> login({
    required String email,
    required String password,
    required String deviceId,
  });

  Future<void> logout({required String refreshToken, required String deviceId});
}

Step 3: Create lib/features/auth/data/auth_repository_impl.dart

dart
import '../domain/auth_repository.dart';
import '../domain/models/auth_tokens.dart';
import 'auth_api.dart';
import 'dtos/login_request.dto.dart';
import 'dtos/register_request.dto.dart';

class AuthRepositoryImpl implements AuthRepository {
  const AuthRepositoryImpl(this._api);

  final AuthApi _api;

  @override
  Future<AuthTokens> register({
    required String email,
    required String password,
    required String deviceId,
  }) async {
    final dto = await _api.register(
      RegisterRequestDto(email: email, password: password, deviceId: deviceId),
    );
    return AuthTokens(
      accessToken: dto.accessToken,
      refreshToken: dto.refreshToken,
      argon2Salt: dto.argon2Salt,
    );
  }

  @override
  Future<AuthTokens> login({
    required String email,
    required String password,
    required String deviceId,
  }) async {
    final dto = await _api.login(
      LoginRequestDto(email: email, password: password, deviceId: deviceId),
    );
    return AuthTokens(
      accessToken: dto.accessToken,
      refreshToken: dto.refreshToken,
      argon2Salt: dto.argon2Salt,
    );
  }

  @override
  Future<void> logout({required String refreshToken, required String deviceId}) =>
      _api.logout({'refreshToken': refreshToken, 'deviceId': deviceId});
}

Step 4: Run build_runner

bash
fvm dart run build_runner build --delete-conflicting-outputs

Expected: auth_api.g.dart generated.

Step 5: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/features/auth/
git commit -m "feat(flutter): add AuthApi (Retrofit) and AuthRepository"

Task 9: Auth Riverpod providers

Files:

  • Create: lib/features/auth/presentation/providers/auth_provider.dart

Step 1: Create lib/features/auth/presentation/providers/auth_provider.dart

dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_api.dart';
import '../../data/auth_repository_impl.dart';
import '../../domain/auth_repository.dart';
import '../../../../core/crypto/crypto_provider.dart';
import '../../../../core/network/api_provider.dart';
import '../../../../core/storage/secure_storage_provider.dart';

part 'auth_provider.g.dart';

@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
  final dio = ref.watch(dioProvider);
  return AuthRepositoryImpl(AuthApi(dio));
}

@riverpod
class AuthNotifier extends _$AuthNotifier {
  @override
  Future<bool> build() async {
    final storage = ref.watch(secureStorageProvider);
    final token = await storage.getAccessToken();
    return token != null;
  }

  Future<void> register({
    required String email,
    required String password,
    required String deviceId,
  }) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final repo = ref.read(authRepositoryProvider);
      final tokens = await repo.register(email: email, password: password, deviceId: deviceId);

      // Derive and store MEK
      final crypto = ref.read(cryptoServiceProvider);
      final mek = await crypto.deriveMek(password: password, argon2Salt: tokens.argon2Salt);
      final storage = ref.read(secureStorageProvider);

      // We need the userId — decode from JWT payload
      final userId = _decodeUserId(tokens.accessToken);
      await storage.saveTokens(
        accessToken: tokens.accessToken,
        refreshToken: tokens.refreshToken,
        userId: userId,
      );
      await storage.saveMek(userId, mek);

      return true;
    });
  }

  Future<void> login({
    required String email,
    required String password,
    required String deviceId,
  }) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final repo = ref.read(authRepositoryProvider);
      final tokens = await repo.login(email: email, password: password, deviceId: deviceId);

      final crypto = ref.read(cryptoServiceProvider);
      final mek = await crypto.deriveMek(password: password, argon2Salt: tokens.argon2Salt);
      final storage = ref.read(secureStorageProvider);

      final userId = _decodeUserId(tokens.accessToken);
      await storage.saveTokens(
        accessToken: tokens.accessToken,
        refreshToken: tokens.refreshToken,
        userId: userId,
      );
      await storage.saveMek(userId, mek);

      return true;
    });
  }

  Future<void> logout() async {
    final storage = ref.read(secureStorageProvider);
    final refreshToken = await storage.getRefreshToken();
    final userId = await storage.getUserId();
    if (refreshToken != null && userId != null) {
      try {
        final repo = ref.read(authRepositoryProvider);
        await repo.logout(refreshToken: refreshToken, deviceId: userId);
      } catch (_) {}
    }
    await storage.clearAll();
    state = const AsyncData(false);
  }

  /// Decode userId (sub claim) from JWT payload without verifying signature.
  String _decodeUserId(String jwt) {
    final parts = jwt.split('.');
    if (parts.length != 3) throw FormatException('Invalid JWT');
    final payload = parts[1];
    final normalized = base64Url.normalize(payload);
    final decoded = utf8.decode(base64Url.decode(normalized));
    final json = jsonDecode(decoded) as Map<String, dynamic>;
    return json['sub'] as String;
  }
}

Add imports at top of file:

dart
import 'dart:convert';

Step 2: Run build_runner

bash
fvm dart run build_runner build --delete-conflicting-outputs

Step 3: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/features/auth/presentation/providers/
git commit -m "feat(flutter): add AuthNotifier Riverpod provider with register/login/logout"

Task 10: AppRouter (go_router + auth redirect)

Files:

  • Create: lib/core/router/app_router.dart

Step 1: Create lib/core/router/app_router.dart

dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../features/auth/presentation/providers/auth_provider.dart';
import '../../features/auth/presentation/screens/login_screen.dart';
import '../../features/auth/presentation/screens/register_screen.dart';

part 'app_router.g.dart';

@riverpod
GoRouter appRouter(AppRouterRef ref) {
  final authState = ref.watch(authNotifierProvider);

  return GoRouter(
    initialLocation: '/auth/login',
    redirect: (context, state) {
      final isAuthenticated = authState.valueOrNull == true;
      final isOnAuthRoute = state.matchedLocation.startsWith('/auth');

      if (!isAuthenticated && !isOnAuthRoute) return '/auth/login';
      if (isAuthenticated && isOnAuthRoute) return '/home';
      return null;
    },
    routes: [
      GoRoute(
        path: '/auth/login',
        builder: (context, state) => const LoginScreen(),
      ),
      GoRoute(
        path: '/auth/register',
        builder: (context, state) => const RegisterScreen(),
      ),
      GoRoute(
        path: '/home',
        builder: (context, state) => const _HomeStub(),
      ),
    ],
  );
}

// Temporary home stub — replaced when home feature is built
class _HomeStub extends StatelessWidget {
  const _HomeStub();

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: Text('Home — coming soon')),
    );
  }
}

Step 2: Run build_runner

bash
fvm dart run build_runner build --delete-conflicting-outputs

Step 3: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/core/router/
git commit -m "feat(flutter): add AppRouter with go_router and auth redirect guard"

Task 11: bootstrap.dart + main.dart

Files:

  • Create: lib/bootstrap.dart
  • Modify: lib/main.dart

Step 1: Create lib/bootstrap.dart

dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart';
import 'core/theme/app_theme.dart';

class AveliaApp extends ConsumerWidget {
  const AveliaApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(appRouterProvider);

    return MaterialApp.router(
      title: 'Avelia',
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
      themeMode: ThemeMode.system,
      routerConfig: router,
      debugShowCheckedModeBanner: false,
    );
  }
}

Step 2: Replace lib/main.dart

dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'bootstrap.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const ProviderScope(child: AveliaApp()));
}

Step 3: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/main.dart avelia-flutter-app/lib/bootstrap.dart
git commit -m "feat(flutter): add bootstrap with ProviderScope and AppTheme"

Task 12: Shared widgets

Files:

  • Create: lib/shared/widgets/primary_button.dart
  • Create: lib/shared/widgets/auth_form_field.dart
  • Create: lib/shared/widgets/loading_overlay.dart

Step 1: Create lib/shared/widgets/primary_button.dart

dart
import 'package:flutter/material.dart';

class PrimaryButton extends StatelessWidget {
  const PrimaryButton({
    super.key,
    required this.label,
    required this.onPressed,
    this.isLoading = false,
  });

  final String label;
  final VoidCallback? onPressed;
  final bool isLoading;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: isLoading ? null : onPressed,
      child: isLoading
          ? const SizedBox(
              height: 20,
              width: 20,
              child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
            )
          : Text(label),
    );
  }
}

Step 2: Create lib/shared/widgets/auth_form_field.dart

dart
import 'package:flutter/material.dart';

class AuthFormField extends StatelessWidget {
  const AuthFormField({
    super.key,
    required this.controller,
    required this.label,
    this.hint,
    this.obscureText = false,
    this.keyboardType,
    this.validator,
    this.textInputAction,
    this.onFieldSubmitted,
  });

  final TextEditingController controller;
  final String label;
  final String? hint;
  final bool obscureText;
  final TextInputType? keyboardType;
  final String? Function(String?)? validator;
  final TextInputAction? textInputAction;
  final void Function(String)? onFieldSubmitted;

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      obscureText: obscureText,
      keyboardType: keyboardType,
      validator: validator,
      textInputAction: textInputAction,
      onFieldSubmitted: onFieldSubmitted,
      decoration: InputDecoration(labelText: label, hintText: hint),
    );
  }
}

Step 3: Create lib/shared/widgets/loading_overlay.dart

dart
import 'package:flutter/material.dart';

class LoadingOverlay extends StatelessWidget {
  const LoadingOverlay({super.key, required this.isLoading, required this.child});

  final bool isLoading;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        child,
        if (isLoading)
          const ColoredBox(
            color: Color(0x66000000),
            child: Center(child: CircularProgressIndicator()),
          ),
      ],
    );
  }
}

Step 4: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/shared/
git commit -m "feat(flutter): add shared widgets (PrimaryButton, AuthFormField, LoadingOverlay)"

Task 13: LoginScreen (TDD — widget test first)

Files:

  • Create: test/features/auth/presentation/login_screen_test.dart
  • Create: lib/features/auth/presentation/screens/login_screen.dart

Step 1: Write the failing widget test

Create test/features/auth/presentation/login_screen_test.dart:

dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:avelia/features/auth/presentation/screens/login_screen.dart';
import 'package:avelia/features/auth/presentation/providers/auth_provider.dart';

// Minimal router for testing navigation
GoRouter _testRouter(Widget home) => GoRouter(
      initialLocation: '/',
      routes: [GoRoute(path: '/', builder: (_, __) => home)],
    );

void main() {
  group('LoginScreen', () {
    testWidgets('renders email and password fields and login button', (tester) async {
      await tester.pumpWidget(
        ProviderScope(
          child: MaterialApp.router(routerConfig: _testRouter(const LoginScreen())),
        ),
      );

      expect(find.byType(TextFormField), findsNWidgets(2));
      expect(find.text('Sign in'), findsOneWidget);
      expect(find.text("Don't have an account?"), findsOneWidget);
    });

    testWidgets('shows validation error when fields are empty', (tester) async {
      await tester.pumpWidget(
        ProviderScope(
          child: MaterialApp.router(routerConfig: _testRouter(const LoginScreen())),
        ),
      );

      await tester.tap(find.text('Sign in'));
      await tester.pump();

      expect(find.text('Enter your email'), findsOneWidget);
      expect(find.text('Enter your password'), findsOneWidget);
    });
  });
}

Step 2: Run test to verify it fails

bash
fvm flutter test test/features/auth/presentation/login_screen_test.dart

Expected: FAIL — Target of URI doesn't exist: 'package:avelia/features/auth/presentation/screens/login_screen.dart'

Step 3: Create lib/features/auth/presentation/screens/login_screen.dart

dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../shared/widgets/auth_form_field.dart';
import '../../../../shared/widgets/primary_button.dart';
import '../providers/auth_provider.dart';

class LoginScreen extends ConsumerStatefulWidget {
  const LoginScreen({super.key});

  @override
  ConsumerState<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends ConsumerState<LoginScreen> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    if (!(_formKey.currentState?.validate() ?? false)) return;

    await ref.read(authNotifierProvider.notifier).login(
          email: _emailController.text.trim(),
          password: _passwordController.text,
          deviceId: 'device-placeholder', // TODO: replace with real device UUID
        );

    if (!mounted) return;
    final authState = ref.read(authNotifierProvider);
    if (authState.hasError) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Invalid email or password')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final isLoading = ref.watch(authNotifierProvider).isLoading;

    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Text(
                  'Welcome back',
                  style: Theme.of(context).textTheme.displayLarge,
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 8),
                Text(
                  'Sign in to Avelia',
                  style: Theme.of(context).textTheme.bodyMedium,
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 40),
                AuthFormField(
                  controller: _emailController,
                  label: 'Email',
                  keyboardType: TextInputType.emailAddress,
                  textInputAction: TextInputAction.next,
                  validator: (v) =>
                      (v == null || v.isEmpty) ? 'Enter your email' : null,
                ),
                const SizedBox(height: 16),
                AuthFormField(
                  controller: _passwordController,
                  label: 'Password',
                  obscureText: true,
                  textInputAction: TextInputAction.done,
                  onFieldSubmitted: (_) => _submit(),
                  validator: (v) =>
                      (v == null || v.isEmpty) ? 'Enter your password' : null,
                ),
                const SizedBox(height: 24),
                PrimaryButton(
                  label: 'Sign in',
                  onPressed: _submit,
                  isLoading: isLoading,
                ),
                const SizedBox(height: 16),
                TextButton(
                  onPressed: () => context.go('/auth/register'),
                  child: const Text("Don't have an account?"),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Step 4: Run test to verify it passes

bash
fvm flutter test test/features/auth/presentation/login_screen_test.dart

Expected: PASS — 2 tests passing

Step 5: Commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add avelia-flutter-app/lib/features/auth/presentation/screens/login_screen.dart avelia-flutter-app/test/features/auth/presentation/login_screen_test.dart
git commit -m "feat(flutter): add LoginScreen with form validation (TDD)"

Task 14: RegisterScreen (TDD) + full test run + smoke test

Files:

  • Create: test/features/auth/presentation/register_screen_test.dart
  • Create: lib/features/auth/presentation/screens/register_screen.dart

Step 1: Write the failing widget test

Create test/features/auth/presentation/register_screen_test.dart:

dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:avelia/features/auth/presentation/screens/register_screen.dart';

GoRouter _testRouter(Widget home) => GoRouter(
      initialLocation: '/',
      routes: [GoRoute(path: '/', builder: (_, __) => home)],
    );

void main() {
  group('RegisterScreen', () {
    testWidgets('renders email, password, confirm fields and register button', (tester) async {
      await tester.pumpWidget(
        ProviderScope(
          child: MaterialApp.router(routerConfig: _testRouter(const RegisterScreen())),
        ),
      );

      expect(find.byType(TextFormField), findsNWidgets(3));
      expect(find.text('Create account'), findsOneWidget);
      expect(find.text('Already have an account?'), findsOneWidget);
    });

    testWidgets('shows validation error when passwords do not match', (tester) async {
      await tester.pumpWidget(
        ProviderScope(
          child: MaterialApp.router(routerConfig: _testRouter(const RegisterScreen())),
        ),
      );

      await tester.enterText(find.byType(TextFormField).at(0), 'test@avelia.com');
      await tester.enterText(find.byType(TextFormField).at(1), 'Password123!');
      await tester.enterText(find.byType(TextFormField).at(2), 'DifferentPass!');
      await tester.tap(find.text('Create account'));
      await tester.pump();

      expect(find.text('Passwords do not match'), findsOneWidget);
    });
  });
}

Step 2: Run to verify it fails

bash
fvm flutter test test/features/auth/presentation/register_screen_test.dart

Expected: FAIL

Step 3: Create lib/features/auth/presentation/screens/register_screen.dart

dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../shared/widgets/auth_form_field.dart';
import '../../../../shared/widgets/primary_button.dart';
import '../providers/auth_provider.dart';

class RegisterScreen extends ConsumerStatefulWidget {
  const RegisterScreen({super.key});

  @override
  ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}

class _RegisterScreenState extends ConsumerState<RegisterScreen> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmController = TextEditingController();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    _confirmController.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    if (!(_formKey.currentState?.validate() ?? false)) return;

    await ref.read(authNotifierProvider.notifier).register(
          email: _emailController.text.trim(),
          password: _passwordController.text,
          deviceId: 'device-placeholder', // TODO: replace with real device UUID
        );

    if (!mounted) return;
    final authState = ref.read(authNotifierProvider);
    if (authState.hasError) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Registration failed. Please try again.')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final isLoading = ref.watch(authNotifierProvider).isLoading;

    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Text(
                  'Create account',
                  style: Theme.of(context).textTheme.displayLarge,
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 8),
                Text(
                  'Start your Avelia journey',
                  style: Theme.of(context).textTheme.bodyMedium,
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 40),
                AuthFormField(
                  controller: _emailController,
                  label: 'Email',
                  keyboardType: TextInputType.emailAddress,
                  textInputAction: TextInputAction.next,
                  validator: (v) =>
                      (v == null || v.isEmpty) ? 'Enter your email' : null,
                ),
                const SizedBox(height: 16),
                AuthFormField(
                  controller: _passwordController,
                  label: 'Password',
                  obscureText: true,
                  textInputAction: TextInputAction.next,
                  validator: (v) =>
                      (v == null || v.length < 8) ? 'At least 8 characters' : null,
                ),
                const SizedBox(height: 16),
                AuthFormField(
                  controller: _confirmController,
                  label: 'Confirm password',
                  obscureText: true,
                  textInputAction: TextInputAction.done,
                  onFieldSubmitted: (_) => _submit(),
                  validator: (v) => v != _passwordController.text
                      ? 'Passwords do not match'
                      : null,
                ),
                const SizedBox(height: 24),
                PrimaryButton(
                  label: 'Create account',
                  onPressed: _submit,
                  isLoading: isLoading,
                ),
                const SizedBox(height: 16),
                TextButton(
                  onPressed: () => context.go('/auth/login'),
                  child: const Text('Already have an account?'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Step 4: Run widget test to verify it passes

bash
fvm flutter test test/features/auth/presentation/register_screen_test.dart

Expected: PASS — 2 tests

Step 5: Run all Flutter tests

bash
fvm flutter test

Expected: All tests PASS (no failures)

Step 6: Flutter analyze

bash
fvm flutter analyze

Expected: No issues found! or only info-level warnings (no errors).

Step 7: Smoke test on simulator

bash
# List available devices
fvm flutter devices

# Run on iOS simulator (or Android emulator)
fvm flutter run --dart-define=BASE_URL=http://localhost:3000

Expected: App launches, shows LoginScreen with Avelia branding, can navigate to RegisterScreen.

Step 8: Update CLAUDE.md + memory

In avelia-flutter-app/CLAUDE.md, update dart run patrol test to patrol test (already done in previous session).

Append to /Users/antonanders/.claude/projects/-Volumes-exSSD-dev-baby-parents-app/memory/MEMORY.md:

markdown

## Flutter Status
- Scaffolded: Flutter 3.35.7 (FVM), Riverpod 2 code-gen, go_router, Drift (schema TBD)
- Auth flow: LoginScreen → RegisterScreen → /home (stub)
- CryptoService: Argon2id MEK derivation (32 bytes from password + argon2Salt)
- MEK stored in flutter_secure_storage keyed to userId
- BASE_URL injected via --dart-define=BASE_URL=http://localhost:3000
- Device ID: currently a placeholder string — replace with platform UUID package in next sprint
- Widget tests: passing (test/features/auth/presentation/)

Step 9: Final commit

bash
cd /Volumes/exSSD/dev/baby-parents-app
git add .
git commit -m "feat(flutter): complete Flutter init — auth screens, crypto layer, Riverpod providers"

Private by design.