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

231
tool/optimize_assets.dart Normal file
View File

@@ -0,0 +1,231 @@
import 'dart:convert';
import 'dart:io';
import 'package:xml/xml.dart';
// Copy of book abbreviations from BibleXmlParser
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',
};
// Map of translations to filenames
final Map<String, String> _translationFiles = {
'esv': 'assets/bible_xml/ESV.xml',
'niv': 'assets/bible_xml/NIV.xml',
'nkjv': 'assets/bible_xml/NKJV.xml',
'nlt': 'assets/bible_xml/NLT.xml',
'nasb': 'assets/bible_xml/NASB.xml',
'kjv': 'assets/bible_xml/KJV.xml',
'msg': 'assets/bible_xml/MSG.xml',
};
void main() async {
print('Starting asset optimization...');
// 1. Load the base JSON
final File jsonFile = File('assets/scriptures.json');
if (!jsonFile.existsSync()) {
print('Error: assets/scriptures.json not found.');
return;
}
final Map<String, dynamic> data = json.decode(jsonFile.readAsStringSync());
// 2. Load and Parse all XMLs
final Map<String, XmlDocument> xmlDocs = {};
for (var entry in _translationFiles.entries) {
final key = entry.key;
final path = entry.value;
final file = File(path);
if (file.existsSync()) {
print('Parsing $key from $path...');
try {
xmlDocs[key] = XmlDocument.parse(file.readAsStringSync());
} catch (e) {
print('Error parsing $path: $e');
}
} else {
print('Warning: $path not found.');
}
}
// 3. Process the JSON structure
for (var phase in ['menstrual', 'follicular', 'ovulation', 'luteal']) {
if (data[phase] != null) {
await _processList(data[phase] as List, xmlDocs);
}
}
if (data['husband'] != null) {
await _processList(data['husband'] as List, xmlDocs);
}
if (data['womanhood'] != null) {
await _processList(data['womanhood'] as List, xmlDocs);
}
if (data['contextual'] != null) {
final contextualMap = data['contextual'] as Map<String, dynamic>;
for (var key in contextualMap.keys) {
await _processList(contextualMap[key] as List, xmlDocs);
}
}
// 4. Write the optimized JSON
final outputFile = File('assets/scriptures_optimized.json');
outputFile.writeAsStringSync(json.encode(data)); // Minified
// outputFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data)); // Pretty print
print('Optimization complete. Wrote to assets/scriptures_optimized.json');
}
Future<void> _processList(List list, Map<String, XmlDocument> xmlDocs) async {
for (var item in list) {
final String reference = item['reference'];
Map<String, dynamic> verses = item['verses'] ?? {};
// Parse reference
final parts = _parseReference(reference);
if (parts == null) {
print('Skipping invalid reference: $reference');
continue;
}
// Look up for each translation
for (var entry in _translationFiles.entries) {
final transKey = entry.key; // esv, niv, etc.
// If already has text, skip (or overwrite? Let's overwrite to ensure consistency,
// but the original JSON had manual entries. The user wants to use the XMLs.
// Let's only fill if missing or if we want to enforce XML source.)
// Strategy: Fill if we have XML data.
if (xmlDocs.containsKey(transKey)) {
final text = _getVerseFromXml(
xmlDocs[transKey]!,
parts['book']!,
int.parse(parts['chapter']!),
int.parse(parts['verse']!)
);
if (text != null) {
verses[transKey] = text;
} else {
print('Warning: Could not find $reference in $transKey');
}
}
}
item['verses'] = verses;
}
}
Map<String, String>? _parseReference(String reference) {
final parts = reference.split(' ');
if (parts.length < 2) return null;
String book = parts.sublist(0, parts.length - 1).join(' ').toLowerCase();
String chapterVerse = parts.last;
final cvParts = chapterVerse.split(':');
if (cvParts.length < 2) return null; // Only handles simple Chapter:Verse for now
return {
'book': book,
'chapter': cvParts[0],
// Handle cases like "1-2" by just taking the first one?
// The current parser Logic only takes one verse number.
// If the reference is "Psalm 23:1-2", the XML parser expects a single integer.
// We should probably just parse the first verse or handle ranges?
// For this fix, let's just parse the start verse to prevent crashing on int.parse
'verse': cvParts[1].split('-')[0],
};
}
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') {
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') {
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') {
return null;
}
// Extract the text content of the verse
return verseElement.innerText.trim();
}