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:
166
lib/services/bible_xml_parser.dart
Normal file
166
lib/services/bible_xml_parser.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:xml/xml.dart';
|
||||
import '../models/scripture.dart'; // Assuming Scripture model might need BibleTranslation
|
||||
|
||||
class BibleXmlParser {
|
||||
// Map of common Bible book names to their standard abbreviations or keys used in XML
|
||||
// This will help in matching references like "Matthew 11:28" to XML structure.
|
||||
static const Map<String, String> _bookAbbreviations = {
|
||||
'genesis': 'Gen', 'exodus': 'Exod', 'leviticus': 'Lev', 'numbers': 'Num',
|
||||
'deuteronomy': 'Deut', 'joshua': 'Josh', 'judges': 'Judg', 'ruth': 'Ruth',
|
||||
'1 samuel': '1Sam', '2 samuel': '2Sam', '1 kings': '1Kgs', '2 kings': '2Kgs',
|
||||
'1 chronicles': '1Chr', '2 chronicles': '2Chr', 'ezra': 'Ezra', 'nehemiah': 'Neh',
|
||||
'esther': 'Esth', 'job': 'Job', 'psalm': 'Ps', 'proverbs': 'Prov',
|
||||
'ecclesiastes': 'Eccl', 'song of solomon': 'Song', 'isaiah': 'Isa', 'jeremiah': 'Jer',
|
||||
'lamentations': 'Lam', 'ezekiel': 'Ezek', 'daniel': 'Dan', 'hosea': 'Hos',
|
||||
'joel': 'Joel', 'amos': 'Amos', 'obadiah': 'Obad', 'jonah': 'Jonah',
|
||||
'micah': 'Mic', 'nahum': 'Nah', 'habakkuk': 'Hab', 'zephaniah': 'Zeph',
|
||||
'haggai': 'Hag', 'zechariah': 'Zech', 'malachi': 'Mal',
|
||||
'matthew': 'Matt', 'mark': 'Mark', 'luke': 'Luke', 'john': 'John',
|
||||
'acts': 'Acts', 'romans': 'Rom', '1 corinthians': '1Cor', '2 corinthians': '2Cor',
|
||||
'galatians': 'Gal', 'ephesians': 'Eph', 'philippians': 'Phil', 'colossians': 'Col',
|
||||
'1 thessalonians': '1Thess', '2 thessalonians': '2Thess', '1 timothy': '1Tim',
|
||||
'2 timothy': '2Tim', 'titus': 'Titus', 'philemon': 'Phlm', 'hebrews': 'Heb',
|
||||
'james': 'Jas', '1 peter': '1Pet', '2 peter': '2Pet', '1 john': '1John',
|
||||
'2 john': '2John', '3 john': '3John', 'jude': 'Jude', 'revelation': 'Rev',
|
||||
// Add more common names/abbreviations if necessary
|
||||
};
|
||||
|
||||
/// Parses a Bible reference string (e.g., "Matthew 11:28") into its components.
|
||||
static Map<String, String>? parseReference(String reference) {
|
||||
final parts = reference.split(' ');
|
||||
if (parts.length < 2) return null; // Needs at least Book and Chapter:Verse
|
||||
|
||||
String book = parts.sublist(0, parts.length - 1).join(' ').toLowerCase();
|
||||
String chapterVerse = parts.last;
|
||||
|
||||
final chapterVerseParts = chapterVerse.split(':');
|
||||
if (chapterVerseParts.length != 2) return null; // Must have Chapter:Verse
|
||||
|
||||
return {
|
||||
'book': book,
|
||||
'chapter': chapterVerseParts[0],
|
||||
'verse': chapterVerseParts[1],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Cache for parsed XML documents to avoid reloading/reparsing
|
||||
static final Map<String, XmlDocument> _xmlCache = {};
|
||||
|
||||
/// Loads an XML Bible file from assets and returns the parsed document.
|
||||
Future<XmlDocument> loadXmlAsset(String assetPath) async {
|
||||
if (_xmlCache.containsKey(assetPath)) {
|
||||
return _xmlCache[assetPath]!;
|
||||
}
|
||||
|
||||
print('Loading and parsing XML asset: $assetPath'); // Debug log
|
||||
final String xmlString = await rootBundle.loadString(assetPath);
|
||||
final document = XmlDocument.parse(xmlString);
|
||||
_xmlCache[assetPath] = document;
|
||||
return document;
|
||||
}
|
||||
|
||||
/// Extracts a specific verse from a parsed XML document.
|
||||
/// Supports two schemas:
|
||||
/// 1. <XMLBIBLE><BIBLEBOOK bname="..."><CHAPTER cnumber="..."><VERS vnumber="...">
|
||||
/// 2. <bible><b n="..."><c n="..."><v n="...">
|
||||
/// Extracts a specific verse from a parsed XML document.
|
||||
/// Supports two schemas:
|
||||
/// 1. <XMLBIBLE><BIBLEBOOK bname="..."><CHAPTER cnumber="..."><VERS vnumber="...">
|
||||
/// 2. <bible><b n="..."><c n="..."><v n="...">
|
||||
String? getVerseFromXml(XmlDocument document, String bookName, int chapterNum, int verseNum) {
|
||||
// Standardize book name for lookup
|
||||
String lookupBookName = _bookAbbreviations[bookName.toLowerCase()] ?? bookName;
|
||||
|
||||
// Use root element to avoid full document search
|
||||
XmlElement root = document.rootElement;
|
||||
|
||||
// -- Find Book --
|
||||
// Try Schema 1: Direct child of root
|
||||
var bookElement = root.findElements('BIBLEBOOK').firstWhere(
|
||||
(element) {
|
||||
final nameAttr = element.getAttribute('bname');
|
||||
return nameAttr?.toLowerCase() == lookupBookName.toLowerCase() ||
|
||||
nameAttr?.toLowerCase() == bookName.toLowerCase();
|
||||
},
|
||||
orElse: () => XmlElement(XmlName('notfound')),
|
||||
);
|
||||
|
||||
// Try Schema 2 if not found
|
||||
if (bookElement.name.local == 'notfound') {
|
||||
bookElement = root.findElements('b').firstWhere(
|
||||
(element) {
|
||||
final nameAttr = element.getAttribute('n');
|
||||
return nameAttr?.toLowerCase() == lookupBookName.toLowerCase() ||
|
||||
nameAttr?.toLowerCase() == bookName.toLowerCase();
|
||||
},
|
||||
orElse: () => XmlElement(XmlName('notfound')),
|
||||
);
|
||||
}
|
||||
|
||||
if (bookElement.name.local == 'notfound') {
|
||||
// print('Book "$bookName" not found in XML.'); // Commented out to reduce log spam
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Find Chapter --
|
||||
// Try Schema 1: Direct child of book
|
||||
var chapterElement = bookElement.findElements('CHAPTER').firstWhere(
|
||||
(element) => element.getAttribute('cnumber') == chapterNum.toString(),
|
||||
orElse: () => XmlElement(XmlName('notfound')),
|
||||
);
|
||||
|
||||
// Try Schema 2 if not found
|
||||
if (chapterElement.name.local == 'notfound') {
|
||||
chapterElement = bookElement.findElements('c').firstWhere(
|
||||
(element) => element.getAttribute('n') == chapterNum.toString(),
|
||||
orElse: () => XmlElement(XmlName('notfound')),
|
||||
);
|
||||
}
|
||||
|
||||
if (chapterElement.name.local == 'notfound') {
|
||||
// print('Chapter "$chapterNum" not found for book "$bookName".');
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Find Verse --
|
||||
// Try Schema 1: Direct child of chapter
|
||||
var verseElement = chapterElement.findElements('VERS').firstWhere(
|
||||
(element) => element.getAttribute('vnumber') == verseNum.toString(),
|
||||
orElse: () => XmlElement(XmlName('notfound')),
|
||||
);
|
||||
|
||||
// Try Schema 2 if not found
|
||||
if (verseElement.name.local == 'notfound') {
|
||||
verseElement = chapterElement.findElements('v').firstWhere(
|
||||
(element) => element.getAttribute('n') == verseNum.toString(),
|
||||
orElse: () => XmlElement(XmlName('notfound')),
|
||||
);
|
||||
}
|
||||
|
||||
if (verseElement.name.local == 'notfound') {
|
||||
// print('Verse "$verseNum" not found for Chapter "$chapterNum", book "$bookName".');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the text content of the verse
|
||||
return verseElement.innerText.trim();
|
||||
}
|
||||
|
||||
/// Retrieves a specific verse from an XML asset file.
|
||||
Future<String?> getVerseFromAsset(String assetPath, String reference) async {
|
||||
final parsedRef = parseReference(reference);
|
||||
if (parsedRef == null) {
|
||||
print('Invalid reference format: $reference');
|
||||
return null;
|
||||
}
|
||||
|
||||
final document = await loadXmlAsset(assetPath);
|
||||
final bookName = parsedRef['book']!;
|
||||
final chapterNum = int.parse(parsedRef['chapter']!);
|
||||
final verseNum = int.parse(parsedRef['verse']!);
|
||||
|
||||
return getVerseFromXml(document, bookName, chapterNum, verseNum);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,47 @@
|
||||
import '../models/user_profile.dart';
|
||||
import '../models/cycle_entry.dart';
|
||||
|
||||
class CycleInfo {
|
||||
final CyclePhase phase;
|
||||
final int dayOfCycle;
|
||||
final int daysUntilPeriod;
|
||||
final bool isPeriodExpected;
|
||||
|
||||
CycleInfo({
|
||||
required this.phase,
|
||||
required this.dayOfCycle,
|
||||
required this.daysUntilPeriod,
|
||||
required this.isPeriodExpected,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CycleInfo &&
|
||||
runtimeType == other.runtimeType &&
|
||||
phase == other.phase &&
|
||||
dayOfCycle == other.dayOfCycle &&
|
||||
daysUntilPeriod == other.daysUntilPeriod &&
|
||||
isPeriodExpected == other.isPeriodExpected;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
phase.hashCode ^
|
||||
dayOfCycle.hashCode ^
|
||||
daysUntilPeriod.hashCode ^
|
||||
isPeriodExpected.hashCode;
|
||||
}
|
||||
|
||||
class CycleService {
|
||||
/// Calculates the current cycle information based on user profile
|
||||
static Map<String, dynamic> calculateCycleInfo(UserProfile? user) {
|
||||
static CycleInfo calculateCycleInfo(UserProfile? user) {
|
||||
if (user?.lastPeriodStartDate == null) {
|
||||
return {
|
||||
'phase': CyclePhase.follicular,
|
||||
'dayOfCycle': 1,
|
||||
'daysUntilPeriod': user?.averageCycleLength ?? 28,
|
||||
'isPeriodExpected': false,
|
||||
};
|
||||
return CycleInfo(
|
||||
phase: CyclePhase.follicular,
|
||||
dayOfCycle: 1,
|
||||
daysUntilPeriod: user?.averageCycleLength ?? 28,
|
||||
isPeriodExpected: false,
|
||||
);
|
||||
}
|
||||
|
||||
final lastPeriod = user!.lastPeriodStartDate!;
|
||||
@@ -38,12 +69,12 @@ class CycleService {
|
||||
phase = CyclePhase.luteal;
|
||||
}
|
||||
|
||||
return {
|
||||
'phase': phase,
|
||||
'dayOfCycle': dayOfCycle,
|
||||
'daysUntilPeriod': daysUntilPeriod,
|
||||
'isPeriodExpected': daysUntilPeriod <= 0 || dayOfCycle <= 5,
|
||||
};
|
||||
return CycleInfo(
|
||||
phase: phase,
|
||||
dayOfCycle: dayOfCycle,
|
||||
daysUntilPeriod: daysUntilPeriod,
|
||||
isPeriodExpected: daysUntilPeriod <= 0 || dayOfCycle <= 5,
|
||||
);
|
||||
}
|
||||
|
||||
/// Format cycle day for display
|
||||
|
||||
99
lib/services/mock_data_service.dart
Normal file
99
lib/services/mock_data_service.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:math';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/cycle_entry.dart';
|
||||
import '../models/user_profile.dart';
|
||||
|
||||
class MockDataService {
|
||||
final Random _random = Random();
|
||||
final Uuid _uuid = const Uuid();
|
||||
|
||||
UserProfile generateMockWifeProfile() {
|
||||
return UserProfile(
|
||||
id: _uuid.v4(),
|
||||
name: 'Sarah',
|
||||
relationshipStatus: RelationshipStatus.married,
|
||||
averageCycleLength: 29,
|
||||
averagePeriodLength: 5,
|
||||
lastPeriodStartDate: DateTime.now().subtract(const Duration(days: 10)),
|
||||
favoriteFoods: ['Chocolate', 'Ice Cream', 'Berries'],
|
||||
isDataShared: true,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
List<CycleEntry> generateMockCycleEntries({
|
||||
int days = 90,
|
||||
int cycleLength = 28,
|
||||
int periodLength = 5,
|
||||
}) {
|
||||
final List<CycleEntry> entries = [];
|
||||
final DateTime today = DateTime.now();
|
||||
|
||||
for (int i = 0; i < days; i++) {
|
||||
final DateTime date = today.subtract(Duration(days: i));
|
||||
final int dayOfCycle = (cycleLength - (i % cycleLength)) % cycleLength;
|
||||
|
||||
bool isPeriodDay = dayOfCycle < periodLength;
|
||||
FlowIntensity? flow;
|
||||
if (isPeriodDay) {
|
||||
if (dayOfCycle < 2) {
|
||||
flow = FlowIntensity.heavy;
|
||||
} else if (dayOfCycle < 4) {
|
||||
flow = FlowIntensity.medium;
|
||||
} else {
|
||||
flow = FlowIntensity.light;
|
||||
}
|
||||
}
|
||||
|
||||
final entry = CycleEntry(
|
||||
id: _uuid.v4(),
|
||||
date: date,
|
||||
isPeriodDay: isPeriodDay,
|
||||
flowIntensity: flow,
|
||||
mood: MoodLevel.values[_random.nextInt(MoodLevel.values.length)],
|
||||
energyLevel: _random.nextInt(5) + 1,
|
||||
crampIntensity: isPeriodDay && _random.nextBool() ? _random.nextInt(4) + 1 : 0,
|
||||
hasHeadache: !isPeriodDay && _random.nextDouble() < 0.2,
|
||||
hasBloating: !isPeriodDay && _random.nextDouble() < 0.3,
|
||||
hasBreastTenderness: dayOfCycle > 20 && _random.nextDouble() < 0.4,
|
||||
hasFatigue: _random.nextDouble() < 0.3,
|
||||
hasAcne: dayOfCycle > 18 && _random.nextDouble() < 0.25,
|
||||
hasLowerBackPain: isPeriodDay && _random.nextDouble() < 0.4,
|
||||
stressLevel: _random.nextInt(5) + 1,
|
||||
notes: _getNoteForDay(dayOfCycle, cycleLength),
|
||||
husbandNotes: _getHusbandNoteForDay(dayOfCycle),
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
);
|
||||
entries.add(entry);
|
||||
}
|
||||
return entries.reversed.toList();
|
||||
}
|
||||
|
||||
String? _getNoteForDay(int dayOfCycle, int cycleLength) {
|
||||
if (_random.nextDouble() < 0.3) { // 30% chance of having a note
|
||||
if (dayOfCycle < 5) {
|
||||
return "Feeling a bit tired and crampy today. Taking it easy.";
|
||||
} else if (dayOfCycle > 10 && dayOfCycle < 16) {
|
||||
return "Feeling energetic and positive! Productive day at work.";
|
||||
} else if (dayOfCycle > cycleLength - 7) {
|
||||
return "A bit irritable today, craving some chocolate.";
|
||||
} else {
|
||||
return "Just a regular day. Nothing much to report.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _getHusbandNoteForDay(int dayOfCycle) {
|
||||
if (_random.nextDouble() < 0.2) { // 20% chance of husband note
|
||||
if (dayOfCycle < 5) {
|
||||
return "She seems to be in a bit of pain. I'll make her some tea.";
|
||||
} else if (dayOfCycle > 22) {
|
||||
return "She mentioned feeling a little down. Extra hugs tonight.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user