Appearance
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-appTask 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.7Expected: .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-pubExpected: 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: trueStep 4: Run flutter pub get
bash
fvm flutter pub getExpected: 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_parametersStep 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/helpersStep 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-outputsExpected: 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/cryptodart
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.dartExpected: 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-outputsStep 6: Run test to verify it passes
bash
fvm flutter test test/core/crypto/crypto_service_test.dartExpected: 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-outputsStep 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-outputsExpected: .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-outputsExpected: 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-outputsStep 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-outputsStep 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.dartExpected: 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.dartExpected: 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.dartExpected: 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.dartExpected: PASS — 2 tests
Step 5: Run all Flutter tests
bash
fvm flutter testExpected: All tests PASS (no failures)
Step 6: Flutter analyze
bash
fvm flutter analyzeExpected: 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:3000Expected: 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"