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,40 @@
import 'package:christian_period_tracker/models/cycle_entry.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('CycleEntry', () {
test('hasSymptoms returns true when there are symptoms', () {
final entry = CycleEntry(
id: '1',
date: DateTime.now(),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
hasHeadache: true,
);
expect(entry.hasSymptoms, isTrue);
});
test('hasSymptoms returns false when there are no symptoms', () {
final entry = CycleEntry(
id: '1',
date: DateTime.now(),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
expect(entry.hasSymptoms, isFalse);
});
test('symptomCount returns the correct number of symptoms', () {
final entry = CycleEntry(
id: '1',
date: DateTime.now(),
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
hasHeadache: true,
hasBloating: true,
crampIntensity: 2,
);
expect(entry.symptomCount, 3);
});
});
}

View File

@@ -7,8 +7,8 @@ void main() {
group('CycleService Tests', () {
test('calculateCycleInfo returns follicular phase for null profile', () {
final info = CycleService.calculateCycleInfo(null);
expect(info['phase'], CyclePhase.follicular);
expect(info['dayOfCycle'], 1);
expect(info.phase, CyclePhase.follicular);
expect(info.dayOfCycle, 1);
});
test('calculateCycleInfo calculates follicular phase correctly (Day 7)', () {
@@ -26,8 +26,8 @@ void main() {
);
final info = CycleService.calculateCycleInfo(user);
expect(info['dayOfCycle'], 7);
expect(info['phase'], CyclePhase.follicular);
expect(info.dayOfCycle, 7);
expect(info.phase, CyclePhase.follicular);
});
test('calculateCycleInfo calculates menstrual phase correctly (Day 2)', () {
@@ -45,8 +45,8 @@ void main() {
);
final info = CycleService.calculateCycleInfo(user);
expect(info['dayOfCycle'], 2);
expect(info['phase'], CyclePhase.menstrual);
expect(info.dayOfCycle, 2);
expect(info.phase, CyclePhase.menstrual);
});
test('calculateCycleInfo handles cycle length wrapping', () {
@@ -65,8 +65,8 @@ void main() {
);
final info = CycleService.calculateCycleInfo(user);
expect(info['dayOfCycle'], 3);
expect(info['phase'], CyclePhase.menstrual);
expect(info.dayOfCycle, 3);
expect(info.phase, CyclePhase.menstrual);
});
});
}

5
test/mocks.dart Normal file
View File

@@ -0,0 +1,5 @@
import 'package:mockito/annotations.dart';
import 'package:christian_period_tracker/services/bible_xml_parser.dart';
@GenerateMocks([BibleXmlParser])
void main() {} // Dummy main function to satisfy Dart

91
test/mocks.mocks.dart Normal file
View File

@@ -0,0 +1,91 @@
// Mocks generated by Mockito 5.4.4 from annotations
// in christian_period_tracker/test/mocks.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i4;
import 'package:christian_period_tracker/services/bible_xml_parser.dart' as _i3;
import 'package:mockito/mockito.dart' as _i1;
import 'package:xml/xml.dart' as _i2;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
// ignore_for_file: deprecated_member_use_from_same_package
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
class _FakeXmlDocument_0 extends _i1.SmartFake implements _i2.XmlDocument {
_FakeXmlDocument_0(
Object parent,
Invocation parentInvocation,
) : super(
parent,
parentInvocation,
);
}
/// A class which mocks [BibleXmlParser].
///
/// See the documentation for Mockito's code generation for more information.
class MockBibleXmlParser extends _i1.Mock implements _i3.BibleXmlParser {
MockBibleXmlParser() {
_i1.throwOnMissingStub(this);
}
@override
_i4.Future<_i2.XmlDocument> loadXmlAsset(String? assetPath) =>
(super.noSuchMethod(
Invocation.method(
#loadXmlAsset,
[assetPath],
),
returnValue: _i4.Future<_i2.XmlDocument>.value(_FakeXmlDocument_0(
this,
Invocation.method(
#loadXmlAsset,
[assetPath],
),
)),
) as _i4.Future<_i2.XmlDocument>);
@override
String? getVerseFromXml(
_i2.XmlDocument? document,
String? bookName,
int? chapterNum,
int? verseNum,
) =>
(super.noSuchMethod(Invocation.method(
#getVerseFromXml,
[
document,
bookName,
chapterNum,
verseNum,
],
)) as String?);
@override
_i4.Future<String?> getVerseFromAsset(
String? assetPath,
String? reference,
) =>
(super.noSuchMethod(
Invocation.method(
#getVerseFromAsset,
[
assetPath,
reference,
],
),
returnValue: _i4.Future<String?>.value(),
) as _i4.Future<String?>);
}

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);
});
});
}

155
test/scripture_test.dart Normal file
View File

@@ -0,0 +1,155 @@
import 'dart:convert';
import 'dart:io';
import 'package:christian_period_tracker/models/scripture.dart';
import 'package:christian_period_tracker/models/user_profile.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:path_provider_linux/path_provider_linux.dart';
import 'package:mockito/mockito.dart';
import 'mocks.mocks.dart'; // Generated mock file
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Scripture', () {
test('fromJson creates a valid Scripture object', () {
final json = {
"verses": {
"esv": "Test verse",
},
"reference": "Test 1:1",
};
final scripture = Scripture.fromJson(json);
expect(scripture.reference, "Test 1:1");
expect(scripture.getVerse(BibleTranslation.esv), "Test verse");
});
});
group('ScriptureDatabase', () {
late ScriptureDatabase database;
late String testPath;
setUpAll(() async {
// Initialize path_provider_platform_interface for testing
// This is a common pattern for mocking platform-specific plugins in tests.
PathProviderPlatform.instance = _MockPathProviderPlatform();
testPath = Directory.current.path + '/test_hive_temp';
// Ensure the directory exists
final Directory tempDir = Directory(testPath);
if (!await tempDir.exists()) {
await tempDir.create(recursive: true);
}
// Create and configure the mock BibleXmlParser
final mockBibleXmlParser = MockBibleXmlParser();
when(mockBibleXmlParser.getVerseFromAsset(any, any)).thenAnswer((invocation) async {
final String reference = invocation.positionalArguments[1];
// Return a mock verse based on the reference for testing purposes
return 'Mock Verse for $reference';
});
// Mock the rootBundle for JSON assets (XML assets are now handled by mockBibleXmlParser)
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMessageHandler(
'flutter/assets',
(ByteData? message) async {
final String key = utf8.decode(message!.buffer.asUint8List());
if (key == 'assets/scriptures.json' || key == 'assets/scriptures_optimized.json') {
final json = {
"menstrual": [
{
"verses": {"esv": "Menstrual verse"},
"reference": "Menstrual 1:1",
"applicablePhases": ["menstrual"]
}
],
"follicular": [
{
"verses": {"esv": "Follicular verse"},
"reference": "Follicular 1:1",
"applicablePhases": ["follicular"]
}
],
"ovulation": [
{
"verses": {"esv": "Ovulation verse"},
"reference": "Ovulation 1:1",
"applicablePhases": ["ovulation"]
}
],
"luteal": [
{
"verses": {"esv": "Luteal verse"},
"reference": "Luteal 1:1",
"applicablePhases": ["luteal"]
}
],
"husband": [
{
"verses": {"esv": "Husband verse"},
"reference": "Husband 1:1",
"applicablePhases": ["husband"]
}
],
"womanhood": [
{
"verses": {"esv": "Womanhood verse"},
"reference": "Womanhood 1:1",
"applicableContexts": ["womanhood"]
}
],
"contextual": {
"pain": [
{
"verses": {"esv": "Pain verse"},
"reference": "Pain 1:1",
"applicableContexts": ["pain"]
}
]
}
};
return utf8.encode(jsonEncode(json)).buffer.asByteData();
}
return null; // Return null for other asset requests not explicitly mocked
},
);
Hive.init(testPath);
Hive.registerAdapter(ScriptureAdapter()); // Register your adapter
Hive.registerAdapter(BibleTranslationAdapter()); // Register BibleTranslationAdapter
database = ScriptureDatabase(bibleXmlParser: mockBibleXmlParser); // Instantiate with mock
await database.loadScriptures();
});
tearDownAll(() async {
await Hive.close();
await Directory(testPath).delete(recursive: true);
});
test('getScriptureForPhase returns the correct scripture', () {
final scripture = database.getScriptureForPhase('menstrual');
expect(scripture.reference, "Menstrual 1:1");
});
test('getHusbandScripture returns a husband scripture', () {
final scripture = database.getHusbandScripture();
expect(scripture.applicablePhases, contains('husband'));
});
});
}
class _MockPathProviderPlatform extends PathProviderPlatform {
@override
Future<String?> getApplicationSupportPath() async {
return Directory.current.path + '/test_hive_temp';
}
@override
Future<String?> getApplicationDocumentsPath() async {
return Directory.current.path + '/test_hive_temp';
}
}