feat: Implement husband features and fix iOS Safari web startup

Implement initial features for husband's companion app, including mock data
service and husband notes screen. Refactor scripture and cycle services
for improved stability and testability. Address iOS Safari web app
startup issue by removing deprecated initialization.

- Implemented MockDataService and HusbandNotesScreen.
- Converted _DashboardTab and DevotionalScreen to StatefulWidgets for robust
  scripture provider initialization.
- Refactored CycleService to use immutable CycleInfo class, reducing UI rebuilds.
- Removed deprecated window.flutterConfiguration from index.html, resolving
  Flutter web app startup failure on iOS Safari.
- Updated and fixed related tests.
This commit is contained in:
2025-12-26 22:40:52 -06:00
parent 464692ce56
commit b4b2bfe749
47 changed files with 240110 additions and 2578 deletions

View File

@@ -0,0 +1,262 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mockito/mockito.dart';
import 'package:christian_period_tracker/models/scripture.dart';
import 'package:christian_period_tracker/models/cycle_entry.dart';
import 'package:christian_period_tracker/models/user_profile.dart';
import 'package:christian_period_tracker/providers/scripture_provider.dart';
import 'package:christian_period_tracker/providers/user_provider.dart';
import 'package:hive_flutter/hive_flutter.dart';
// Fake ScriptureDatabase implementation for testing
class FakeScriptureDatabase implements ScriptureDatabase {
final int Function(String phase) getScriptureCountForPhaseFn;
final Scripture? Function(String phase, int index) getScriptureForPhaseByIndexFn;
final Scripture? Function(String phase)? getRandomScriptureForPhaseFn;
FakeScriptureDatabase({
required this.getScriptureCountForPhaseFn,
required this.getScriptureForPhaseByIndexFn,
this.getRandomScriptureForPhaseFn,
});
@override
int getScriptureCountForPhase(String phase) => getScriptureCountForPhaseFn(phase);
@override
Scripture? getScriptureForPhaseByIndex(String phase, int index) =>
getScriptureForPhaseByIndexFn(phase, index);
@override
Scripture? getRandomScriptureForPhase(String phase) =>
getRandomScriptureForPhaseFn != null ? getRandomScriptureForPhaseFn!(phase) : null;
// Unimplemented methods (not used by ScriptureNotifier)
@override
List<Scripture> getAllScriptures() => throw UnimplementedError();
@override
Scripture getHusbandScripture() => throw UnimplementedError();
@override
Future<void> loadScriptures() => Future.value(); // Can be mocked to do nothing
@override
Scripture? getRecommendedScripture(CycleEntry entry) => throw UnimplementedError();
@override
Scripture getScriptureForPhase(String phase) => throw UnimplementedError();
}
void main() {
group('ScriptureNotifier', () {
late ProviderContainer container;
late String testPath;
setUpAll(() async {
testPath = Directory.current.path + '/test_hive_temp_scripture_provider';
final Directory tempDir = Directory(testPath);
if (!await tempDir.exists()) {
await tempDir.create(recursive: true);
}
Hive.init(testPath);
Hive.registerAdapter(UserProfileAdapter());
Hive.registerAdapter(RelationshipStatusAdapter());
Hive.registerAdapter(FertilityGoalAdapter());
Hive.registerAdapter(BibleTranslationAdapter());
Hive.registerAdapter(UserRoleAdapter());
await Hive.openBox<UserProfile>('user_profile');
});
tearDownAll(() async {
await Hive.close();
await Directory(testPath).delete(recursive: true);
});
final testScripture1 = Scripture(
verses: {BibleTranslation.esv: "Verse 1"},
reference: "Ref 1",
applicablePhases: ['menstrual'],
);
final testScripture2 = Scripture(
verses: {BibleTranslation.esv: "Verse 2"},
reference: "Ref 2",
applicablePhases: ['menstrual'],
);
final testScripture3 = Scripture(
verses: {BibleTranslation.esv: "Verse 3"},
reference: "Ref 3",
applicablePhases: ['menstrual'],
);
tearDown(() {
container.dispose();
});
test('initializes with correct scripture for phase', () async {
final fakeDb = FakeScriptureDatabase(
getScriptureCountForPhaseFn: (phase) => 3,
getScriptureForPhaseByIndexFn: (phase, index) => testScripture1,
);
container = ProviderContainer(
overrides: [
scriptureDatabaseProvider.overrideWithValue(fakeDb),
userProfileProvider.overrideWith(
(ref) => UserProfileNotifier(),
),
],
);
final notifier = container.read(scriptureProvider.notifier);
notifier.initializeScripture(CyclePhase.menstrual);
final state = container.read(scriptureProvider);
expect(state.currentScripture, testScripture1);
expect(state.currentPhase, CyclePhase.menstrual);
expect(state.maxIndex, 3);
// currentIndex will depend on dayOfYear % 3, which is hard to predict
// So we'll just check it's within bounds.
expect(state.currentIndex, isNonNegative);
expect(state.currentIndex, lessThan(3));
});
test('getNextScripture cycles correctly', () async {
final scriptures = [testScripture1, testScripture2, testScripture3];
final fakeDb = FakeScriptureDatabase(
getScriptureCountForPhaseFn: (phase) => scriptures.length,
getScriptureForPhaseByIndexFn: (phase, index) => scriptures[index],
);
container = ProviderContainer(
overrides: [
scriptureDatabaseProvider.overrideWithValue(fakeDb),
userProfileProvider.overrideWith(
(ref) => UserProfileNotifier(),
),
],
);
final notifier = container.read(scriptureProvider.notifier);
// Force initial state to 0 for predictable cycling
notifier.initializeScripture(CyclePhase.menstrual);
// Override currentIndex to 0 for predictable test
container.read(scriptureProvider.notifier).state = container.read(scriptureProvider).copyWith(currentIndex: 0);
// First next
notifier.getNextScripture();
expect(container.read(scriptureProvider).currentScripture, testScripture2);
expect(container.read(scriptureProvider).currentIndex, 1);
// Second next
notifier.getNextScripture();
expect(container.read(scriptureProvider).currentScripture, testScripture3);
expect(container.read(scriptureProvider).currentIndex, 2);
// Wrap around
notifier.getNextScripture();
expect(container.read(scriptureProvider).currentScripture, testScripture1);
expect(container.read(scriptureProvider).currentIndex, 0);
});
test('getPreviousScripture cycles correctly', () async {
final scriptures = [testScripture1, testScripture2, testScripture3];
final fakeDb = FakeScriptureDatabase(
getScriptureCountForPhaseFn: (phase) => scriptures.length,
getScriptureForPhaseByIndexFn: (phase, index) => scriptures[index],
);
container = ProviderContainer(
overrides: [
scriptureDatabaseProvider.overrideWithValue(fakeDb),
userProfileProvider.overrideWith(
(ref) => UserProfileNotifier(),
),
],
);
final notifier = container.read(scriptureProvider.notifier);
// Force initial state to 0 for predictable cycling
notifier.initializeScripture(CyclePhase.menstrual);
// Override currentIndex to 0 for predictable test
container.read(scriptureProvider.notifier).state = container.read(scriptureProvider).copyWith(currentIndex: 0);
// First previous (wraps around)
notifier.getPreviousScripture();
expect(container.read(scriptureProvider).currentScripture, testScripture3);
expect(container.read(scriptureProvider).currentIndex, 2);
// Second previous
notifier.getPreviousScripture();
expect(container.read(scriptureProvider).currentScripture, testScripture2);
expect(container.read(scriptureProvider).currentIndex, 1);
// Third previous
notifier.getPreviousScripture();
expect(container.read(scriptureProvider).currentScripture, testScripture1);
expect(container.read(scriptureProvider).currentIndex, 0);
});
test('getRandomScripture updates to a valid scripture', () async {
final scriptures = [testScripture1, testScripture2, testScripture3];
final fakeDb = FakeScriptureDatabase(
getScriptureCountForPhaseFn: (phase) => scriptures.length,
getScriptureForPhaseByIndexFn: (phase, index) => scriptures[index % scriptures.length], // Ensure it always returns a valid one
);
container = ProviderContainer(
overrides: [
scriptureDatabaseProvider.overrideWithValue(fakeDb),
userProfileProvider.overrideWith(
(ref) => UserProfileNotifier(),
),
],
);
final notifier = container.read(scriptureProvider.notifier);
notifier.initializeScripture(CyclePhase.menstrual);
// Perform a random selection
notifier.getRandomScripture();
final state = container.read(scriptureProvider);
expect(state.currentScripture, isNotNull);
expect(state.currentScripture, isIn(scriptures)); // Ensure it's one of the valid scriptures
expect(state.currentIndex, isNonNegative);
expect(state.currentIndex, lessThan(scriptures.length));
});
test('does not change state if maxIndex is 0', () async {
final fakeDb = FakeScriptureDatabase(
getScriptureCountForPhaseFn: (phase) => 0,
getScriptureForPhaseByIndexFn: (phase, index) => null,
);
container = ProviderContainer(
overrides: [
scriptureDatabaseProvider.overrideWithValue(fakeDb),
userProfileProvider.overrideWith(
(ref) => UserProfileNotifier(),
),
],
);
final notifier = container.read(scriptureProvider.notifier);
notifier.initializeScripture(CyclePhase.menstrual);
final initialState = container.read(scriptureProvider);
expect(initialState.currentScripture, isNull);
expect(initialState.maxIndex, 0);
notifier.getNextScripture();
notifier.getPreviousScripture();
notifier.getRandomScripture();
// State should remain unchanged
expect(container.read(scriptureProvider), initialState);
});
});
}