Compare commits

...

10 Commits

Author SHA1 Message Date
ede7064bda sync additons 2026-01-10 12:50:52 -06:00
0a72259a9a feat: Add sharePadSupplies setting and update SharingSettingsScreen
- Add sharePadSupplies field to UserProfile model (HiveField 58)
- Update SharingSettingsScreen: Pad Supplies toggle disabled when pad tracking off
- Update SharingSettingsScreen: Intimacy sharing always enabled (it's their husband)
- Regenerate Hive adapters
2026-01-09 21:32:40 -06:00
1c2c56e9e2 feat: Add auto-sync, fix partner linking UI, update sharing settings
- Add 10-second periodic auto-sync to CycleEntriesNotifier
- Fix husband_devotional_screen: use partnerId for isConnected check, navigate to SharingSettingsScreen instead of legacy mock dialog
- Remove obsolete _showConnectDialog method and mock data import
- Update husband_settings_screen: show 'Partner Settings' with linked partner name when connected
- Add SharingSettingsScreen: Pad Supplies toggle (disabled when pad tracking off), Intimacy always enabled
- Add CORS OPTIONS handler to backend server
- Add _ensureServerRegistration for reliable partner linking
- Add copy button to Invite Partner dialog
- Dynamic base URL for web (uses window.location.hostname)
2026-01-09 17:20:49 -06:00
d28898cb81 Implement data sync and cleanup 2026-01-09 13:48:38 -06:00
dc6bcad83f Enhance Pad Tracking with new Flow and Supply logic 2026-01-09 13:35:07 -06:00
24ffac2415 Fix Hive typeId conflict between Scripture and TeachingPlan 2026-01-09 10:09:07 -06:00
a799e9cf59 Resolve all lints and deprecation warnings 2026-01-09 10:04:51 -06:00
512577b092 Implement Notifications and Pad Tracking Enhancements 2026-01-08 15:46:28 -06:00
9ae77e7ab0 Refactor Husband Settings to dedicated screen and add Appearance option 2026-01-05 18:00:44 -06:00
d50cab3188 Implement dynamic scripture loading from XML and fix theming for Learn screens 2026-01-05 17:40:48 -06:00
77 changed files with 8775 additions and 4752 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"avdmanager.sdkPath": "/home/sterl/Downloads/Tracker-main/tracker/lib/providers"
}

View File

@@ -7,10 +7,11 @@ plugins {
android {
namespace = "com.faithapps.christian_period_tracker"
compileSdk = flutter.compileSdkVersion
compileSdk = 36
ndkVersion = flutter.ndkVersion
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
@@ -24,7 +25,7 @@ android {
applicationId = "com.faithapps.christian_period_tracker"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
@@ -39,6 +40,17 @@ android {
}
}
configurations.all {
resolutionStrategy {
force 'androidx.core:core:1.13.1'
force 'androidx.core:core-ktx:1.13.1'
}
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
}
flutter {
source = "../.."
}

View File

@@ -1,14 +1,5 @@
<<<<<<< HEAD
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
=======
#Fri Dec 19 21:26:00 CST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
>>>>>>> 6742220 (Your commit message here)

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.1.0" apply false
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}

198
backend/bin/server.dart Normal file
View File

@@ -0,0 +1,198 @@
import 'dart:io';
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';
import 'package:tracker_backend/database.dart';
void main(List<String> args) async {
// Use port 8090 as requested
final port = 8090;
final db = TrackerDatabase();
final app = Router();
app.get('/', (Request request) {
return Response.ok('Tracker Sync Server Running');
});
// Handle CORS Preflight (OPTIONS) for all routes
app.add('OPTIONS', r'/<ignored|.*>', (Request request) {
return Response.ok('', headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type',
});
});
// Sync Push Endpoint
// Expects JSON: { "userId": "...", "entries": [], "teachingPlans": [], "prayerRequests": [] }
app.post('/sync/push', (Request request) async {
try {
final payload = await request.readAsString();
final data = jsonDecode(payload);
final userId = data['userId'];
// 0. Update User Record
// We expect 'userDetails' in payload now, but handle legacy
if (data.containsKey('userDetails')) {
db.upsertUser(userId, data['userDetails']);
} else {
// Fallback: create basic record if missing?
// Or just let it fail if we need linking.
// We'll trust frontend sends it now.
}
// 1. Cycle Entries
final entries = data['entries'] as List? ?? [];
for (var entry in entries) {
db.upsertCycleEntry(userId, entry);
}
// 2. Teaching Plans
final teachingPlans = data['teachingPlans'] as List? ?? [];
for (var plan in teachingPlans) {
db.upsertTeachingPlan(userId, plan);
}
// 3. Prayer Requests
final prayerRequests = data['prayerRequests'] as List? ?? [];
for (var req in prayerRequests) {
db.upsertPrayerRequest(userId, req);
}
print(
'Synced for $userId: ${entries.length} entries, ${teachingPlans.length} plans, ${prayerRequests.length} prayers');
return Response.ok(jsonEncode({'status': 'success'}));
} catch (e) {
print('Sync Error: $e');
return Response.internalServerError(body: 'Sync Failed: $e');
}
});
// Preview Link Endpoint
// POST /sync/preview
// Body: { "targetId": "..." }
// Returns: { "name": "...", "role": "..." }
app.post('/sync/preview', (Request request) async {
try {
final payload = await request.readAsString();
final data = jsonDecode(payload);
final targetId = data['targetId'];
if (targetId == null) {
return Response.badRequest(body: 'Missing targetId');
}
final targetUser = db.getUser(targetId);
if (targetUser == null) {
return Response.notFound(jsonEncode({'error': 'Partner ID not found'}));
}
return Response.ok(jsonEncode({
'status': 'success',
'partnerName': targetUser['name'],
'partnerRole': targetUser['role'],
}));
} catch (e) {
print('Preview Error: $e');
return Response.internalServerError(body: 'Preview Failed: $e');
}
});
// Link Endpoint
// POST /sync/link
// Body: { "userId": "...", "targetId": "..." }
app.post('/sync/link', (Request request) async {
try {
final payload = await request.readAsString();
final data = jsonDecode(payload);
final userId = data['userId'];
final targetId = data['targetId'];
if (userId == null || targetId == null) {
return Response.badRequest(body: 'Missing userId or targetId');
}
// Verify target exists
final targetUser = db.getUser(targetId);
if (targetUser == null) {
return Response.notFound(jsonEncode({'error': 'Partner ID not found'}));
}
// Perform Link
db.linkPartners(userId, targetId);
// Return partner name/info
return Response.ok(jsonEncode({
'status': 'success',
'partnerName': targetUser['name'],
'partnerEmail': targetUser['email']
}));
} catch (e) {
print('Link Error: $e');
return Response.internalServerError(body: 'Link Failed: $e');
}
});
// Pull Endpoint
// GET /sync/pull?userId=...&partnerId=...
app.get('/sync/pull', (Request request) {
final userId = request.url.queryParameters['userId'];
final partnerId = request.url.queryParameters['partnerId'];
if (userId == null) return Response.badRequest(body: 'Missing userId');
// 1. Get My Data
final myEntries = db.getCycleEntries(userId);
final myPlans = db.getTeachingPlans(userId); // Plans I created
final myPrayers = db.getPrayerRequests(userId); // Prayers I created
// 2. Get Partner Data (if linked)
List<Map<String, dynamic>> partnerEntries = [];
List<Map<String, dynamic>> partnerPlans = [];
List<Map<String, dynamic>> partnerPrayers = [];
if (partnerId != null && partnerId.isNotEmpty) {
// Fetch partner's cycle entries
partnerEntries = db.getCycleEntries(partnerId);
// Fetch plans created by partner (e.g. Husband created plans for Wife to see)
partnerPlans = db.getTeachingPlans(partnerId);
// Fetch partner's prayer requests
partnerPrayers = db.getPrayerRequests(partnerId);
}
// 3. Get User Profile (to sync back partner changes)
final userProfile = db.getUser(userId);
// Combine Data
final responseData = {
'entries': [...myEntries, ...partnerEntries],
// Teaching Plans: I want to see my own AND my partner's
'teachingPlans': [...myPlans, ...partnerPlans],
// Prayer Requests: I want to see my own AND my partner's
'prayerRequests': [...myPrayers, ...partnerPrayers],
if (userProfile != null) 'userProfile': userProfile,
};
return Response.ok(jsonEncode(responseData));
});
// Enable CORS
final handler = Pipeline().addMiddleware((innerHandler) {
return (request) async {
final response = await innerHandler(request);
return response.change(headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Origin, Content-Type',
});
};
}).addHandler(app);
final server = await io.serve(handler, InternetAddress.anyIPv4, port);
print('Server running on localhost:${server.port}');
}

BIN
backend/data/tracker.db Normal file

Binary file not shown.

282
backend/lib/database.dart Normal file
View File

@@ -0,0 +1,282 @@
import 'dart:io';
import 'package:sqlite3/sqlite3.dart' as sql;
import 'package:path/path.dart' as p;
class TrackerDatabase {
late final sql.Database _db;
TrackerDatabase() {
_init();
}
void _init() {
// Ensure data directory exists
final dataDir = Directory('data');
if (!dataDir.existsSync()) {
dataDir.createSync();
}
final dbPath = p.join('data', 'tracker.db');
print('Opening database at $dbPath');
_db = sql.sqlite3.open(dbPath);
_createTables();
}
void _createTables() {
print('Creating tables if not exist...');
// Users table
_db.execute('''
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
role TEXT NOT NULL,
name TEXT,
email TEXT,
partner_id TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''');
// Cycle Entries table
_db.execute('''
CREATE TABLE IF NOT EXISTS cycle_entries (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
date TEXT NOT NULL,
flow_intensity TEXT,
is_period_day INTEGER DEFAULT 0,
symptoms TEXT, -- JSON string
moods TEXT, -- JSON string
notes TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, date)
)
''');
// Teaching Plans table
_db.execute('''
CREATE TABLE IF NOT EXISTS teaching_plans (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
date TEXT NOT NULL,
topic TEXT,
scripture_reference TEXT,
notes TEXT,
is_completed INTEGER DEFAULT 0,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''');
// Prayer Requests table
_db.execute('''
CREATE TABLE IF NOT EXISTS prayer_requests (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
request TEXT,
is_answered INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
''');
print('Tables created.');
}
// Basic CRUD placeholders for sync
void upsertCycleEntry(String userId, Map<String, dynamic> entry) {
// Assuming entry contains fields matching DB
final stmt = _db.prepare('''
INSERT OR REPLACE INTO cycle_entries (
id, user_id, date, flow_intensity, is_period_day, symptoms, moods, notes, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''');
stmt.execute([
entry['id'],
userId,
entry['date'],
entry['flowIntensity'],
entry['isPeriodDay'] == true ? 1 : 0,
entry['symptoms'], // JSON
entry['moods'], // JSON
entry['notes'],
]);
stmt.dispose();
}
List<Map<String, dynamic>> getCycleEntries(String userId, {String? since}) {
// If since is provided, filter by updated_at
// For MVP sync, we might just return all or a simple diff
final result =
_db.select('SELECT * FROM cycle_entries WHERE user_id = ?', [userId]);
return result
.map((row) => {
'id': row['id'],
'date': row['date'],
'flowIntensity': row['flow_intensity'],
'isPeriodDay': row['is_period_day'] == 1,
'symptoms': row['symptoms'],
'moods': row['moods'],
'notes': row['notes'],
'updatedAt': row['updated_at']
})
.toList();
}
// Teaching Plan operations
void upsertTeachingPlan(String userId, Map<String, dynamic> plan) {
final stmt = _db.prepare('''
INSERT OR REPLACE INTO teaching_plans (
id, user_id, date, topic, scripture_reference, notes, is_completed, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''');
stmt.execute([
plan['id'],
userId,
plan['date'],
plan['topic'],
plan['scriptureReference'],
plan['notes'],
plan['isCompleted'] == true ? 1 : 0,
]);
stmt.dispose();
}
List<Map<String, dynamic>> getTeachingPlans(String userId) {
final result =
_db.select('SELECT * FROM teaching_plans WHERE user_id = ?', [userId]);
return result
.map((row) => {
'id': row['id'],
'date': row['date'],
'topic': row['topic'],
'scriptureReference': row['scripture_reference'],
'notes': row['notes'],
'isCompleted': row['is_completed'] == 1,
'updatedAt': row['updated_at']
})
.toList();
}
// Prayer Request operations
void upsertPrayerRequest(String userId, Map<String, dynamic> request) {
final stmt = _db.prepare('''
INSERT OR REPLACE INTO prayer_requests (
id, user_id, request, is_answered, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''');
stmt.execute([
request['id'],
userId,
request['request'],
request['isAnswered'] == true ? 1 : 0,
request['createdAt'],
]);
stmt.dispose();
}
List<Map<String, dynamic>> getPrayerRequests(String userId) {
final result =
_db.select('SELECT * FROM prayer_requests WHERE user_id = ?', [userId]);
return result
.map((row) => {
'id': row['id'],
'request': row['request'],
'isAnswered': row['is_answered'] == 1,
'createdAt': row['created_at'],
'updatedAt': row['updated_at']
})
.toList();
}
// User operations
void upsertUser(String userId, Map<String, dynamic> userData) {
// Check if user exists first
final existing = _db.select('SELECT * FROM users WHERE id = ?', [userId]);
if (existing.isEmpty) {
// Insert new
final stmt = _db.prepare('''
INSERT INTO users (
id, role, name, email, partner_id, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''');
stmt.execute([
userId,
userData['role'] ?? 'wife',
userData['name'],
userData['email'],
userData['partnerId'],
userData['createdAt'] ?? DateTime.now().toIso8601String(),
]);
stmt.dispose();
} else {
// Update existing
// CRITICAL: Do NOT overwrite partner_id with NULL if it is already set in DB
// unless the client specifically might intend it (which is hard to know).
// For Safe Onboarding: If DB has a partner_id, and incoming is NULL, keep DB value.
final row = existing.first;
final dbPartnerId = row['partner_id'];
final incomingPartnerId = userData['partnerId'];
String? finalPartnerId = incomingPartnerId;
if (incomingPartnerId == null && dbPartnerId != null) {
finalPartnerId = dbPartnerId as String?;
}
final stmt = _db.prepare('''
UPDATE users SET
role = ?,
name = ?,
email = ?,
partner_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''');
stmt.execute([
userData['role'] ?? row['role'],
userData['name'] ?? row['name'],
userData['email'] ?? row['email'],
finalPartnerId,
userId,
]);
stmt.dispose();
}
}
bool linkPartners(String userId, String targetPartnerId) {
// 1. Verify target exists (optional, or just blindly update)
// For MVP, just update both.
// Update User -> Partner
_db.execute('UPDATE users SET partner_id = ? WHERE id = ?',
[targetPartnerId, userId]);
// Update Partner -> User
_db.execute('UPDATE users SET partner_id = ? WHERE id = ?',
[userId, targetPartnerId]);
// Check if both have partner_id set now?
// Just return true.
return true;
}
Map<String, dynamic>? getUser(String userId) {
final result = _db.select('SELECT * FROM users WHERE id = ?', [userId]);
if (result.isEmpty) return null;
final row = result.first;
return {
'id': row['id'],
'role': row['role'],
'name': row['name'],
'email': row['email'],
'partnerId': row['partner_id'],
};
}
}

173
backend/pubspec.lock Normal file
View File

@@ -0,0 +1,173 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
ffi:
dependency: transitive
description:
name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev"
source: hosted
version: "2.1.5"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_methods:
dependency: transitive
description:
name: http_methods
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
lints:
dependency: "direct dev"
description:
name: lints
sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
shelf:
dependency: "direct main"
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_router:
dependency: "direct main"
description:
name: shelf_router
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
url: "https://pub.dev"
source: hosted
version: "1.1.4"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sqlite3:
dependency: "direct main"
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.dev"
source: hosted
version: "4.5.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.7.0 <4.0.0"

18
backend/pubspec.yaml Normal file
View File

@@ -0,0 +1,18 @@
name: tracker_backend
description: A simple backend for Tracker sync.
version: 1.0.0
publish_to: "none"
environment:
sdk: ^3.5.0
dependencies:
shelf: ^1.4.1
shelf_router: ^1.1.4
sqlite3: ^2.4.0
path: ^1.8.3
uuid: ^4.0.0
crypto: ^3.0.3
dev_dependencies:
lints: ^2.1.1

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

161
lib/app_startup.dart Normal file
View File

@@ -0,0 +1,161 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'main.dart'; // Import ChristianPeriodTrackerApp
import 'models/cycle_entry.dart';
import 'models/teaching_plan.dart';
import 'models/prayer_request.dart';
import 'models/scripture.dart';
import 'models/user_profile.dart';
import 'screens/splash_screen.dart';
import 'services/notification_service.dart';
class AppStartupWidget extends StatefulWidget {
const AppStartupWidget({super.key});
@override
State<AppStartupWidget> createState() => _AppStartupWidgetState();
}
class _AppStartupWidgetState extends State<AppStartupWidget> {
bool _isInitialized = false;
String _status = 'Initializing...';
bool _showSkipButton = false;
final Duration _minSplashDuration = const Duration(milliseconds: 2000);
@override
void initState() {
super.initState();
_initializeApp();
// Show skip button if loading takes too long (e.g., 5 seconds)
Future.delayed(const Duration(seconds: 5), () {
if (mounted && !_isInitialized) {
setState(() => _showSkipButton = true);
}
});
}
Future<void> _initializeApp() async {
final stopwatch = Stopwatch()..start();
try {
setState(() => _status = 'Loading user profile...');
// Add timeout to prevent indefinite hanging
await Hive.openBox<UserProfile>('user_profile_v2')
.timeout(const Duration(seconds: 5));
setState(() => _status = 'Loading cycle data...');
await Hive.openBox<CycleEntry>('cycle_entries_v2')
.timeout(const Duration(seconds: 5));
setState(() => _status = 'Loading shared data...');
await Hive.openBox<TeachingPlan>('teaching_plans_v2')
.timeout(const Duration(seconds: 5));
await Hive.openBox<PrayerRequest>('prayer_requests_v2')
.timeout(const Duration(seconds: 5));
setState(() => _status = 'Loading scriptures...');
// Wrap scripture loading in a try-catch specifically to allow app to start even if assets fail
try {
await ScriptureDatabase()
.loadScriptures()
.timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('Scripture loading failed (non-critical): $e');
setState(() => _status = 'Scripture loading skipped...');
}
setState(() => _status = 'Setting up notifications...');
try {
await NotificationService()
.initialize()
.timeout(const Duration(seconds: 3));
} catch (e) {
debugPrint('Notification init failed: $e');
}
} catch (e, stack) {
debugPrint('Initialization error: $e');
debugPrint(stack.toString());
setState(() => _status = 'Error: $e');
// On critical error, allow user to proceed manually via skip button
setState(() => _showSkipButton = true);
return;
}
final elapsed = stopwatch.elapsed;
if (elapsed < _minSplashDuration) {
await Future.delayed(_minSplashDuration - elapsed);
}
if (mounted) {
setState(() {
_isInitialized = true;
});
}
}
void _skipInitialization() {
setState(() => _isInitialized = true);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return MaterialApp(
home: Scaffold(
body: Stack(
children: [
const SplashScreen(isStartup: true),
// Status Message (High Visibility)
Positioned(
bottom: 80,
left: 20,
right: 20,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_status,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.black87,
fontSize: 14,
fontWeight: FontWeight.bold,
decoration: TextDecoration.none,
fontFamily: 'SansSerif'),
),
),
),
// Skip Button
if (_showSkipButton)
Positioned(
bottom: 30,
left: 0,
right: 0,
child: Center(
child: ElevatedButton(
onPressed: _skipInitialization,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
foregroundColor: Colors.white,
),
child: const Text('Skip Loading (Force Start)'),
),
),
),
],
),
),
debugShowCheckedModeBanner: false,
);
}
return const ChristianPeriodTrackerApp();
}
}

View File

@@ -1,3 +1,5 @@
library learn_content;
/// Learn article content for the Husband section
/// Contains educational articles about understanding her cycle, biblical manhood, and NFP
@@ -28,7 +30,7 @@ class LearnSection {
class LearnContent {
static const List<LearnArticle> articles = [
// ========== UNDERSTANDING HER ==========
LearnArticle(
id: 'four_phases',
title: 'The 4 Phases of Her Cycle',
@@ -36,13 +38,15 @@ class LearnContent {
category: 'Understanding Her',
sections: [
LearnSection(
content: 'Your wife\'s body goes through four distinct phases each month, '
content:
'Your wife\'s body goes through four distinct phases each month, '
'each bringing different physical and emotional experiences. Understanding '
'these phases helps you be a more attentive and supportive husband.',
),
LearnSection(
heading: '1. Menstrual Phase (Days 1-5)',
content: 'This is when her period occurs. The uterine lining sheds, which can '
content:
'This is when her period occurs. The uterine lining sheds, which can '
'cause cramping, fatigue, and lower energy. Many women experience headaches, '
'bloating, and mood sensitivity during this time.\n\n'
'💡 How you can help: Offer comfort items, help with household tasks, '
@@ -50,7 +54,8 @@ class LearnContent {
),
LearnSection(
heading: 'Dysmenorrhea (Painful Periods)',
content: 'While some cramping is normal, severe menstrual pain is called '
content:
'While some cramping is normal, severe menstrual pain is called '
'Dysmenorrhea. It affects many women and can be truly debilitating.\n\n'
'**Primary Dysmenorrhea**: Common menstrual cramps caused by '
'prostaglandins (chemicals that make the uterus contract). Pain '
@@ -64,16 +69,17 @@ class LearnContent {
),
LearnSection(
heading: '2. Follicular Phase (Days 6-12)',
content: 'After her period ends, estrogen begins rising. This typically brings '
content:
'After her period ends, estrogen begins rising. This typically brings '
'increased energy, better mood, and higher confidence. She may feel more '
'social and creative during this time.\n\n'
'💡 How you can help: This is a great time for date nights, important '
'conversations, and planning activities together.',
),
LearnSection(
heading: '3. Ovulation Phase (Days 13-15)',
content: 'This is peak fertility. Estrogen peaks and her body releases an egg. '
content:
'This is peak fertility. Estrogen peaks and her body releases an egg. '
'Many women feel their best during this phase—higher energy, increased '
'confidence, and feeling more attractive.\n\n'
'💡 How you can help: Appreciate and affirm her. This is when she may feel '
@@ -81,7 +87,8 @@ class LearnContent {
),
LearnSection(
heading: '4. Luteal Phase (Days 16-28)',
content: 'Progesterone rises after ovulation. If pregnancy doesn\'t occur, '
content:
'Progesterone rises after ovulation. If pregnancy doesn\'t occur, '
'hormone levels drop, leading to PMS symptoms like mood swings, irritability, '
'fatigue, cravings, and bloating.\n\n'
'💡 How you can help: Extra patience and understanding. Her feelings are real, '
@@ -89,7 +96,8 @@ class LearnContent {
),
LearnSection(
heading: 'Remember',
content: '"Husbands, love your wives, just as Christ loved the church and gave '
content:
'"Husbands, love your wives, just as Christ loved the church and gave '
'himself up for her." — Ephesians 5:25\n\n'
'Understanding her cycle is one practical way to live out sacrificial love.',
),
@@ -103,13 +111,15 @@ class LearnContent {
category: 'Understanding Her',
sections: [
LearnSection(
content: 'If you\'ve ever wondered why your wife seems like a different person '
content:
'If you\'ve ever wondered why your wife seems like a different person '
'at different times of the month, the answer lies in hormones. These powerful '
'chemical messengers directly affect her brain, emotions, and body.',
),
LearnSection(
heading: 'The Key Hormones',
content: '**Estrogen** — The "feel good" hormone. When it\'s high, she typically '
content:
'**Estrogen** — The "feel good" hormone. When it\'s high, she typically '
'feels more energetic, positive, and socially engaged. When it drops suddenly '
'(before her period), it can cause mood dips.\n\n'
'**Progesterone** — The "calming" hormone. It rises after ovulation and can cause '
@@ -120,7 +130,8 @@ class LearnContent {
),
LearnSection(
heading: 'It\'s Not "In Her Head"',
content: 'Studies show that hormonal fluctuations create real changes in brain chemistry. '
content:
'Studies show that hormonal fluctuations create real changes in brain chemistry. '
'The limbic system (emotional center) is highly sensitive to hormone levels. When '
'her hormones shift, her emotional responses genuinely change—this isn\'t weakness '
'or drama, it\'s biology.',
@@ -135,7 +146,8 @@ class LearnContent {
),
LearnSection(
heading: 'Scripture to Remember',
content: '"Live with your wives in an understanding way, showing honor to the woman '
content:
'"Live with your wives in an understanding way, showing honor to the woman '
'as the weaker vessel, since they are heirs with you of the grace of life, so '
'that your prayers may not be hindered." — 1 Peter 3:7',
),
@@ -149,7 +161,8 @@ class LearnContent {
category: 'Understanding Her',
sections: [
LearnSection(
content: 'Premenstrual Syndrome (PMS) affects up to 90% of women to some degree. '
content:
'Premenstrual Syndrome (PMS) affects up to 90% of women to some degree. '
'For 20-40% of women, symptoms significantly impact daily life. This is not '
'exaggeration or seeking attention—it\'s a medically recognized condition.',
),
@@ -176,7 +189,8 @@ class LearnContent {
),
LearnSection(
heading: 'PMDD: When It\'s Severe',
content: 'About 3-8% of women experience Premenstrual Dysphoric Disorder (PMDD), '
content:
'About 3-8% of women experience Premenstrual Dysphoric Disorder (PMDD), '
'a severe form of PMS. Symptoms can include depression, hopelessness, severe '
'anxiety, and difficulty functioning. If your wife experiences severe symptoms, '
'encourage her to talk to a doctor—treatment is available.',
@@ -193,7 +207,8 @@ class LearnContent {
),
LearnSection(
heading: 'A Husband\'s Prayer',
content: '"Lord, help me to be patient and understanding when my wife is struggling. '
content:
'"Lord, help me to be patient and understanding when my wife is struggling. '
'Give me eyes to see her needs and a heart willing to serve. Help me reflect '
'Your love to her, especially when it\'s hard. Amen."',
),
@@ -209,14 +224,16 @@ class LearnContent {
category: 'Biblical Manhood',
sections: [
LearnSection(
content: '"Husbands, love your wives, as Christ loved the church and gave himself '
content:
'"Husbands, love your wives, as Christ loved the church and gave himself '
'up for her." — Ephesians 5:25\n\n'
'This is the highest calling for a husband. But what does it actually look like '
'in everyday life, especially related to her cycle?',
),
LearnSection(
heading: 'Christ\'s Love is Sacrificial',
content: 'Jesus gave up His comfort, His preferences, and ultimately His life. '
content:
'Jesus gave up His comfort, His preferences, and ultimately His life. '
'For you, this means:\n\n'
'• Helping with housework when she\'s exhausted from cramps\n'
'• Keeping your frustration in check when she\'s emotional\n'
@@ -225,7 +242,8 @@ class LearnContent {
),
LearnSection(
heading: 'Christ\'s Love is Patient',
content: 'Jesus was patient with His disciples\' failures and slow understanding. '
content:
'Jesus was patient with His disciples\' failures and slow understanding. '
'For you, this means:\n\n'
'• Not rushing her to "get over" how she\'s feeling\n'
'• Listening without immediately trying to fix things\n'
@@ -234,7 +252,8 @@ class LearnContent {
),
LearnSection(
heading: 'Christ\'s Love is Nourishing',
content: '"He nourishes and cherishes her" (Ephesians 5:29). For you, this means:\n\n'
content:
'"He nourishes and cherishes her" (Ephesians 5:29). For you, this means:\n\n'
'• Speaking words of encouragement and affirmation\n'
'• Providing for her physical comfort\n'
'• Protecting her from unnecessary stress\n'
@@ -242,7 +261,8 @@ class LearnContent {
),
LearnSection(
heading: 'Christ\'s Love is Understanding',
content: 'Jesus knows our weaknesses because He experienced human flesh. '
content:
'Jesus knows our weaknesses because He experienced human flesh. '
'For you, this means:\n\n'
'• Learning about her cycle and what she experiences\n'
'• Remembering what helps her and what doesn\'t\n'
@@ -251,7 +271,8 @@ class LearnContent {
),
LearnSection(
heading: 'Daily Application',
content: 'Each morning, ask yourself: "How can I love her like Christ today?"\n\n'
content:
'Each morning, ask yourself: "How can I love her like Christ today?"\n\n'
'Each evening, reflect: "Did I sacrifice my preferences for her good today?"',
),
],
@@ -264,13 +285,15 @@ class LearnContent {
category: 'Biblical Manhood',
sections: [
LearnSection(
content: '"For even the Son of Man came not to be served but to serve, and to give '
content:
'"For even the Son of Man came not to be served but to serve, and to give '
'his life as a ransom for many." — Mark 10:45\n\n'
'Biblical leadership isn\'t about authority and control—it\'s about service and sacrifice.',
),
LearnSection(
heading: 'Leadership = Responsibility, Not Power',
content: 'Being the head of your home means you are responsible for:\n\n'
content:
'Being the head of your home means you are responsible for:\n\n'
'• Your wife\'s spiritual well-being\n'
'• The emotional atmosphere of your home\n'
'• Initiating prayer and spiritual conversations\n'
@@ -279,7 +302,8 @@ class LearnContent {
),
LearnSection(
heading: 'A Servant Leader Listens',
content: 'Great leaders listen more than they speak. When your wife is struggling:\n\n'
content:
'Great leaders listen more than they speak. When your wife is struggling:\n\n'
'• Put down your phone and give full attention\n'
'• Ask questions to understand, not to fix\n'
'• Validate her feelings before offering solutions\n'
@@ -295,7 +319,8 @@ class LearnContent {
),
LearnSection(
heading: 'A Servant Leader Gets His Hands Dirty',
content: 'Jesus washed feet—the work of the lowest servant. For you, this means:\n\n'
content:
'Jesus washed feet—the work of the lowest servant. For you, this means:\n\n'
'• Doing dishes, laundry, and cleaning without being asked\n'
'• Getting up with sick children at night\n'
'• Taking on tasks she normally does when she\'s unwell\n'
@@ -319,7 +344,8 @@ class LearnContent {
category: 'Biblical Manhood',
sections: [
LearnSection(
content: 'Prayer is the most powerful thing you can do for your wife. It changes '
content:
'Prayer is the most powerful thing you can do for your wife. It changes '
'her, changes you, and invites God\'s presence into your marriage.',
),
LearnSection(
@@ -375,13 +401,15 @@ class LearnContent {
category: 'NFP for Husbands',
sections: [
LearnSection(
content: 'Natural Family Planning (NFP) isn\'t just your wife\'s responsibility—it\'s '
content:
'Natural Family Planning (NFP) isn\'t just your wife\'s responsibility—it\'s '
'a shared journey. Learning to understand her fertility signs brings you closer '
'together and honors the gift of her body.',
),
LearnSection(
heading: 'The Main Fertility Signs',
content: '**Cervical Mucus**: As ovulation approaches, mucus changes from dry to '
content:
'**Cervical Mucus**: As ovulation approaches, mucus changes from dry to '
'wet, slippery, and stretchy (like egg whites). This indicates peak fertility.\n\n'
'**Basal Body Temperature**: Temperature rises 0.5-1°F after ovulation and stays '
'elevated until the next period. A sustained rise confirms ovulation occurred.\n\n'
@@ -416,7 +444,8 @@ class LearnContent {
),
LearnSection(
heading: 'Scripture',
content: '"Two are better than one, because they have a good reward for their toil. '
content:
'"Two are better than one, because they have a good reward for their toil. '
'For if they fall, one will lift up his fellow." — Ecclesiastes 4:9-10',
),
],
@@ -429,13 +458,15 @@ class LearnContent {
category: 'NFP for Husbands',
sections: [
LearnSection(
content: 'For couples practicing NFP to avoid pregnancy, abstaining during the '
content:
'For couples practicing NFP to avoid pregnancy, abstaining during the '
'fertile window can be challenging. But this sacrifice can become a profound '
'spiritual discipline that strengthens your marriage.',
),
LearnSection(
heading: 'The Challenge is Real',
content: 'Let\'s be honest: abstinence during fertile days is difficult.\n\n'
content:
'Let\'s be honest: abstinence during fertile days is difficult.\n\n'
'• Her desire may peak during ovulation (biology is ironic that way)\n'
'• It requires self-control and communication\n'
'• It can feel like a sacrifice without immediate reward\n\n'
@@ -443,7 +474,8 @@ class LearnContent {
),
LearnSection(
heading: 'Reframing Abstinence',
content: 'Instead of seeing fertile days as deprivation, see them as:\n\n'
content:
'Instead of seeing fertile days as deprivation, see them as:\n\n'
'• **An opportunity for self-control**: "Like a city whose walls are broken through '
'is a person who lacks self-control." (Proverbs 25:28)\n\n'
'• **A chance to serve your wife**: Respecting her body and your shared decision\n\n'
@@ -452,7 +484,8 @@ class LearnContent {
),
LearnSection(
heading: 'Ways to Stay Connected',
content: 'During fertile days, intentionally build closeness through:\n\n'
content:
'During fertile days, intentionally build closeness through:\n\n'
'• Quality conversation (deeper than logistics)\n'
'• Physical affection without intercourse\n'
'• Date nights focused on connection\n'
@@ -471,7 +504,8 @@ class LearnContent {
),
LearnSection(
heading: 'A Prayer for Difficult Days',
content: '"Lord, this is hard. Help me to love her well even when I\'m frustrated. '
content:
'"Lord, this is hard. Help me to love her well even when I\'m frustrated. '
'Give me self-control and help me see this sacrifice as an offering to You. '
'Deepen our connection in other ways during this time. Amen."',
),
@@ -486,34 +520,39 @@ class LearnContent {
category: 'Understanding My Cycle',
sections: [
LearnSection(
content: 'Your menstrual cycle is more than just your period. It\'s a continuous '
content:
'Your menstrual cycle is more than just your period. It\'s a continuous '
'rhythm of hormones that affects your energy, mood, and body. Understanding '
'these four phases empowers you to work with your body, rather than against it.',
),
LearnSection(
heading: '1. Menstrual Phase (Days 1-5)',
content: 'The start of your cycle. Progesterone plunges, causing the uterine lining '
content:
'The start of your cycle. Progesterone plunges, causing the uterine lining '
'to shed. Energy typically dips.\n\n'
'💡 Tips: Prioritize rest. Warm foods and gentle movement (like walking or stretching) '
'can help with cramping. Don\'t push yourself to be highly productive if you don\'t feel up to it.',
),
LearnSection(
heading: '2. Follicular Phase (Days 6-12)',
content: 'Estrogen rises as your body prepares an egg. You likely feel a boost in energy, '
content:
'Estrogen rises as your body prepares an egg. You likely feel a boost in energy, '
'creativity, and social desire. Your skin may clear up and you feel more optimistic.\n\n'
'💡 Tips: This is a great time to start new projects, exercise harder, and schedule '
'social gatherings.',
),
LearnSection(
heading: '3. Ovulation Phase (Days 13-15)',
content: 'Peak fertility. Estrogen is high, and you may feel your most confident and vibrant. '
content:
'Peak fertility. Estrogen is high, and you may feel your most confident and vibrant. '
'Libido often increases. You are magnetic!\n\n'
'💡 Tips: Schedule date nights or important conversations. You are likely more '
'articulate and persuasive now.',
),
LearnSection(
heading: '4. Luteal Phase (Days 16-28)',
content: 'Progesterone acts as a "braking" hormone. Energy winds down. You may turn inward '
content:
'Progesterone acts as a "braking" hormone. Energy winds down. You may turn inward '
'and feel more reflective. PMS symptoms may appear near the end.\n\n'
'💡 Tips: Be gentle with yourself. Focus on completing tasks rather than starting new ones. '
'Organize your space and prepare for the rest phase coming next.',
@@ -528,23 +567,27 @@ class LearnContent {
category: 'Understanding My Cycle',
sections: [
LearnSection(
content: 'It is not "all in your head." Hormones like estrogen and progesterone '
content:
'It is not "all in your head." Hormones like estrogen and progesterone '
'directly impact brain chemistry and neurotransmitters like serotonin. '
'Fluctuating emotions are a biological reality.',
),
LearnSection(
heading: 'Estrogen: The Uplifter',
content: 'When estrogen is high (Follicular/Ovulation), it boosts serotonin and dopamine. '
content:
'When estrogen is high (Follicular/Ovulation), it boosts serotonin and dopamine. '
'You feel resilient, happy, and outgoing.',
),
LearnSection(
heading: 'Progesterone: The Sedative',
content: 'Rising in the Luteal phase, progesterone has a calming effect but can also '
content:
'Rising in the Luteal phase, progesterone has a calming effect but can also '
'lead to feelings of sadness or sluggishness if levels aren\'t balanced.',
),
LearnSection(
heading: 'The Drop',
content: 'Just before your period, both hormones drop. This withdrawal can cause '
content:
'Just before your period, both hormones drop. This withdrawal can cause '
'irritability, anxiety, and tearfulness (PMS). It is temporary!\n\n'
'Scripture: "I praise you, for I am fearfully and wonderfully made." — Psalm 139:14',
),
@@ -558,12 +601,14 @@ class LearnContent {
category: 'Disease Prevention',
sections: [
LearnSection(
content: 'Maintaining reproductive health involves regular hygiene and awareness '
content:
'Maintaining reproductive health involves regular hygiene and awareness '
'of your body\'s natural defenses.',
),
LearnSection(
heading: 'Hygiene Basics',
content: '• **Wiping**: Always wipe from front to back to prevent bacteria from '
content:
'• **Wiping**: Always wipe from front to back to prevent bacteria from '
'entering the urethra or vagina.\n'
'• **Cleaning**: Use warm water. Avoid harsh soaps, douches, or scented products '
'internally, as they disrupt the natural pH balance and can lead to yeast infections or BV.\n'
@@ -571,14 +616,16 @@ class LearnContent {
),
LearnSection(
heading: 'UTI Prevention',
content: 'Urinary Tract Infections (UTIs) are common but preventable:\n'
content:
'Urinary Tract Infections (UTIs) are common but preventable:\n'
'• Hydrate well to flush out bacteria.\n'
'• Urinate after intercourse to clear the urethra.\n'
'• Don\'t hold urine for long periods.',
),
LearnSection(
heading: 'STI Prevention',
content: 'Sexually Transmitted Infections can affect anyone. If you or your partner '
content:
'Sexually Transmitted Infections can affect anyone. If you or your partner '
'have a history of STIs, open communication and testing are crucial.\n\n'
'Monogamy within marriage provides the safest environment for sexual health.',
),
@@ -592,23 +639,27 @@ class LearnContent {
category: 'Disease Prevention',
sections: [
LearnSection(
content: 'Regular medical check-ups are vital for catching issues early when they '
content:
'Regular medical check-ups are vital for catching issues early when they '
'are most treatable.',
),
LearnSection(
heading: 'Pap Smears & HPV Testing',
content: '• **What**: Checks for cervical cancer and HPV (the virus that causes it).\n'
content:
'• **What**: Checks for cervical cancer and HPV (the virus that causes it).\n'
'• **When**: Typically every 3-5 years for women aged 21-65, depending on your doctor\'s advice.',
),
LearnSection(
heading: 'Breast Self-Exams',
content: '• **What**: Feeling for lumps or changes in breast tissue.\n'
content:
'• **What**: Feeling for lumps or changes in breast tissue.\n'
'• **When**: Once a month, ideally a few days after your period ends when '
'breasts are least tender.',
),
LearnSection(
heading: 'Annual Well-Woman Exam',
content: 'Even if you don\'t need a Pap smear locally, an annual visit allows you to discuss '
content:
'Even if you don\'t need a Pap smear locally, an annual visit allows you to discuss '
'period pain, fertility, contraception, and overall wellness with your provider.',
),
],
@@ -621,7 +672,8 @@ class LearnContent {
category: 'Partnership',
sections: [
LearnSection(
content: 'Your husband likely wants to support you but may not know how. '
content:
'Your husband likely wants to support you but may not know how. '
'Clear communication bridges that gap.',
),
LearnSection(
@@ -632,31 +684,35 @@ class LearnContent {
),
LearnSection(
heading: 'Share Your Cycle',
content: ' Invite him into the process. Let him know when your period starts '
content:
' Invite him into the process. Let him know when your period starts '
'or when you are ovulating. This helps him understand your changing needs '
'and moods.',
),
],
),
LearnArticle(
LearnArticle(
id: 'wife_shared_responsibility',
title: 'Shared Responsibility',
subtitle: 'Navigating fertility together',
category: 'Partnership',
sections: [
LearnSection(
content: 'Fertility is a shared reality, not just "the woman\'s job." '
content:
'Fertility is a shared reality, not just "the woman\'s job." '
'Whether trying to conceive or avoiding pregnancy, walk this path together.',
),
LearnSection(
heading: 'NFP / FAM',
content: 'If practicing Natural Family Planning, involve him in chart reading. '
'Discuss the fertile window together. It builds intimacy and trust.',
heading: 'NFP / FAM',
content:
'If practicing Natural Family Planning, involve him in chart reading. '
'Discuss the fertile window together. It builds intimacy and trust.',
),
LearnSection(
heading: 'Mutual Care',
content: 'Scripture calls husbands to love their wives as their own bodies. '
content:
'Scripture calls husbands to love their wives as their own bodies. '
'Allow him to care for you, and seek to understand his perspective as well.',
),
],

View File

@@ -2,13 +2,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'models/scripture.dart';
import 'theme/app_theme.dart';
import 'screens/splash_screen.dart';
import 'screens/onboarding/onboarding_screen.dart';
import 'screens/home/home_screen.dart';
import 'screens/husband/husband_home_screen.dart';
import 'models/user_profile.dart';
import 'models/cycle_entry.dart';
import 'models/teaching_plan.dart';
import 'models/prayer_request.dart';
import 'models/scripture.dart';
import 'providers/user_provider.dart';
import 'services/notification_service.dart';
import 'app_startup.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -28,24 +33,15 @@ void main() async {
Hive.registerAdapter(UserRoleAdapter());
Hive.registerAdapter(BibleTranslationAdapter());
Hive.registerAdapter(ScriptureAdapter());
Hive.registerAdapter(AppThemeModeAdapter()); // Register new adapter
Hive.registerAdapter(AppThemeModeAdapter());
Hive.registerAdapter(SupplyItemAdapter());
Hive.registerAdapter(PadTypeAdapter());
Hive.registerAdapter(TeachingPlanAdapter());
Hive.registerAdapter(PrayerRequestAdapter());
// Open boxes and load scriptures in parallel
await Future.wait([
Hive.openBox<UserProfile>('user_profile'),
Hive.openBox<CycleEntry>('cycle_entries'),
ScriptureDatabase().loadScriptures(),
]);
// Initialize notifications
await NotificationService().initialize();
runApp(const ProviderScope(child: ChristianPeriodTrackerApp()));
runApp(const ProviderScope(child: AppStartupWidget()));
}
// Helper to convert hex string to Color
Color _colorFromHex(String hexColor) {
try {
@@ -61,24 +57,39 @@ class ChristianPeriodTrackerApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch user profile to determine
final userProfile = ref.watch(userProfileProvider);
final ThemeMode themeMode;
final Color accentColor;
if (userProfile != null) {
accentColor = _colorFromHex(userProfile.accentColor);
switch (userProfile.themeMode) {
case AppThemeMode.light:
themeMode = ThemeMode.light;
break;
case AppThemeMode.dark:
themeMode = ThemeMode.dark;
break;
case AppThemeMode.system:
default:
themeMode = ThemeMode.system;
break;
if (userProfile.isHusband) {
accentColor = _colorFromHex(userProfile.husbandAccentColor);
switch (userProfile.husbandThemeMode) {
case AppThemeMode.light:
themeMode = ThemeMode.light;
break;
case AppThemeMode.dark:
themeMode = ThemeMode.dark;
break;
case AppThemeMode.system:
themeMode = ThemeMode.system;
break;
}
} else {
accentColor = _colorFromHex(userProfile.accentColor);
switch (userProfile.themeMode) {
case AppThemeMode.light:
themeMode = ThemeMode.light;
break;
case AppThemeMode.dark:
themeMode = ThemeMode.dark;
break;
case AppThemeMode.system:
themeMode = ThemeMode.system;
break;
}
}
} else {
// Default theme for initial load or if profile is null
@@ -86,13 +97,23 @@ class ChristianPeriodTrackerApp extends ConsumerWidget {
accentColor = AppColors.sageGreen;
}
// Determine the home screen based on profile state
Widget homeScreen;
if (userProfile == null) {
homeScreen = const OnboardingScreen();
} else if (userProfile.role == UserRole.husband) {
homeScreen = const HusbandHomeScreen();
} else {
homeScreen = const HomeScreen();
}
return MaterialApp(
title: 'Christian Period Tracker',
debugShowCheckedModeBanner: false,
theme: AppTheme.getLightTheme(accentColor),
darkTheme: AppTheme.getDarkTheme(accentColor),
themeMode: themeMode,
home: const SplashScreen(),
home: homeScreen,
);
}
}

View File

@@ -27,6 +27,9 @@ enum MoodLevel {
/// Flow intensity for period days
@HiveType(typeId: 4)
enum FlowIntensity {
@HiveField(4)
none, // No flow / Precautionary
@HiveField(0)
spotting,
@@ -166,7 +169,8 @@ class CycleEntry extends HiveObject {
String? husbandNotes; // Separate notes for husband
@HiveField(29)
bool? intimacyProtected; // null = no selection, true = protected, false = unprotected
bool?
intimacyProtected; // null = no selection, true = protected, false = unprotected
@HiveField(30, defaultValue: false)
bool usedPantyliner;
@@ -174,6 +178,9 @@ class CycleEntry extends HiveObject {
@HiveField(31, defaultValue: 0)
int pantylinerCount;
@HiveField(32)
String? prayerRequest;
CycleEntry({
required this.id,
required this.date,
@@ -207,6 +214,7 @@ class CycleEntry extends HiveObject {
this.husbandNotes,
this.usedPantyliner = false,
this.pantylinerCount = 0,
this.prayerRequest,
});
List<bool> get _symptomsList => [
@@ -271,6 +279,7 @@ class CycleEntry extends HiveObject {
String? husbandNotes,
bool? usedPantyliner,
int? pantylinerCount,
String? prayerRequest,
}) {
return CycleEntry(
id: id ?? this.id,
@@ -292,7 +301,8 @@ class CycleEntry extends HiveObject {
hasInsomnia: hasInsomnia ?? this.hasInsomnia,
basalBodyTemperature: basalBodyTemperature ?? this.basalBodyTemperature,
cervicalMucus: cervicalMucus ?? this.cervicalMucus,
ovulationTestPositive: ovulationTestPositive ?? this.ovulationTestPositive,
ovulationTestPositive:
ovulationTestPositive ?? this.ovulationTestPositive,
notes: notes ?? this.notes,
cravings: cravings ?? this.cravings,
sleepHours: sleepHours ?? this.sleepHours,
@@ -305,6 +315,7 @@ class CycleEntry extends HiveObject {
husbandNotes: husbandNotes ?? this.husbandNotes,
usedPantyliner: usedPantyliner ?? this.usedPantyliner,
pantylinerCount: pantylinerCount ?? this.pantylinerCount,
prayerRequest: prayerRequest ?? this.prayerRequest,
);
}
}
@@ -345,6 +356,8 @@ extension MoodLevelExtension on MoodLevel {
extension FlowIntensityExtension on FlowIntensity {
String get label {
switch (this) {
case FlowIntensity.none:
return 'No Flow';
case FlowIntensity.spotting:
return 'Spotting';
case FlowIntensity.light:
@@ -405,16 +418,12 @@ extension CyclePhaseExtension on CyclePhase {
return [
AppColors.sageGreen,
AppColors.follicularPhase,
AppColors.sageGreen.withOpacity(0.7)
AppColors.sageGreen.withValues(alpha: 0.7)
];
case CyclePhase.ovulation:
return [AppColors.lavender, AppColors.ovulationPhase, AppColors.rose];
case CyclePhase.luteal:
return [
AppColors.lutealPhase,
AppColors.lavender,
AppColors.blushPink
];
return [AppColors.lutealPhase, AppColors.lavender, AppColors.blushPink];
}
}

View File

@@ -49,13 +49,14 @@ class CycleEntryAdapter extends TypeAdapter<CycleEntry> {
husbandNotes: fields[28] as String?,
usedPantyliner: fields[30] == null ? false : fields[30] as bool,
pantylinerCount: fields[31] == null ? 0 : fields[31] as int,
prayerRequest: fields[32] as String?,
);
}
@override
void write(BinaryWriter writer, CycleEntry obj) {
writer
..writeByte(32)
..writeByte(33)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -119,7 +120,9 @@ class CycleEntryAdapter extends TypeAdapter<CycleEntry> {
..writeByte(30)
..write(obj.usedPantyliner)
..writeByte(31)
..write(obj.pantylinerCount);
..write(obj.pantylinerCount)
..writeByte(32)
..write(obj.prayerRequest);
}
@override
@@ -194,6 +197,8 @@ class FlowIntensityAdapter extends TypeAdapter<FlowIntensity> {
@override
FlowIntensity read(BinaryReader reader) {
switch (reader.readByte()) {
case 4:
return FlowIntensity.none;
case 0:
return FlowIntensity.spotting;
case 1:
@@ -203,13 +208,16 @@ class FlowIntensityAdapter extends TypeAdapter<FlowIntensity> {
case 3:
return FlowIntensity.heavy;
default:
return FlowIntensity.spotting;
return FlowIntensity.none;
}
}
@override
void write(BinaryWriter writer, FlowIntensity obj) {
switch (obj) {
case FlowIntensity.none:
writer.writeByte(4);
break;
case FlowIntensity.spotting:
writer.writeByte(0);
break;

View File

@@ -0,0 +1,53 @@
import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart';
part 'prayer_request.g.dart';
@HiveType(typeId: 15)
class PrayerRequest extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String request;
@HiveField(2)
final bool isAnswered;
@HiveField(3)
final DateTime createdAt;
@HiveField(4)
final DateTime updatedAt;
PrayerRequest({
required this.id,
required this.request,
this.isAnswered = false,
required this.createdAt,
required this.updatedAt,
});
PrayerRequest copyWith({
String? request,
bool? isAnswered,
DateTime? updatedAt,
}) {
return PrayerRequest(
id: id,
request: request ?? this.request,
isAnswered: isAnswered ?? this.isAnswered,
createdAt: createdAt,
updatedAt: updatedAt ?? DateTime.now(),
);
}
factory PrayerRequest.create({required String request}) {
return PrayerRequest(
id: const Uuid().v4(),
request: request,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
}
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'prayer_request.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PrayerRequestAdapter extends TypeAdapter<PrayerRequest> {
@override
final int typeId = 15;
@override
PrayerRequest read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return PrayerRequest(
id: fields[0] as String,
request: fields[1] as String,
isAnswered: fields[2] as bool,
createdAt: fields[3] as DateTime,
updatedAt: fields[4] as DateTime,
);
}
@override
void write(BinaryWriter writer, PrayerRequest obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.request)
..writeByte(2)
..write(obj.isAnswered)
..writeByte(3)
..write(obj.createdAt)
..writeByte(4)
..write(obj.updatedAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PrayerRequestAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart'; // For debugPrint
import 'package:hive_flutter/hive_flutter.dart'; // Import Hive
import '../services/bible_xml_parser.dart'; // Import the XML parser
@@ -39,12 +40,12 @@ class Scripture extends HiveObject {
reference: json['reference'],
reflection: json['reflection'],
applicablePhases: (json['applicablePhases'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
?.map((e) => e as String)
.toList() ??
[],
applicableContexts: (json['applicableContexts'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
?.map((e) => e as String)
.toList() ??
[],
);
}
@@ -54,6 +55,7 @@ class Scripture extends HiveObject {
verses[BibleTranslation.esv] ??
verses.values.first;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
@@ -92,7 +94,9 @@ class Scripture extends HiveObject {
if (a.length != b.length) return false;
if (a.keys.length != b.keys.length) return false; // Added length check
for (final key in a.keys) {
if (!b.containsKey(key) || a[key] != b[key]) return false; // Added containsKey check
if (!b.containsKey(key) || a[key] != b[key]) {
return false; // Added containsKey check
}
}
return true;
}
@@ -103,26 +107,14 @@ class ScriptureDatabase {
static final ScriptureDatabase _instance = ScriptureDatabase._internal();
factory ScriptureDatabase({BibleXmlParser? bibleXmlParser}) {
_instance._bibleXmlParser = bibleXmlParser ?? BibleXmlParser();
return _instance;
}
ScriptureDatabase._internal();
late BibleXmlParser _bibleXmlParser;
late Box<Scripture> _scriptureBox;
// Mapping of BibleTranslation to its XML asset path
final Map<BibleTranslation, String> _translationFileMapping = {
BibleTranslation.esv: 'bible_xml/ESV.xml',
BibleTranslation.niv: 'bible_xml/NIV.xml',
BibleTranslation.nkjv: 'bible_xml/NKJV.xml',
BibleTranslation.nlt: 'bible_xml/NLT.xml',
BibleTranslation.nasb: 'bible_xml/NASB.xml',
BibleTranslation.kjv: 'bible_xml/KJV.xml',
BibleTranslation.msg: 'bible_xml/MSG.xml',
};
List<Scripture> _menstrualScriptures = [];
List<Scripture> _follicularScriptures = [];
@@ -137,17 +129,21 @@ class ScriptureDatabase {
Scripture(
reference: "Mark 10:45",
verses: {
BibleTranslation.esv: "For even the Son of Man came not to be served but to serve, and to give his life as a ransom for many.",
BibleTranslation.niv: "For even the Son of Man did not come to be served, but to serve, and to give his life as a ransom for many.",
BibleTranslation.esv:
"For even the Son of Man came not to be served but to serve, and to give his life as a ransom for many.",
BibleTranslation.niv:
"For even the Son of Man did not come to be served, but to serve, and to give his life as a ransom for many.",
},
reflection: "True leadership is servanthood. How can you serve your wife today?",
reflection:
"True leadership is servanthood. How can you serve your wife today?",
applicablePhases: ['husband'],
applicableContexts: ['leadership', 'servant'],
),
Scripture(
reference: "Philippians 2:3-4",
verses: {
BibleTranslation.esv: "Do nothing from selfish ambition or conceit, but in humility count others more significant than yourselves. Let each of you look not only to his own interests, but also to the interests of others.",
BibleTranslation.esv:
"Do nothing from selfish ambition or conceit, but in humility count others more significant than yourselves. Let each of you look not only to his own interests, but also to the interests of others.",
},
reflection: "Humility is the foundation of a happy marriage.",
applicablePhases: ['husband'],
@@ -156,8 +152,10 @@ class ScriptureDatabase {
Scripture(
reference: "Proverbs 29:18",
verses: {
BibleTranslation.esv: "Where there is no prophetic vision the people cast off restraint, but blessed is he who keeps the law.",
BibleTranslation.kjv: "Where there is no vision, the people perish: but he that keepeth the law, happy is he.",
BibleTranslation.esv:
"Where there is no prophetic vision the people cast off restraint, but blessed is he who keeps the law.",
BibleTranslation.kjv:
"Where there is no vision, the people perish: but he that keepeth the law, happy is he.",
},
reflection: "Lead your family with a clear, Godly vision.",
applicablePhases: ['husband'],
@@ -166,26 +164,32 @@ class ScriptureDatabase {
Scripture(
reference: "James 1:5",
verses: {
BibleTranslation.esv: "If any of you lacks wisdom, let him ask God, who gives generously to all without reproach, and it will be given him.",
BibleTranslation.esv:
"If any of you lacks wisdom, let him ask God, who gives generously to all without reproach, and it will be given him.",
},
reflection: "Seek God's wisdom in every decision you make for your family.",
reflection:
"Seek God's wisdom in every decision you make for your family.",
applicablePhases: ['husband'],
applicableContexts: ['wisdom', 'vision'],
),
Scripture(
reference: "1 Timothy 3:4-5",
verses: {
BibleTranslation.esv: "He must manage his own household well, with all dignity keeping his children submissive, for if someone does not know how to manage his own household, how will he care for God's church?",
BibleTranslation.esv:
"He must manage his own household well, with all dignity keeping his children submissive, for if someone does not know how to manage his own household, how will he care for God's church?",
},
reflection: "Your first ministry is your home. Manage it with love and dignity.",
reflection:
"Your first ministry is your home. Manage it with love and dignity.",
applicablePhases: ['husband'],
applicableContexts: ['leadership'],
),
Scripture(
reference: "Colossians 3:19",
verses: {
BibleTranslation.esv: "Husbands, love your wives, and do not be harsh with them.",
BibleTranslation.niv: "Husbands, love your wives and do not be harsh with them.",
BibleTranslation.esv:
"Husbands, love your wives, and do not be harsh with them.",
BibleTranslation.niv:
"Husbands, love your wives and do not be harsh with them.",
},
reflection: "Gentleness is a sign of strength, not weakness.",
applicablePhases: ['husband'],
@@ -206,9 +210,11 @@ class ScriptureDatabase {
_scriptureBox = await Hive.openBox<Scripture>('scriptures');
if (_scriptureBox.isEmpty) {
print('Hive box is empty. Importing scriptures from optimized JSON data...');
debugPrint(
'Hive box is empty. Importing scriptures from optimized JSON data...');
// Load the pre-processed JSON file which already contains all verse text
final String response = await rootBundle.loadString('assets/scriptures_optimized.json');
final String response =
await rootBundle.loadString('assets/scriptures_optimized.json');
final Map<String, dynamic> data = json.decode(response);
List<Scripture> importedScriptures = [];
@@ -218,28 +224,32 @@ class ScriptureDatabase {
for (var jsonEntry in list) {
final reference = jsonEntry['reference'];
final reflection = jsonEntry['reflection']; // Optional
final applicablePhases = (jsonEntry['applicablePhases'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ?? [];
final applicableContexts = (jsonEntry['applicableContexts'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ?? [];
final applicablePhases =
(jsonEntry['applicablePhases'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
final applicableContexts =
(jsonEntry['applicableContexts'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
// Map string keys (esv, niv) to BibleTranslation enum
Map<BibleTranslation, String> versesMap = {};
if (jsonEntry['verses'] != null) {
(jsonEntry['verses'] as Map<String, dynamic>).forEach((key, value) {
// Find enum by name (case-insensitive usually, but here keys are lowercase 'esv')
try {
final translation = BibleTranslation.values.firstWhere(
(e) => e.name.toLowerCase() == key.toLowerCase()
);
versesMap[translation] = value.toString();
} catch (e) {
print('Warning: Unknown translation key "$key" in optimized JSON');
}
// Find enum by name (case-insensitive usually, but here keys are lowercase 'esv')
try {
final translation = BibleTranslation.values.firstWhere(
(e) => e.name.toLowerCase() == key.toLowerCase());
versesMap[translation] = value.toString();
} catch (e) {
debugPrint(
'Warning: Unknown translation key "$key" in optimized JSON');
}
});
}
@@ -255,15 +265,26 @@ class ScriptureDatabase {
}
}
// Process all sections
if (data['menstrual'] != null) processList(data['menstrual'], 'menstrual');
if (data['follicular'] != null) processList(data['follicular'], 'follicular');
if (data['ovulation'] != null) processList(data['ovulation'], 'ovulation');
if (data['luteal'] != null) processList(data['luteal'], 'luteal');
if (data['husband'] != null) processList(data['husband'], 'husband');
if (data['womanhood'] != null) processList(data['womanhood'], 'womanhood');
if (data['menstrual'] != null) {
processList(data['menstrual'], 'menstrual');
}
if (data['follicular'] != null) {
processList(data['follicular'], 'follicular');
}
if (data['ovulation'] != null) {
processList(data['ovulation'], 'ovulation');
}
if (data['luteal'] != null) {
processList(data['luteal'], 'luteal');
}
if (data['husband'] != null) {
processList(data['husband'], 'husband');
}
if (data['womanhood'] != null) {
processList(data['womanhood'], 'womanhood');
}
if (data['contextual'] != null) {
final contextualMap = data['contextual'] as Map<String, dynamic>;
contextualMap.forEach((key, value) {
@@ -272,11 +293,14 @@ class ScriptureDatabase {
}
// Store all imported scriptures into Hive
for (var scripture in importedScriptures) {
await _scriptureBox.put(scripture.reference, scripture); // Using reference as key
}
final Map<String, Scripture> scripturesMap = {
for (var s in importedScriptures) s.reference: s
};
await _scriptureBox.putAll(scripturesMap);
debugPrint(
'Successfully imported ${importedScriptures.length} scriptures.');
} else {
print('Hive box is not empty. Loading scriptures from Hive...');
debugPrint('Hive box is not empty. Loading scriptures from Hive...');
}
// Populate internal lists from Hive box values
@@ -293,7 +317,8 @@ class ScriptureDatabase {
.where((s) => s.applicablePhases.contains('luteal'))
.toList();
_husbandScriptures = [
..._scriptureBox.values.where((s) => s.applicablePhases.contains('husband')),
..._scriptureBox.values
.where((s) => s.applicablePhases.contains('husband')),
..._hardcodedHusbandScriptures,
];
// Remove duplicates based on reference if any
@@ -308,10 +333,18 @@ class ScriptureDatabase {
.where((s) => s.applicableContexts.contains('womanhood'))
.toList();
_contextualScriptures = {
'anxiety': _scriptureBox.values.where((s) => s.applicableContexts.contains('anxiety')).toList(),
'pain': _scriptureBox.values.where((s) => s.applicableContexts.contains('pain')).toList(),
'fatigue': _scriptureBox.values.where((s) => s.applicableContexts.contains('fatigue')).toList(),
'joy': _scriptureBox.values.where((s) => s.applicableContexts.contains('joy')).toList(),
'anxiety': _scriptureBox.values
.where((s) => s.applicableContexts.contains('anxiety'))
.toList(),
'pain': _scriptureBox.values
.where((s) => s.applicableContexts.contains('pain'))
.toList(),
'fatigue': _scriptureBox.values
.where((s) => s.applicableContexts.contains('fatigue'))
.toList(),
'joy': _scriptureBox.values
.where((s) => s.applicableContexts.contains('joy'))
.toList(),
};
}
@@ -421,7 +454,6 @@ class ScriptureDatabase {
return scriptures[index];
}
// ... imports
// ... inside ScriptureDatabase class
@@ -518,10 +550,22 @@ class ScriptureDatabase {
...(_contextualScriptures['fatigue'] ?? []),
...(_contextualScriptures['joy'] ?? []),
];
if (scriptures.isEmpty) return Scripture(verses: {BibleTranslation.esv: "No scripture found."}, reference: "Unknown", applicablePhases: [], applicableContexts: []);
if (scriptures.isEmpty) {
return Scripture(
verses: {BibleTranslation.esv: "No scripture found."},
reference: "Unknown",
applicablePhases: [],
applicableContexts: []);
}
}
if (scriptures.isEmpty) return Scripture(verses: {BibleTranslation.esv: "No scripture found."}, reference: "Unknown", applicablePhases: [], applicableContexts: []);
if (scriptures.isEmpty) {
return Scripture(
verses: {BibleTranslation.esv: "No scripture found."},
reference: "Unknown",
applicablePhases: [],
applicableContexts: []);
}
return scriptures[Random().nextInt(scriptures.length)];
}
@@ -529,7 +573,11 @@ class ScriptureDatabase {
Scripture getHusbandScripture() {
final scriptures = _husbandScriptures;
if (scriptures.isEmpty) {
return Scripture(verses: {BibleTranslation.esv: "No husband scripture found."}, reference: "Unknown", applicablePhases: [], applicableContexts: []);
return Scripture(
verses: {BibleTranslation.esv: "No husband scripture found."},
reference: "Unknown",
applicablePhases: [],
applicableContexts: []);
}
return scriptures[Random().nextInt(scriptures.length)];
}

View File

@@ -3,7 +3,7 @@ import 'package:uuid/uuid.dart';
part 'teaching_plan.g.dart';
@HiveType(typeId: 10)
@HiveType(typeId: 14)
class TeachingPlan {
@HiveField(0)
final String id;

View File

@@ -8,7 +8,7 @@ part of 'teaching_plan.dart';
class TeachingPlanAdapter extends TypeAdapter<TeachingPlan> {
@override
final int typeId = 10;
final int typeId = 14;
@override
TeachingPlan read(BinaryReader reader) {

View File

@@ -64,13 +64,13 @@ enum PadType {
@HiveField(1)
regular,
@HiveField(2)
super_pad,
superPad,
@HiveField(3)
overnight,
@HiveField(4)
tampon_regular,
tamponRegular,
@HiveField(5)
tampon_super,
tamponSuper,
@HiveField(6)
menstrualCup,
@HiveField(7)
@@ -86,13 +86,13 @@ extension PadTypeExtension on PadType {
return 'Liner';
case PadType.regular:
return 'Regular Pad';
case PadType.super_pad:
case PadType.superPad:
return 'Super Pad';
case PadType.overnight:
return 'Overnight';
case PadType.tampon_regular:
case PadType.tamponRegular:
return 'Tampon (Regular)';
case PadType.tampon_super:
case PadType.tamponSuper:
return 'Tampon (Super)';
case PadType.menstrualCup:
return 'Cup';
@@ -302,6 +302,23 @@ class UserProfile extends HiveObject {
@HiveField(53)
List<TeachingPlan>? teachingPlans;
// Husband-specific theme settings
@HiveField(54, defaultValue: AppThemeMode.system)
AppThemeMode husbandThemeMode;
@HiveField(55, defaultValue: '0xFF1A3A5C')
String husbandAccentColor;
// Whether to use example/demo data (for husband not connected to wife)
@HiveField(56, defaultValue: false)
bool useExampleData;
@HiveField(57)
String? partnerId; // ID of the partner to sync with
@HiveField(58, defaultValue: true)
bool sharePadSupplies; // Share pad supply data with partner
UserProfile({
required this.id,
required this.name,
@@ -356,6 +373,11 @@ class UserProfile extends HiveObject {
this.isCalendarProtected = false,
this.isSuppliesProtected = false,
this.teachingPlans,
this.husbandThemeMode = AppThemeMode.system,
this.husbandAccentColor = '0xFF1A3A5C',
this.useExampleData = false,
this.partnerId,
this.sharePadSupplies = true,
});
/// Check if user is married
@@ -435,6 +457,11 @@ class UserProfile extends HiveObject {
bool? isCalendarProtected,
bool? isSuppliesProtected,
List<TeachingPlan>? teachingPlans,
AppThemeMode? husbandThemeMode,
String? husbandAccentColor,
bool? useExampleData,
String? partnerId,
bool? sharePadSupplies,
}) {
return UserProfile(
id: id ?? this.id,
@@ -470,8 +497,10 @@ class UserProfile extends HiveObject {
padBrand: padBrand ?? this.padBrand,
padAbsorbency: padAbsorbency ?? this.padAbsorbency,
padInventoryCount: padInventoryCount ?? this.padInventoryCount,
lowInventoryThreshold: lowInventoryThreshold ?? this.lowInventoryThreshold,
isAutoInventoryEnabled: isAutoInventoryEnabled ?? this.isAutoInventoryEnabled,
lowInventoryThreshold:
lowInventoryThreshold ?? this.lowInventoryThreshold,
isAutoInventoryEnabled:
isAutoInventoryEnabled ?? this.isAutoInventoryEnabled,
lastInventoryUpdate: lastInventoryUpdate ?? this.lastInventoryUpdate,
notifyPeriodEstimate: notifyPeriodEstimate ?? this.notifyPeriodEstimate,
notifyPeriodStart: notifyPeriodStart ?? this.notifyPeriodStart,
@@ -491,6 +520,11 @@ class UserProfile extends HiveObject {
isCalendarProtected: isCalendarProtected ?? this.isCalendarProtected,
isSuppliesProtected: isSuppliesProtected ?? this.isSuppliesProtected,
teachingPlans: teachingPlans ?? this.teachingPlans,
husbandThemeMode: husbandThemeMode ?? this.husbandThemeMode,
husbandAccentColor: husbandAccentColor ?? this.husbandAccentColor,
useExampleData: useExampleData ?? this.useExampleData,
partnerId: partnerId ?? this.partnerId,
sharePadSupplies: sharePadSupplies ?? this.sharePadSupplies,
);
}
}

View File

@@ -118,13 +118,20 @@ class UserProfileAdapter extends TypeAdapter<UserProfile> {
isCalendarProtected: fields[51] == null ? false : fields[51] as bool,
isSuppliesProtected: fields[52] == null ? false : fields[52] as bool,
teachingPlans: (fields[53] as List?)?.cast<TeachingPlan>(),
husbandThemeMode:
fields[54] == null ? AppThemeMode.system : fields[54] as AppThemeMode,
husbandAccentColor:
fields[55] == null ? '0xFF1A3A5C' : fields[55] as String,
useExampleData: fields[56] == null ? false : fields[56] as bool,
partnerId: fields[57] as String?,
sharePadSupplies: fields[58] == null ? true : fields[58] as bool,
);
}
@override
void write(BinaryWriter writer, UserProfile obj) {
writer
..writeByte(53)
..writeByte(58)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -230,7 +237,17 @@ class UserProfileAdapter extends TypeAdapter<UserProfile> {
..writeByte(52)
..write(obj.isSuppliesProtected)
..writeByte(53)
..write(obj.teachingPlans);
..write(obj.teachingPlans)
..writeByte(54)
..write(obj.husbandThemeMode)
..writeByte(55)
..write(obj.husbandAccentColor)
..writeByte(56)
..write(obj.useExampleData)
..writeByte(57)
..write(obj.partnerId)
..writeByte(58)
..write(obj.sharePadSupplies);
}
@override
@@ -452,13 +469,13 @@ class PadTypeAdapter extends TypeAdapter<PadType> {
case 1:
return PadType.regular;
case 2:
return PadType.super_pad;
return PadType.superPad;
case 3:
return PadType.overnight;
case 4:
return PadType.tampon_regular;
return PadType.tamponRegular;
case 5:
return PadType.tampon_super;
return PadType.tamponSuper;
case 6:
return PadType.menstrualCup;
case 7:
@@ -479,16 +496,16 @@ class PadTypeAdapter extends TypeAdapter<PadType> {
case PadType.regular:
writer.writeByte(1);
break;
case PadType.super_pad:
case PadType.superPad:
writer.writeByte(2);
break;
case PadType.overnight:
writer.writeByte(3);
break;
case PadType.tampon_regular:
case PadType.tamponRegular:
writer.writeByte(4);
break;
case PadType.tampon_super:
case PadType.tamponSuper:
writer.writeByte(5);
break;
case PadType.menstrualCup:

View File

@@ -0,0 +1,83 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/prayer_request.dart';
import 'user_provider.dart';
final prayerRequestsProvider =
StateNotifierProvider<PrayerRequestsNotifier, List<PrayerRequest>>((ref) {
return PrayerRequestsNotifier(ref);
});
class PrayerRequestsNotifier extends StateNotifier<List<PrayerRequest>> {
final Ref ref;
PrayerRequestsNotifier(this.ref) : super([]) {
_loadRequests();
}
void _loadRequests() {
// In a real app with Hive, we'd have a box for this.
// Since I haven't opened a box yet for prayers, I'll do it here or assume main.dart opened it.
// I'll use a separate box 'prayer_requests_v2'.
// Note: main.dart needs to open this box. I'll need to update main.dart.
if (Hive.isBoxOpen('prayer_requests_v2')) {
final box = Hive.box<PrayerRequest>('prayer_requests_v2');
state = box.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
}
Future<void> addRequest(String requestText) async {
final newRequest = PrayerRequest.create(request: requestText);
final box = Hive.box<PrayerRequest>('prayer_requests_v2');
await box.put(newRequest.id, newRequest);
state = [...state, newRequest]
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
_push();
}
Future<void> updateRequest(PrayerRequest request) async {
final box = Hive.box<PrayerRequest>('prayer_requests_v2');
await box.put(request.id, request);
state = [
for (final r in state)
if (r.id == request.id) request else r
]..sort((a, b) => b.createdAt.compareTo(a.createdAt));
_push();
}
Future<void> toggleAnswered(PrayerRequest request) async {
final updated = request.copyWith(isAnswered: !request.isAnswered);
await updateRequest(updated);
}
Future<void> deleteRequest(String id) async {
final box = Hive.box<PrayerRequest>('prayer_requests_v2');
await box.delete(id);
state = state.where((r) => r.id != id).toList();
_push();
}
// Sync Logic
Future<void> _push() async {
final user = ref.read(userProfileProvider);
if (user != null) {
// We only push OUR requests, or all?
// Simplified: Push all local state. Backend handles upsert.
// But we need to pass everything to pushSyncData now.
// This is where splitting providers makes "push everything" hard without a central sync manager.
// For now, I'll just trigger a full sync if I can, or update pushSyncData to allow partial updates?
// No, SyncService.pushSyncData expects all lists.
// I should expose the current state to the sync service/orchestrator.
// HACK: I will just instantiate SyncService here, but I need entries and plans too.
// Better: Have a `SyncProvider` that reads all other providers and pushes.
// For this step, I'll skip auto-push on every add/edit and rely on manual "Sync Data" or periodic sync?
// The user wants "Sync".
// I'll call `ref.read(cycleEntriesProvider.notifier).syncData()` which I can modify to pull from here.
// Let's modify CycleEntriesNotifier.syncData to act as the central sync coordinator.
}
}
}

View File

@@ -1,8 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/scripture.dart';
import '../models/cycle_entry.dart';
import 'user_provider.dart';
import 'package:collection/collection.dart'; // For IterableExtension
// For IterableExtension
// State for ScriptureProvider
class ScriptureState {
@@ -36,9 +35,8 @@ class ScriptureState {
// StateNotifier for ScriptureProvider
class ScriptureNotifier extends StateNotifier<ScriptureState> {
final ScriptureDatabase _scriptureDatabase;
final Ref _ref;
ScriptureNotifier(this._scriptureDatabase, this._ref) : super(ScriptureState()) {
ScriptureNotifier(this._scriptureDatabase) : super(ScriptureState()) {
// We don't initialize here directly, as we need the phase from other providers.
// Initialization will be triggered by the UI.
}
@@ -48,11 +46,13 @@ class ScriptureNotifier extends StateNotifier<ScriptureState> {
Future<void> initializeScripture(CyclePhase phase) async {
// Only re-initialize if the phase has changed or no scripture is currently set
if (state.currentPhase != phase || state.currentScripture == null) {
final scriptureCount = _scriptureDatabase.getScriptureCountForPhase(phase.name);
final scriptureCount =
_scriptureDatabase.getScriptureCountForPhase(phase.name);
if (scriptureCount > 0) {
// Use day of year to get a stable initial scripture for the day
final dayOfYear =
DateTime.now().difference(DateTime(DateTime.now().year, 1, 1)).inDays;
final dayOfYear = DateTime.now()
.difference(DateTime(DateTime.now().year, 1, 1))
.inDays;
final initialIndex = dayOfYear % scriptureCount;
state = state.copyWith(
currentPhase: phase,
@@ -73,24 +73,38 @@ class ScriptureNotifier extends StateNotifier<ScriptureState> {
}
void getNextScripture() {
if (state.currentPhase == null || state.maxIndex == null || state.maxIndex == 0) return;
if (state.currentPhase == null ||
state.maxIndex == null ||
state.maxIndex == 0) {
return;
}
final nextIndex = (state.currentIndex + 1) % state.maxIndex!;
_updateScripture(nextIndex);
}
void getPreviousScripture() {
if (state.currentPhase == null || state.maxIndex == null || state.maxIndex == 0) return;
if (state.currentPhase == null ||
state.maxIndex == null ||
state.maxIndex == 0) {
return;
}
final prevIndex = (state.currentIndex - 1 + state.maxIndex!) % state.maxIndex!;
final prevIndex =
(state.currentIndex - 1 + state.maxIndex!) % state.maxIndex!;
_updateScripture(prevIndex);
}
void getRandomScripture() {
if (state.currentPhase == null || state.maxIndex == null || state.maxIndex == 0) return;
if (state.currentPhase == null ||
state.maxIndex == null ||
state.maxIndex == 0) {
return;
}
// Use a proper random number generator for better randomness
final randomIndex = DateTime.now().microsecondsSinceEpoch % state.maxIndex!; // Still using timestamp for simplicity
final randomIndex = DateTime.now().microsecondsSinceEpoch %
state.maxIndex!; // Still using timestamp for simplicity
_updateScripture(randomIndex);
}
@@ -109,5 +123,5 @@ final scriptureDatabaseProvider = Provider((ref) => ScriptureDatabase());
final scriptureProvider =
StateNotifierProvider<ScriptureNotifier, ScriptureState>((ref) {
return ScriptureNotifier(ref.watch(scriptureDatabaseProvider), ref);
return ScriptureNotifier(ref.watch(scriptureDatabaseProvider));
});

View File

@@ -0,0 +1,43 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SyncNotificationState {
final bool hasNewLogData;
final bool hasNewPrayerData;
const SyncNotificationState({
this.hasNewLogData = false,
this.hasNewPrayerData = false,
});
SyncNotificationState copyWith({
bool? hasNewLogData,
bool? hasNewPrayerData,
}) {
return SyncNotificationState(
hasNewLogData: hasNewLogData ?? this.hasNewLogData,
hasNewPrayerData: hasNewPrayerData ?? this.hasNewPrayerData,
);
}
}
class SyncNotificationNotifier extends StateNotifier<SyncNotificationState> {
SyncNotificationNotifier() : super(const SyncNotificationState());
void setHasNewLogData(bool value) {
if (state.hasNewLogData != value) {
state = state.copyWith(hasNewLogData: value);
}
}
void setHasNewPrayerData(bool value) {
if (state.hasNewPrayerData != value) {
state = state.copyWith(hasNewPrayerData: value);
}
}
}
final syncNotificationProvider =
StateNotifierProvider<SyncNotificationNotifier, SyncNotificationState>(
(ref) {
return SyncNotificationNotifier();
});

View File

@@ -0,0 +1,39 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/teaching_plan.dart';
final teachingPlansProvider =
StateNotifierProvider<TeachingPlansNotifier, List<TeachingPlan>>((ref) {
return TeachingPlansNotifier();
});
class TeachingPlansNotifier extends StateNotifier<List<TeachingPlan>> {
TeachingPlansNotifier() : super([]) {
_loadPlans();
}
void _loadPlans() {
if (Hive.isBoxOpen('teaching_plans_v2')) {
final box = Hive.box<TeachingPlan>('teaching_plans_v2');
state = box.values.toList()..sort((a, b) => b.date.compareTo(a.date));
}
}
Future<void> addPlan(TeachingPlan plan) async {
final box = Hive.box<TeachingPlan>('teaching_plans_v2');
await box.put(plan.id, plan);
_loadPlans();
}
Future<void> updatePlan(TeachingPlan plan) async {
final box = Hive.box<TeachingPlan>('teaching_plans_v2');
await box.put(plan.id, plan);
_loadPlans();
}
Future<void> deletePlan(String id) async {
final box = Hive.box<TeachingPlan>('teaching_plans_v2');
await box.delete(id);
_loadPlans();
}
}

View File

@@ -1,11 +1,19 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/user_profile.dart';
import '../models/cycle_entry.dart';
import '../services/cycle_service.dart';
import '../services/sync_service.dart';
import 'prayer_provider.dart';
import 'teaching_plan_provider.dart';
import '../models/prayer_request.dart';
import '../models/teaching_plan.dart';
/// Provider for the user profile
final userProfileProvider = StateNotifierProvider<UserProfileNotifier, UserProfile?>((ref) {
final userProfileProvider =
StateNotifierProvider<UserProfileNotifier, UserProfile?>((ref) {
return UserProfileNotifier();
});
@@ -16,12 +24,12 @@ class UserProfileNotifier extends StateNotifier<UserProfile?> {
}
void _loadProfile() {
final box = Hive.box<UserProfile>('user_profile');
final box = Hive.box<UserProfile>('user_profile_v2');
state = box.get('current_user');
}
Future<void> updateProfile(UserProfile profile) async {
final box = Hive.box<UserProfile>('user_profile');
final box = Hive.box<UserProfile>('user_profile_v2');
await box.put('current_user', profile);
state = profile;
}
@@ -38,55 +46,76 @@ class UserProfileNotifier extends StateNotifier<UserProfile?> {
}
}
Future<void> updateRelationshipStatus(RelationshipStatus relationshipStatus) async {
Future<void> updateRelationshipStatus(
RelationshipStatus relationshipStatus) async {
if (state != null) {
await updateProfile(state!.copyWith(relationshipStatus: relationshipStatus));
await updateProfile(
state!.copyWith(relationshipStatus: relationshipStatus));
}
}
Future<void> clearProfile() async {
final box = Hive.box<UserProfile>('user_profile');
final box = Hive.box<UserProfile>('user_profile_v2');
await box.clear();
state = null;
}
}
/// Provider for cycle entries
final cycleEntriesProvider = StateNotifierProvider<CycleEntriesNotifier, List<CycleEntry>>((ref) {
return CycleEntriesNotifier();
final cycleEntriesProvider =
StateNotifierProvider<CycleEntriesNotifier, List<CycleEntry>>((ref) {
return CycleEntriesNotifier(ref);
});
/// Notifier for cycle entries
class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
CycleEntriesNotifier() : super([]) {
final Ref ref;
Timer? _syncTimer;
CycleEntriesNotifier(this.ref) : super([]) {
_loadEntries();
syncData(); // Auto-sync on load
// Start periodic sync every 10 seconds
_syncTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
_pull();
});
}
@override
void dispose() {
_syncTimer?.cancel();
super.dispose();
}
void _loadEntries() {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
state = box.values.toList()..sort((a, b) => b.date.compareTo(a.date));
}
Future<void> addEntry(CycleEntry entry) async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
await box.put(entry.id, entry);
_loadEntries();
_push();
}
Future<void> updateEntry(CycleEntry entry) async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
await box.put(entry.id, entry);
_loadEntries();
_push();
}
Future<void> deleteEntry(String id) async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
await box.delete(id);
_loadEntries();
_push();
}
Future<void> deleteEntriesForMonth(int year, int month) async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
final keysToDelete = <dynamic>[];
for (var entry in box.values) {
if (entry.date.year == year && entry.date.month == month) {
@@ -95,13 +124,120 @@ class CycleEntriesNotifier extends StateNotifier<List<CycleEntry>> {
}
await box.deleteAll(keysToDelete);
_loadEntries();
_push();
}
Future<void> clearEntries() async {
final box = Hive.box<CycleEntry>('cycle_entries');
final box = Hive.box<CycleEntry>('cycle_entries_v2');
await box.clear();
state = [];
_push();
}
// Sync Logic
Future<void> syncData() async {
await _pull();
// After pull, we might want to push any local changes not in remote?
// For now, simpler consistency: Pull then Push current state?
// Or just Pull. Push happens on edit.
// Let's just Pull.
}
Future<void> _push() async {
final userBox = Hive.box<UserProfile>('user_profile_v2');
final user = userBox.get('current_user');
// Read current state from other providers
// Note: This relies on the providers being initialized.
final plans = ref.read(teachingPlansProvider);
final prayers = ref.read(prayerRequestsProvider);
if (user != null) {
final userDetails = {
'name': user.name,
'role': user.role.name,
'partnerId': user.partnerId,
'createdAt': user.createdAt.toIso8601String(),
// Add other relevant fields if needed for display on partner's side
};
await SyncService().pushSyncData(
userId: user.id,
entries: state,
teachingPlans: plans,
prayerRequests: prayers,
userDetails: userDetails,
);
}
}
Future<void> _pull() async {
final userBox = Hive.box<UserProfile>('user_profile_v2');
final user = userBox.get('current_user');
if (user == null) return;
final syncResult = await SyncService().pullSyncData(
user.id,
partnerId: user.partnerId,
);
// 0. Check for Server-Side Profile Updates (Auto-Link)
if (syncResult.containsKey('userProfile')) {
final serverProfile = syncResult['userProfile'] as Map<String, dynamic>;
final serverPartnerId = serverProfile['partnerId'];
// If server has a partner ID and we don't (or it's different), update!
if (serverPartnerId != null && serverPartnerId != user.partnerId) {
// Update local profile
final updatedProfile = user.copyWith(partnerId: serverPartnerId);
await Hive.box<UserProfile>('user_profile_v2')
.put('current_user', updatedProfile);
// Refresh provider state if needed, but important is to RE-SYNC with new ID
// so we get the partner's data immediately.
return _pull(); // Recursive call will now use new partnerId
}
}
final remoteEntries = syncResult['entries'] as List<CycleEntry>? ?? [];
final remotePlans =
syncResult['teachingPlans'] as List<TeachingPlan>? ?? [];
final remotePrayers =
syncResult['prayerRequests'] as List<PrayerRequest>? ?? [];
// 1. Cycle Entries
if (remoteEntries.isNotEmpty) {
final box = Hive.box<CycleEntry>('cycle_entries_v2');
// Simple merge: remote wins for id collisions
for (var entry in remoteEntries) {
await box.put(entry.id, entry);
}
_loadEntries();
}
// 2. Teaching Plans
if (remotePlans.isNotEmpty) {
final box = Hive.box<TeachingPlan>('teaching_plans_v2');
for (var plan in remotePlans) {
await box.put(plan.id, plan);
}
// Refresh provider
ref.invalidate(teachingPlansProvider);
}
// 3. Prayer Requests
if (remotePrayers.isNotEmpty) {
final box = Hive.box<PrayerRequest>('prayer_requests_v2');
for (var req in remotePrayers) {
await box.put(req.id, req);
}
// Refresh provider
ref.invalidate(prayerRequestsProvider);
}
}
// Example data generation removed
}
/// Computed provider for current cycle info

View File

@@ -6,7 +6,6 @@ import 'package:table_calendar/table_calendar.dart';
import '../../models/user_profile.dart';
import '../../models/cycle_entry.dart';
import '../../providers/user_provider.dart';
import '../../services/cycle_service.dart';
import '../../theme/app_theme.dart';
import '../log/log_screen.dart';
import 'package:uuid/uuid.dart';
@@ -37,7 +36,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
final entries = ref.watch(cycleEntriesProvider);
final user = ref.watch(userProfileProvider);
final isIrregular = user?.isIrregularCycle ?? false;
int cycleLength = user?.averageCycleLength ?? 28;
if (isIrregular) {
if (_predictionMode == PredictionMode.short) {
@@ -46,251 +45,274 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
cycleLength = user?.maxCycleLength ?? 35;
}
}
final lastPeriodStart = user?.lastPeriodStartDate;
return ProtectedContentWrapper(
title: 'Calendar',
isProtected: user?.isCalendarProtected ?? false,
userProfile: user,
child: SafeArea(
child: SingleChildScrollView(
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Row(
title: 'Calendar',
isProtected: user?.isCalendarProtected ?? false,
userProfile: user,
child: SafeArea(
child: SingleChildScrollView(
child: Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Expanded(
child: Text(
'Calendar',
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
),
),
),
_buildLegendButton(),
],
),
if (isIrregular) ...[
const SizedBox(height: 16),
_buildPredictionToggle(),
],
],
),
),
// Calendar
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: TableCalendar(
firstDay: DateTime.now().subtract(const Duration(days: 365)),
lastDay: DateTime.now().add(const Duration(days: 365)),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() => _calendarFormat = format);
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
calendarStyle: CalendarStyle(
outsideDaysVisible: false,
defaultTextStyle: GoogleFonts.outfit(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color ?? AppColors.charcoal,
),
weekendTextStyle: GoogleFonts.outfit(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color ?? AppColors.charcoal,
),
todayDecoration: BoxDecoration(
color: AppColors.sageGreen.withOpacity(0.3),
shape: BoxShape.circle,
),
todayTextStyle: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.sageGreen,
),
selectedDecoration: const BoxDecoration(
color: AppColors.sageGreen,
shape: BoxShape.circle,
),
selectedTextStyle: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleLarge?.color ?? AppColors.charcoal,
),
leftChevronIcon: Icon(
Icons.chevron_left,
color: Theme.of(context).iconTheme.color ?? AppColors.warmGray,
),
rightChevronIcon: Icon(
Icons.chevron_right,
color: Theme.of(context).iconTheme.color ?? AppColors.warmGray,
),
),
daysOfWeekStyle: DaysOfWeekStyle(
weekdayStyle: GoogleFonts.outfit(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color ?? AppColors.warmGray,
),
weekendStyle: GoogleFonts.outfit(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color ?? AppColors.warmGray,
),
),
calendarBuilders: CalendarBuilders(
defaultBuilder: (context, day, focusedDay) {
return _buildCalendarDay(day, focusedDay, entries, lastPeriodStart, cycleLength, isSelected: false, isToday: false);
},
todayBuilder: (context, day, focusedDay) {
return _buildCalendarDay(day, focusedDay, entries, lastPeriodStart, cycleLength, isToday: true);
},
selectedBuilder: (context, day, focusedDay) {
return _buildCalendarDay(day, focusedDay, entries, lastPeriodStart, cycleLength, isSelected: true);
},
markerBuilder: (context, date, events) {
final entry = _getEntryForDate(date, entries);
if (entry == null) {
final phase =
_getPhaseForDate(date, lastPeriodStart, cycleLength);
if (phase != null) {
return Positioned(
bottom: 4,
child: Container(
width: 5,
height: 5,
decoration: BoxDecoration(
color: _getPhaseColor(phase),
shape: BoxShape.circle,
),
),
);
}
return null;
}
// If we have an entry, show icons/markers
return Positioned(
bottom: 4,
child: Row(
mainAxisSize: MainAxisSize.min,
Row(
children: [
if (entry.isPeriodDay)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: const BoxDecoration(
color: AppColors.menstrualPhase,
shape: BoxShape.circle,
),
),
if (entry.mood != null ||
entry.energyLevel != 3 ||
entry.hasSymptoms)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: const BoxDecoration(
color: AppColors.softGold,
shape: BoxShape.circle,
Expanded(
child: Text(
'Calendar',
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.w600,
color: Theme.of(context)
.textTheme
.headlineLarge
?.color,
),
),
),
_buildLegendButton(),
],
),
);
},
if (isIrregular) ...[
const SizedBox(height: 16),
_buildPredictionToggle(),
],
],
),
),
),
),
const SizedBox(height: 24),
// Divider / Header for Day Info
if (_selectedDay != null) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Text(
'Daily Log',
style: GoogleFonts.outfit(
fontSize: 16,
// Calendar
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: TableCalendar(
firstDay:
DateTime.now().subtract(const Duration(days: 365)),
lastDay: DateTime.now().add(const Duration(days: 365)),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() => _calendarFormat = format);
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
calendarStyle: CalendarStyle(
outsideDaysVisible: false,
defaultTextStyle: GoogleFonts.outfit(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color ??
AppColors.charcoal,
),
weekendTextStyle: GoogleFonts.outfit(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium?.color ??
AppColors.charcoal,
),
todayDecoration: BoxDecoration(
color: AppColors.sageGreen.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
todayTextStyle: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.warmGray,
letterSpacing: 1,
color: AppColors.sageGreen,
),
selectedDecoration: const BoxDecoration(
color: AppColors.sageGreen,
shape: BoxShape.circle,
),
selectedTextStyle: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(width: 12),
const Expanded(child: Divider(color: AppColors.lightGray)),
],
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleLarge?.color ??
AppColors.charcoal,
),
leftChevronIcon: Icon(
Icons.chevron_left,
color: Theme.of(context).iconTheme.color ??
AppColors.warmGray,
),
rightChevronIcon: Icon(
Icons.chevron_right,
color: Theme.of(context).iconTheme.color ??
AppColors.warmGray,
),
),
daysOfWeekStyle: DaysOfWeekStyle(
weekdayStyle: GoogleFonts.outfit(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color ??
AppColors.warmGray,
),
weekendStyle: GoogleFonts.outfit(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color ??
AppColors.warmGray,
),
),
calendarBuilders: CalendarBuilders(
defaultBuilder: (context, day, focusedDay) {
return _buildCalendarDay(day, focusedDay, entries,
lastPeriodStart, cycleLength,
isSelected: false, isToday: false);
},
todayBuilder: (context, day, focusedDay) {
return _buildCalendarDay(day, focusedDay, entries,
lastPeriodStart, cycleLength,
isToday: true);
},
selectedBuilder: (context, day, focusedDay) {
return _buildCalendarDay(day, focusedDay, entries,
lastPeriodStart, cycleLength,
isSelected: true);
},
markerBuilder: (context, date, events) {
final entry = _getEntryForDate(date, entries);
if (entry == null) {
final phase = _getPhaseForDate(
date, lastPeriodStart, cycleLength);
if (phase != null) {
return Positioned(
bottom: 4,
child: Container(
width: 5,
height: 5,
decoration: BoxDecoration(
color: _getPhaseColor(phase),
shape: BoxShape.circle,
),
),
);
}
return null;
}
// If we have an entry, show icons/markers
return Positioned(
bottom: 4,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (entry.isPeriodDay)
Container(
width: 6,
height: 6,
margin:
const EdgeInsets.symmetric(horizontal: 1),
decoration: const BoxDecoration(
color: AppColors.menstrualPhase,
shape: BoxShape.circle,
),
),
if (entry.mood != null ||
entry.energyLevel != 3 ||
entry.hasSymptoms)
Container(
width: 6,
height: 6,
margin:
const EdgeInsets.symmetric(horizontal: 1),
decoration: const BoxDecoration(
color: AppColors.softGold,
shape: BoxShape.circle,
),
),
],
),
);
},
),
),
),
),
const SizedBox(height: 12),
// Day Info (No longer Expanded)
_buildDayInfo(
_selectedDay!, lastPeriodStart, cycleLength, entries, user),
const SizedBox(height: 40), // Bottom padding
],
],
),
),
));
const SizedBox(height: 24),
// Divider / Header for Day Info
if (_selectedDay != null) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Text(
'Daily Log',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.warmGray,
letterSpacing: 1,
),
),
const SizedBox(width: 12),
const Expanded(
child: Divider(color: AppColors.lightGray)),
],
),
),
const SizedBox(height: 12),
// Day Info (No longer Expanded)
_buildDayInfo(_selectedDay!, lastPeriodStart, cycleLength,
entries, user),
const SizedBox(height: 40), // Bottom padding
],
],
),
),
));
}
Widget _buildPredictionToggle() {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.lightGray.withOpacity(0.5),
color: AppColors.lightGray.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_buildToggleItem(PredictionMode.short, 'Short (-)', AppColors.menstrualPhase),
_buildToggleItem(PredictionMode.regular, 'Regular', AppColors.sageGreen),
_buildToggleItem(PredictionMode.long, 'Long (+)', AppColors.lutealPhase),
_buildToggleItem(
PredictionMode.short, 'Short (-)', AppColors.menstrualPhase),
_buildToggleItem(
PredictionMode.regular, 'Regular', AppColors.sageGreen),
_buildToggleItem(
PredictionMode.long, 'Long (+)', AppColors.lutealPhase),
],
),
);
@@ -304,12 +326,16 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.transparent,
color: isSelected
? (Theme.of(context).brightness == Brightness.dark
? const Color(0xFF333333)
: Colors.white)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: isSelected
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, 2),
)
@@ -336,12 +362,12 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.blushPink.withOpacity(0.5),
color: AppColors.blushPink.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 16, color: AppColors.rose),
const Icon(Icons.info_outline, size: 16, color: AppColors.rose),
const SizedBox(width: 4),
Text(
'Legend',
@@ -374,7 +400,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 20),
@@ -407,7 +433,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
label,
style: GoogleFonts.outfit(
fontSize: 14,
color: AppColors.charcoal,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -415,8 +441,8 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
);
}
Widget _buildDayInfo(DateTime date, DateTime? lastPeriodStart, int cycleLength,
List<CycleEntry> entries, UserProfile? user) {
Widget _buildDayInfo(DateTime date, DateTime? lastPeriodStart,
int cycleLength, List<CycleEntry> entries, UserProfile? user) {
final phase = _getPhaseForDate(date, lastPeriodStart, cycleLength);
final entry = _getEntryForDate(date, entries);
@@ -428,7 +454,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -459,7 +485,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _getPhaseColor(phase).withOpacity(0.15),
color: _getPhaseColor(phase).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Row(
@@ -487,17 +513,18 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
.bodyMedium
?.copyWith(color: AppColors.warmGray),
),
if (user?.isPadTrackingEnabled == true &&
phase != CyclePhase.menstrual &&
(user?.padSupplies?.any((s) => s.type == PadType.pantyLiner) ?? false)) ...[
if (user?.isPadTrackingEnabled == true &&
phase != CyclePhase.menstrual &&
(user?.padSupplies?.any((s) => s.type == PadType.pantyLiner) ??
false)) ...[
const SizedBox(height: 16),
_buildPantylinerPrompt(date, null),
],
] else ...[
// Period Detail
if (entry.isPeriodDay)
_buildDetailRow(Icons.water_drop, 'Period Day',
AppColors.menstrualPhase,
_buildDetailRow(
Icons.water_drop, 'Period Day', AppColors.menstrualPhase,
value: entry.flowIntensity?.label),
// Mood Detail
@@ -524,11 +551,11 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
if (user?.isPadTrackingEnabled == true) ...[
const SizedBox(height: 16),
if (entry.usedPantyliner)
_buildDetailRow(Icons.layers_outlined, 'Supplies Used', AppColors.menstrualPhase,
_buildDetailRow(Icons.layers_outlined, 'Supplies Used',
AppColors.menstrualPhase,
value: '${entry.pantylinerCount}'),
if (!entry.usedPantyliner && !entry.isPeriodDay)
_buildPantylinerPrompt(date, entry),
_buildPantylinerPrompt(date, entry),
],
// Notes
@@ -544,16 +571,15 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
fontWeight: FontWeight.w600,
color: AppColors.warmGray)),
const SizedBox(height: 4),
Text(entry.notes!,
style: GoogleFonts.outfit(fontSize: 14)),
Text(entry.notes!, style: GoogleFonts.outfit(fontSize: 14)),
],
),
),
],
if (user?.isPadTrackingEnabled == true) ...[
const SizedBox(height: 16),
_buildManualSupplyEntryButton(date),
const SizedBox(height: 16),
_buildManualSupplyEntryButton(date),
],
const SizedBox(height: 24),
@@ -581,17 +607,17 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
? Icons.edit_note
: Icons.add_circle_outline),
label: Text(entry != null ? 'Edit Log' : 'Add Log'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.sageGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.sageGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
),
),
),
],
),
],
),
],
),
);
@@ -602,39 +628,45 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.menstrualPhase.withOpacity(0.05),
color: AppColors.menstrualPhase.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.menstrualPhase.withOpacity(0.2)),
border:
Border.all(color: AppColors.menstrualPhase.withValues(alpha: 0.2)),
),
child: Row(
children: [
const Icon(Icons.help_outline, color: AppColors.menstrualPhase, size: 20),
const Icon(Icons.help_outline,
color: AppColors.menstrualPhase, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'Did you use pantyliners today?',
style: GoogleFonts.outfit(fontSize: 14, color: AppColors.charcoal),
style: GoogleFonts.outfit(
fontSize: 14, color: Theme.of(context).colorScheme.onSurface),
),
),
TextButton(
onPressed: () {
if (entry != null) {
ref.read(cycleEntriesProvider.notifier).updateEntry(
entry.copyWith(usedPantyliner: true, pantylinerCount: 1),
);
} else {
final newEntry = CycleEntry(
id: const Uuid().v4(),
date: date,
usedPantyliner: true,
pantylinerCount: 1,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
ref.read(cycleEntriesProvider.notifier).addEntry(newEntry);
}
if (entry != null) {
ref.read(cycleEntriesProvider.notifier).updateEntry(
entry.copyWith(usedPantyliner: true, pantylinerCount: 1),
);
} else {
final newEntry = CycleEntry(
id: const Uuid().v4(),
date: date,
usedPantyliner: true,
pantylinerCount: 1,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
ref.read(cycleEntriesProvider.notifier).addEntry(newEntry);
}
},
child: Text('Yes', style: GoogleFonts.outfit(color: AppColors.menstrualPhase, fontWeight: FontWeight.bold)),
child: Text('Yes',
style: GoogleFonts.outfit(
color: AppColors.menstrualPhase,
fontWeight: FontWeight.bold)),
),
],
),
@@ -656,7 +688,8 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.menstrualPhase,
side: const BorderSide(color: AppColors.menstrualPhase),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
);
@@ -674,9 +707,9 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
margin: const EdgeInsets.only(top: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.softGold.withOpacity(isDark ? 0.15 : 0.1),
color: AppColors.softGold.withValues(alpha: isDark ? 0.15 : 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.softGold.withOpacity(0.3)),
border: Border.all(color: AppColors.softGold.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -729,7 +762,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 18),
@@ -762,13 +795,24 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
String _getSymptomsString(CycleEntry entry) {
List<String> s = [];
if (entry.crampIntensity != null && entry.crampIntensity! > 0)
if (entry.crampIntensity != null && entry.crampIntensity! > 0) {
s.add('Cramps (${entry.crampIntensity}/5)');
if (entry.hasHeadache) s.add('Headache');
if (entry.hasBloating) s.add('Bloating');
if (entry.hasBreastTenderness) s.add('Breast Tenderness');
if (entry.hasFatigue) s.add('Fatigue');
if (entry.hasAcne) s.add('Acne');
}
if (entry.hasHeadache) {
s.add('Headache');
}
if (entry.hasBloating) {
s.add('Bloating');
}
if (entry.hasBreastTenderness) {
s.add('Breast Tenderness');
}
if (entry.hasFatigue) {
s.add('Fatigue');
}
if (entry.hasAcne) {
s.add('Acne');
}
return s.join(', ');
}
@@ -827,21 +871,12 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
return months[month - 1];
}
bool _isLoggedPeriodDay(DateTime date, List<CycleEntry> entries) {
final entry = _getEntryForDate(date, entries);
return entry?.isPeriodDay ?? false;
}
Widget _buildCalendarDay(
DateTime day,
DateTime focusedDay,
List<CycleEntry> entries,
DateTime? lastPeriodStart,
int cycleLength,
{bool isSelected = false, bool isToday = false, bool isWeekend = false}) {
Widget _buildCalendarDay(DateTime day, DateTime focusedDay,
List<CycleEntry> entries, DateTime? lastPeriodStart, int cycleLength,
{bool isSelected = false, bool isToday = false}) {
final phase = _getPhaseForDate(day, lastPeriodStart, cycleLength);
final isDark = Theme.of(context).brightness == Brightness.dark;
// Determine the Day of Cycle
int? doc;
if (lastPeriodStart != null) {
@@ -863,12 +898,12 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
);
} else if (isToday) {
decoration = BoxDecoration(
color: AppColors.sageGreen.withOpacity(0.2),
color: AppColors.sageGreen.withValues(alpha: 0.2),
shape: BoxShape.circle,
);
} else if (phase != null) {
decoration = BoxDecoration(
color: _getPhaseColor(phase).withOpacity(isDark ? 0.2 : 0.15),
color: _getPhaseColor(phase).withValues(alpha: isDark ? 0.2 : 0.15),
shape: BoxShape.circle,
);
}
@@ -876,14 +911,22 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen> {
// Text style
TextStyle textStyle = GoogleFonts.outfit(
fontSize: (isOvulationDay || isPeriodStart) ? 18 : 14,
fontWeight: (isOvulationDay || isPeriodStart) ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : (isToday ? AppColors.sageGreen : (Theme.of(context).textTheme.bodyMedium?.color)),
fontWeight: (isOvulationDay || isPeriodStart)
? FontWeight.bold
: FontWeight.normal,
color: isSelected
? Colors.white
: (isToday
? AppColors.sageGreen
: (Theme.of(context).textTheme.bodyMedium?.color)),
);
if (isOvulationDay) {
textStyle = textStyle.copyWith(color: isSelected ? Colors.white : AppColors.ovulationPhase);
textStyle = textStyle.copyWith(
color: isSelected ? Colors.white : AppColors.ovulationPhase);
} else if (isPeriodStart) {
textStyle = textStyle.copyWith(color: isSelected ? Colors.white : AppColors.menstrualPhase);
textStyle = textStyle.copyWith(
color: isSelected ? Colors.white : AppColors.menstrualPhase);
}
return Container(

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../models/scripture.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/user_provider.dart';
import '../../services/cycle_service.dart';
@@ -10,6 +9,8 @@ import '../../widgets/scripture_card.dart';
import '../../models/user_profile.dart';
import '../../models/teaching_plan.dart';
import '../../providers/scripture_provider.dart'; // Import the new provider
import '../prayer/prayer_request_screen.dart';
import '../settings/sharing_settings_screen.dart';
class DevotionalScreen extends ConsumerStatefulWidget {
const DevotionalScreen({super.key});
@@ -54,7 +55,7 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
...BibleTranslation.values.map((t) => ListTile(
title: Text(t.label),
trailing: user.bibleTranslation == t
? Icon(Icons.check, color: AppColors.sageGreen)
? const Icon(Icons.check, color: AppColors.sageGreen)
: null,
onTap: () => Navigator.pop(context, t),
)),
@@ -73,7 +74,8 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
@override
Widget build(BuildContext context) {
// Listen for changes in the cycle info to re-initialize scripture if needed
ref.listen<CycleInfo>(currentCycleInfoProvider, (previousCycleInfo, newCycleInfo) {
ref.listen<CycleInfo>(currentCycleInfoProvider,
(previousCycleInfo, newCycleInfo) {
if (previousCycleInfo?.phase != newCycleInfo.phase) {
_initializeScripture();
}
@@ -91,7 +93,8 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
final maxIndex = scriptureState.maxIndex;
if (scripture == null) {
return const Center(child: CircularProgressIndicator()); // Or some error message
return const Center(
child: CircularProgressIndicator()); // Or some error message
}
return SafeArea(
@@ -117,7 +120,7 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getPhaseColor(phase).withOpacity(0.15),
color: _getPhaseColor(phase).withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Row(
@@ -142,7 +145,7 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
phase.description,
style: GoogleFonts.outfit(
fontSize: 14,
color: AppColors.warmGray,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
@@ -165,19 +168,21 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
Positioned(
left: 0,
child: IconButton(
icon: Icon(Icons.arrow_back_ios),
onPressed: () =>
ref.read(scriptureProvider.notifier).getPreviousScripture(),
color: AppColors.charcoal,
icon: const Icon(Icons.arrow_back_ios),
onPressed: () => ref
.read(scriptureProvider.notifier)
.getPreviousScripture(),
color: Theme.of(context).colorScheme.onSurface,
),
),
Positioned(
right: 0,
child: IconButton(
icon: Icon(Icons.arrow_forward_ios),
onPressed: () =>
ref.read(scriptureProvider.notifier).getNextScripture(),
color: AppColors.charcoal,
icon: const Icon(Icons.arrow_forward_ios),
onPressed: () => ref
.read(scriptureProvider.notifier)
.getNextScripture(),
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -185,16 +190,17 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
),
const SizedBox(height: 16),
if (maxIndex != null && maxIndex > 1)
Center(
child: TextButton.icon(
onPressed: () => ref.read(scriptureProvider.notifier).getRandomScripture(),
icon: const Icon(Icons.shuffle),
label: const Text('Random Verse'),
style: TextButton.styleFrom(
foregroundColor: AppColors.sageGreen,
Center(
child: TextButton.icon(
onPressed: () =>
ref.read(scriptureProvider.notifier).getRandomScripture(),
icon: const Icon(Icons.shuffle),
label: const Text('Random Verse'),
style: TextButton.styleFrom(
foregroundColor: AppColors.sageGreen,
),
),
),
),
const SizedBox(height: 24),
// Reflection
@@ -207,7 +213,7 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -216,20 +222,20 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
const Row(
children: [
Icon(
Icons.lightbulb_outline,
color: AppColors.softGold,
size: 20,
),
const SizedBox(width: 8),
SizedBox(width: 8),
Text(
'Reflection',
style: GoogleFonts.outfit(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleLarge?.color,
color: AppColors.charcoal,
),
),
],
@@ -258,7 +264,7 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -269,7 +275,7 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
children: [
Row(
children: [
Icon(
const Icon(
Icons.favorite_outline,
color: AppColors.rose,
size: 20,
@@ -306,8 +312,8 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.lavender.withOpacity(isDark ? 0.35 : 0.2),
AppColors.blushPink.withOpacity(isDark ? 0.35 : 0.2),
AppColors.lavender.withValues(alpha: isDark ? 0.35 : 0.2),
AppColors.blushPink.withValues(alpha: isDark ? 0.35 : 0.2),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -319,14 +325,14 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
children: [
Row(
children: [
Text('🙏', style: TextStyle(fontSize: 20)),
const Text('🙏', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Text(
'Prayer Prompt',
style: GoogleFonts.outfit(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleLarge?.color,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -351,8 +357,8 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
if (user.teachingPlans?.isNotEmpty ?? false)
_buildTeachingPlanCard(context, user.teachingPlans!)
else
_buildSampleTeachingCard(context),
_buildSampleTeachingCard(context),
const SizedBox(height: 24),
// Action buttons
@@ -368,9 +374,16 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.edit_note),
label: const Text('Journal'),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PrayerRequestScreen(),
),
);
},
icon: const Icon(Icons.spa_outlined),
label: const Text('Prayer Requests'),
),
),
],
@@ -434,32 +447,34 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
'Help me to serve with joy and purpose. Amen."';
case CyclePhase.ovulation:
return '"Creator God, I am fearfully and wonderfully made. '
'Thank You for the gift of womanhood. '
'Help me to honor You in all I do today. Amen."';
'Thank You for the gift of womanhood. '
'Help me to honor You in all I do today. Amen."';
case CyclePhase.luteal:
return '"Lord, I bring my anxious thoughts to You. '
'When my emotions feel overwhelming, remind me of Your peace. '
'Help me to be gentle with myself as You are gentle with me. Amen."';
'When my emotions feel overwhelming, remind me of Your peace. '
'Help me to be gentle with myself as You are gentle with me. Amen."';
}
}
Widget _buildTeachingPlanCard(BuildContext context, List<TeachingPlan> plans) {
Widget _buildTeachingPlanCard(
BuildContext context, List<TeachingPlan> plans) {
// Get latest uncompleted plan or just latest
if (plans.isEmpty) return const SizedBox.shrink();
// Sort by date desc
final sorted = List<TeachingPlan>.from(plans)..sort((a,b) => b.date.compareTo(a.date));
final sorted = List<TeachingPlan>.from(plans)
..sort((a, b) => b.date.compareTo(a.date));
final latestPlan = sorted.first;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.gold.withOpacity(0.5)),
border: Border.all(color: AppColors.gold.withValues(alpha: 0.5)),
boxShadow: [
BoxShadow(
color: AppColors.gold.withOpacity(0.1),
color: AppColors.gold.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -470,13 +485,13 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
children: [
Row(
children: [
Icon(Icons.menu_book, color: AppColors.navyBlue),
const Icon(Icons.menu_book, color: AppColors.navyBlue),
const SizedBox(width: 8),
Expanded(
child: Text(
'Leading in the Word',
style: GoogleFonts.outfit(
fontSize: 16,
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.navyBlue,
),
@@ -485,12 +500,15 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.gold.withOpacity(0.1),
color: AppColors.gold.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Husband\'s Sharing',
style: GoogleFonts.outfit(fontSize: 10, color: AppColors.gold, fontWeight: FontWeight.bold),
style: GoogleFonts.outfit(
fontSize: 10,
color: AppColors.gold,
fontWeight: FontWeight.bold),
),
),
],
@@ -501,7 +519,7 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
color: Theme.of(context).colorScheme.onSurface,
),
),
if (latestPlan.scriptureReference.isNotEmpty) ...[
@@ -521,7 +539,7 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
style: GoogleFonts.lora(
fontSize: 15,
height: 1.5,
color: AppColors.charcoal.withOpacity(0.9),
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
],
@@ -534,12 +552,14 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.warmGray.withOpacity(0.3), style: BorderStyle.solid),
border: Border.all(
color: AppColors.warmGray.withValues(alpha: 0.3),
style: BorderStyle.solid),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -550,13 +570,13 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
children: [
Row(
children: [
Icon(Icons.menu_book, color: AppColors.warmGray),
const Icon(Icons.menu_book, color: AppColors.warmGray),
const SizedBox(width: 12),
Expanded(
child: Text(
'Leading in the Word',
style: GoogleFonts.outfit(
fontSize: 16,
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.warmGray,
),
@@ -565,12 +585,15 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.warmGray.withOpacity(0.1),
color: AppColors.warmGray.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Sample',
style: GoogleFonts.outfit(fontSize: 10, color: AppColors.warmGray, fontWeight: FontWeight.bold),
style: GoogleFonts.outfit(
fontSize: 10,
color: AppColors.warmGray,
fontWeight: FontWeight.bold),
),
),
],
@@ -581,7 +604,10 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.charcoal.withOpacity(0.7),
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.7),
),
),
const SizedBox(height: 4),
@@ -600,13 +626,20 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
fontSize: 15,
height: 1.5,
fontStyle: FontStyle.italic,
color: AppColors.charcoal.withOpacity(0.6),
color: AppColors.charcoal.withValues(alpha: 0.6),
),
),
const SizedBox(height: 16),
Center(
Center(
child: OutlinedButton.icon(
onPressed: () => _showShareDialog(context),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SharingSettingsScreen(),
),
);
},
icon: const Icon(Icons.link, size: 18),
label: const Text('Connect with Husband'),
style: OutlinedButton.styleFrom(
@@ -614,68 +647,6 @@ class _DevotionalScreenState extends ConsumerState<DevotionalScreen> {
side: const BorderSide(color: AppColors.navyBlue),
),
),
),
],
),
);
}
void _showShareDialog(BuildContext context) {
// Generate a simple pairing code (in a real app, this would be stored/validated)
final userProfile = ref.read(userProfileProvider);
final pairingCode = userProfile?.id?.substring(0, 6).toUpperCase() ?? 'ABC123';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.share_outlined, color: AppColors.navyBlue),
const SizedBox(width: 8),
const Text('Share with Husband'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Share this code with your husband so he can connect to your cycle data:',
style: GoogleFonts.outfit(fontSize: 14, color: AppColors.warmGray),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: AppColors.navyBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.navyBlue.withOpacity(0.3)),
),
child: SelectableText(
pairingCode,
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 4,
color: AppColors.navyBlue,
),
),
),
const SizedBox(height: 16),
Text(
'He can enter this in his app under Settings > Connect with Wife.',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
textAlign: TextAlign.center,
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.navyBlue,
foregroundColor: Colors.white,
),
child: const Text('Done'),
),
],
),

View File

@@ -3,8 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_theme.dart';
import '../../models/user_profile.dart';
import '../../models/cycle_entry.dart';
import '../../models/scripture.dart';
import '../calendar/calendar_screen.dart';
import '../log/log_screen.dart';
import '../log/pad_tracker_screen.dart';
@@ -12,15 +11,15 @@ import '../devotional/devotional_screen.dart';
import '../settings/appearance_screen.dart';
import '../settings/cycle_settings_screen.dart';
import '../settings/relationship_settings_screen.dart';
import '../settings/goal_settings_screen.dart';
import '../settings/goal_settings_screen.dart';
import '../settings/cycle_history_screen.dart';
import '../settings/sharing_settings_screen.dart';
import '../settings/notification_settings_screen.dart';
import '../settings/privacy_settings_screen.dart';
import '../settings/supplies_settings_screen.dart';
import '../settings/export_data_screen.dart';
import '../learn/wife_learn_screen.dart';
import '../../widgets/tip_card.dart';
import '../learn/wife_learn_screen.dart';
import '../../widgets/cycle_ring.dart';
import '../../widgets/scripture_card.dart';
import '../../widgets/pad_tracker_card.dart';
@@ -37,7 +36,8 @@ class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedIndex = ref.watch(navigationProvider);
final isPadTrackingEnabled = ref.watch(userProfileProvider.select((u) => u?.isPadTrackingEnabled ?? false));
final isPadTrackingEnabled = ref.watch(
userProfileProvider.select((u) => u?.isPadTrackingEnabled ?? false));
final List<Widget> tabs;
final List<BottomNavigationBarItem> navBarItems;
@@ -50,16 +50,38 @@ class HomeScreen extends ConsumerWidget {
const LogScreen(),
const DevotionalScreen(),
const WifeLearnScreen(),
_SettingsTab(onReset: () => ref.read(navigationProvider.notifier).setIndex(0)),
_SettingsTab(
onReset: () => ref.read(navigationProvider.notifier).setIndex(0)),
];
navBarItems = [
const BottomNavigationBarItem(icon: Icon(Icons.home_outlined), activeIcon: Icon(Icons.home), label: 'Home'),
const BottomNavigationBarItem(icon: Icon(Icons.calendar_today_outlined), activeIcon: Icon(Icons.calendar_today), label: 'Calendar'),
const BottomNavigationBarItem(icon: Icon(Icons.inventory_2_outlined), activeIcon: Icon(Icons.inventory_2), label: 'Supplies'),
const BottomNavigationBarItem(icon: Icon(Icons.add_circle_outline), activeIcon: Icon(Icons.add_circle), label: 'Log'),
const BottomNavigationBarItem(icon: Icon(Icons.menu_book_outlined), activeIcon: Icon(Icons.menu_book), label: 'Devotional'),
const BottomNavigationBarItem(icon: Icon(Icons.school_outlined), activeIcon: Icon(Icons.school), label: 'Learn'),
const BottomNavigationBarItem(icon: Icon(Icons.settings_outlined), activeIcon: Icon(Icons.settings), label: 'Settings'),
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home'),
const BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
activeIcon: Icon(Icons.calendar_today),
label: 'Calendar'),
const BottomNavigationBarItem(
icon: Icon(Icons.inventory_2_outlined),
activeIcon: Icon(Icons.inventory_2),
label: 'Supplies'),
const BottomNavigationBarItem(
icon: Icon(Icons.add_circle_outline),
activeIcon: Icon(Icons.add_circle),
label: 'Log'),
const BottomNavigationBarItem(
icon: Icon(Icons.menu_book_outlined),
activeIcon: Icon(Icons.menu_book),
label: 'Devotional'),
const BottomNavigationBarItem(
icon: Icon(Icons.school_outlined),
activeIcon: Icon(Icons.school),
label: 'Learn'),
const BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Settings'),
];
} else {
tabs = [
@@ -68,15 +90,34 @@ class HomeScreen extends ConsumerWidget {
const DevotionalScreen(),
const LogScreen(),
const WifeLearnScreen(),
_SettingsTab(onReset: () => ref.read(navigationProvider.notifier).setIndex(0)),
_SettingsTab(
onReset: () => ref.read(navigationProvider.notifier).setIndex(0)),
];
navBarItems = [
const BottomNavigationBarItem(icon: Icon(Icons.home_outlined), activeIcon: Icon(Icons.home), label: 'Home'),
const BottomNavigationBarItem(icon: Icon(Icons.calendar_today_outlined), activeIcon: Icon(Icons.calendar_today), label: 'Calendar'),
const BottomNavigationBarItem(icon: Icon(Icons.menu_book_outlined), activeIcon: Icon(Icons.menu_book), label: 'Devotional'),
const BottomNavigationBarItem(icon: Icon(Icons.add_circle_outline), activeIcon: Icon(Icons.add_circle), label: 'Log'),
const BottomNavigationBarItem(icon: Icon(Icons.school_outlined), activeIcon: Icon(Icons.school), label: 'Learn'),
const BottomNavigationBarItem(icon: Icon(Icons.settings_outlined), activeIcon: Icon(Icons.settings), label: 'Settings'),
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Home'),
const BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
activeIcon: Icon(Icons.calendar_today),
label: 'Calendar'),
const BottomNavigationBarItem(
icon: Icon(Icons.menu_book_outlined),
activeIcon: Icon(Icons.menu_book),
label: 'Devotional'),
const BottomNavigationBarItem(
icon: Icon(Icons.add_circle_outline),
activeIcon: Icon(Icons.add_circle),
label: 'Log'),
const BottomNavigationBarItem(
icon: Icon(Icons.school_outlined),
activeIcon: Icon(Icons.school),
label: 'Learn'),
const BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Settings'),
];
}
@@ -93,7 +134,7 @@ class HomeScreen extends ConsumerWidget {
color: (Theme.of(context).brightness == Brightness.dark
? Colors.black
: AppColors.charcoal)
.withOpacity(0.1),
.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
@@ -111,7 +152,7 @@ class HomeScreen extends ConsumerWidget {
}
class _DashboardTab extends ConsumerStatefulWidget {
const _DashboardTab({super.key});
const _DashboardTab();
@override
ConsumerState<_DashboardTab> createState() => _DashboardTabState();
@@ -134,7 +175,8 @@ class _DashboardTabState extends ConsumerState<_DashboardTab> {
@override
Widget build(BuildContext context) {
// Listen for changes in the cycle info to re-initialize scripture if needed
ref.listen<CycleInfo>(currentCycleInfoProvider, (previousCycleInfo, newCycleInfo) {
ref.listen<CycleInfo>(currentCycleInfoProvider,
(previousCycleInfo, newCycleInfo) {
if (previousCycleInfo?.phase != newCycleInfo.phase) {
_initializeScripture();
}
@@ -145,10 +187,8 @@ class _DashboardTabState extends ConsumerState<_DashboardTab> {
final translation =
ref.watch(userProfileProvider.select((u) => u?.bibleTranslation)) ??
BibleTranslation.esv;
final role = ref.watch(userProfileProvider.select((u) => u?.role)) ??
UserRole.wife;
final isMarried =
ref.watch(userProfileProvider.select((u) => u?.isMarried)) ?? false;
final role =
ref.watch(userProfileProvider.select((u) => u?.role)) ?? UserRole.wife;
final averageCycleLength =
ref.watch(userProfileProvider.select((u) => u?.averageCycleLength)) ??
28;
@@ -163,7 +203,8 @@ class _DashboardTabState extends ConsumerState<_DashboardTab> {
final maxIndex = scriptureState.maxIndex;
if (scripture == null) {
return const Center(child: CircularProgressIndicator()); // Or some error message
return const Center(
child: CircularProgressIndicator()); // Or some error message
}
return SafeArea(
@@ -181,10 +222,8 @@ class _DashboardTabState extends ConsumerState<_DashboardTab> {
phase: phase,
),
),
if (phase == CyclePhase.menstrual) ...[
const SizedBox(height: 24),
const PadTrackerCard(),
],
const SizedBox(height: 24),
const PadTrackerCard(),
const SizedBox(height: 32),
// Main Scripture Card with Navigation
Stack(
@@ -202,19 +241,21 @@ class _DashboardTabState extends ConsumerState<_DashboardTab> {
Positioned(
left: 0,
child: IconButton(
icon: Icon(Icons.arrow_back_ios),
onPressed: () =>
ref.read(scriptureProvider.notifier).getPreviousScripture(),
color: AppColors.charcoal,
icon: const Icon(Icons.arrow_back_ios),
onPressed: () => ref
.read(scriptureProvider.notifier)
.getPreviousScripture(),
color: Theme.of(context).colorScheme.onSurface,
),
),
Positioned(
right: 0,
child: IconButton(
icon: Icon(Icons.arrow_forward_ios),
onPressed: () =>
ref.read(scriptureProvider.notifier).getNextScripture(),
color: AppColors.charcoal,
icon: const Icon(Icons.arrow_forward_ios),
onPressed: () => ref
.read(scriptureProvider.notifier)
.getNextScripture(),
color: Theme.of(context).colorScheme.onSurface,
),
),
],
@@ -222,16 +263,17 @@ class _DashboardTabState extends ConsumerState<_DashboardTab> {
),
const SizedBox(height: 16),
if (maxIndex != null && maxIndex > 1)
Center(
child: TextButton.icon(
onPressed: () => ref.read(scriptureProvider.notifier).getRandomScripture(),
icon: const Icon(Icons.shuffle),
label: const Text('Random Verse'),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
Center(
child: TextButton.icon(
onPressed: () =>
ref.read(scriptureProvider.notifier).getRandomScripture(),
icon: const Icon(Icons.shuffle),
label: const Text('Random Verse'),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
),
),
),
const SizedBox(height: 24),
Text(
'Quick Log',
@@ -243,8 +285,7 @@ class _DashboardTabState extends ConsumerState<_DashboardTab> {
const SizedBox(height: 12),
const QuickLogButtons(),
const SizedBox(height: 24),
if (role == UserRole.wife)
_buildWifeTipsSection(context),
if (role == UserRole.wife) _buildWifeTipsSection(context),
const SizedBox(height: 20),
],
),
@@ -292,7 +333,7 @@ class _DashboardTabState extends ConsumerState<_DashboardTab> {
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withOpacity(0.5),
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
@@ -313,7 +354,8 @@ class _SettingsTab extends ConsumerWidget {
{VoidCallback? onTap}) {
return ListTile(
leading: Icon(icon,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8)),
color:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.8)),
title: Text(
title,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
@@ -364,18 +406,18 @@ class _SettingsTab extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final name =
ref.watch(userProfileProvider.select((u) => u?.name)) ?? 'Guest';
final roleSymbol =
ref.watch(userProfileProvider.select((u) => u?.role)) ==
UserRole.husband
? 'HUSBAND'
: null;
final roleSymbol = ref.watch(userProfileProvider.select((u) => u?.role)) ==
UserRole.husband
? 'HUSBAND'
: null;
final relationshipStatus = ref.watch(userProfileProvider
.select((u) => u?.relationshipStatus.name.toUpperCase())) ??
'SINGLE';
final translationLabel =
ref.watch(userProfileProvider.select((u) => u?.bibleTranslation.label)) ??
'ESV';
final isSingle = ref.watch(userProfileProvider.select((u) => u?.relationshipStatus == RelationshipStatus.single));
final translationLabel = ref.watch(
userProfileProvider.select((u) => u?.bibleTranslation.label)) ??
'ESV';
final isSingle = ref.watch(userProfileProvider
.select((u) => u?.relationshipStatus == RelationshipStatus.single));
return SafeArea(
child: SingleChildScrollView(
@@ -398,8 +440,10 @@ class _SettingsTab extends ConsumerWidget {
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
Theme.of(context).colorScheme.outline.withOpacity(0.05)),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.05)),
),
child: Row(
children: [
@@ -409,8 +453,14 @@ class _SettingsTab extends ConsumerWidget {
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary.withOpacity(0.7),
Theme.of(context).colorScheme.secondary.withOpacity(0.7)
Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7),
Theme.of(context)
.colorScheme
.secondary
.withValues(alpha: 0.7)
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -459,14 +509,15 @@ class _SettingsTab extends ConsumerWidget {
const SizedBox(height: 24),
_buildSettingsGroup(context, 'Preferences', [
_buildSettingsTile(
context,
Icons.notifications_outlined,
context,
Icons.notifications_outlined,
'Notifications',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const NotificationSettingsScreen()));
builder: (context) =>
const NotificationSettingsScreen()));
},
),
_buildSettingsTile(
@@ -477,7 +528,8 @@ class _SettingsTab extends ConsumerWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SuppliesSettingsScreen()));
builder: (context) =>
const SuppliesSettingsScreen()));
},
),
_buildSettingsTile(
@@ -488,12 +540,13 @@ class _SettingsTab extends ConsumerWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RelationshipSettingsScreen()));
builder: (context) =>
const RelationshipSettingsScreen()));
},
),
_buildSettingsTile(
context,
Icons.flag_outlined,
Icons.flag_outlined,
'Cycle Goal',
onTap: () {
Navigator.push(
@@ -521,8 +574,7 @@ class _SettingsTab extends ConsumerWidget {
'My Favorites',
onTap: () => _showFavoritesDialog(context, ref),
),
_buildSettingsTile(
context, Icons.security, 'Privacy & Security',
_buildSettingsTile(context, Icons.security, 'Privacy & Security',
onTap: () {
Navigator.push(
context,
@@ -559,16 +611,34 @@ class _SettingsTab extends ConsumerWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CycleHistoryScreen()));
builder: (context) => const CycleHistoryScreen()));
}),
_buildSettingsTile(
context, Icons.download_outlined, 'Export Data',
onTap: () {
context, Icons.download_outlined, 'Export Data', onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ExportDataScreen()));
}),
_buildSettingsTile(
context,
Icons.sync,
'Sync Data',
onTap: () async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Syncing data...')),
);
await ref.read(cycleEntriesProvider.notifier).syncData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Sync complete'),
backgroundColor: Colors.green,
),
);
}
},
),
]),
const SizedBox(height: 16),
_buildSettingsGroup(context, 'Account', [
@@ -602,7 +672,10 @@ class _SettingsTab extends ConsumerWidget {
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.05)),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.05)),
),
child: Column(
children: tiles,
@@ -629,7 +702,9 @@ class _SettingsTab extends ConsumerWidget {
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Unlock'),
@@ -648,7 +723,8 @@ class _SettingsTab extends ConsumerWidget {
final granted = await _authenticate(context, userProfile.privacyPin!);
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Incorrect PIN')));
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Incorrect PIN')));
}
return;
}
@@ -663,14 +739,16 @@ class _SettingsTab extends ConsumerWidget {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('My Favorites', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
title: Text('My Favorites',
style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'List your favorite comfort foods, snacks, or flowers so your husband knows what to get you!',
style: GoogleFonts.outfit(fontSize: 13, color: AppColors.warmGray),
style:
GoogleFonts.outfit(fontSize: 13, color: AppColors.warmGray),
),
const SizedBox(height: 16),
TextField(
@@ -696,8 +774,11 @@ class _SettingsTab extends ConsumerWidget {
.where((e) => e.isNotEmpty)
.toList();
final updatedProfile = userProfile.copyWith(favoriteFoods: favorites);
ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
final updatedProfile =
userProfile.copyWith(favoriteFoods: favorites);
ref
.read(userProfileProvider.notifier)
.updateProfile(updatedProfile);
Navigator.pop(context);
},
child: const Text('Save'),
@@ -706,68 +787,6 @@ class _SettingsTab extends ConsumerWidget {
),
);
}
void _showShareDialog(BuildContext context, WidgetRef ref) {
// Generate a simple pairing code (in a real app, this would be stored/validated)
final userProfile = ref.read(userProfileProvider);
final pairingCode = userProfile?.id?.substring(0, 6).toUpperCase() ?? 'ABC123';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.share_outlined, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
const Text('Share with Husband'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Share this code with your husband so he can connect to your cycle data:',
style: GoogleFonts.outfit(fontSize: 14, color: AppColors.warmGray),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3)),
),
child: SelectableText(
pairingCode,
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 4,
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 16),
Text(
'He can enter this in his app under Settings > Connect with Wife.',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
textAlign: TextAlign.center,
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
),
child: const Text('Done'),
),
],
),
);
}
}
Widget _buildWifeTipsSection(BuildContext context) {
@@ -833,7 +852,7 @@ Widget _buildTipCard(
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
@@ -868,4 +887,4 @@ Widget _buildTipCard(
),
],
);
}
}

View File

@@ -1,171 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_theme.dart';
import './learn_article_screen.dart';
class _HusbandLearnScreen extends StatelessWidget {
const _HusbandLearnScreen();
@override
Widget build(BuildContext context) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Learn',
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.w600,
color: AppColors.navyBlue,
),
),
const SizedBox(height: 24),
_buildSection(context, 'Understanding Her', [
_LearnItem(
icon: Icons.loop,
title: 'The 4 Phases of Her Cycle',
subtitle: 'What\'s happening in her body each month',
articleId: 'four_phases',
),
_LearnItem(
icon: Icons.psychology_outlined,
title: 'Why Does Her Mood Change?',
subtitle: 'Hormones explained simply',
articleId: 'mood_changes',
),
_LearnItem(
icon: Icons.medical_information_outlined,
title: 'PMS is Real',
subtitle: 'Medical facts for supportive husbands',
articleId: 'pms_is_real',
),
]),
const SizedBox(height: 24),
_buildSection(context, 'Biblical Manhood', [
_LearnItem(
icon: Icons.favorite,
title: 'Loving Like Christ',
subtitle: 'Ephesians 5 in daily practice',
articleId: 'loving_like_christ',
),
_LearnItem(
icon: Icons.handshake,
title: 'Servant Leadership at Home',
subtitle: 'What it really means',
articleId: 'servant_leadership',
),
_LearnItem(
icon: Icons.auto_awesome,
title: 'Praying for Your Wife',
subtitle: 'Practical guide',
articleId: 'praying_for_wife',
),
]),
const SizedBox(height: 24),
_buildSection(context, 'NFP for Husbands', [
_LearnItem(
icon: Icons.show_chart,
title: 'Reading the Charts Together',
subtitle: 'Understanding fertility signs',
articleId: 'reading_charts',
),
_LearnItem(
icon: Icons.schedule,
title: 'Abstinence as Spiritual Discipline',
subtitle: 'Growing together during fertile days',
articleId: 'abstinence_discipline',
),
]),
],
),
),
);
}
Widget _buildSection(BuildContext context, String title, List<_LearnItem> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.warmGray,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: items
.map((item) => ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.navyBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.icon,
color: AppColors.navyBlue,
size: 20,
),
),
title: Text(
item.title,
style: GoogleFonts.outfit(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.charcoal,
),
),
subtitle: Text(
item.subtitle,
style: GoogleFonts.outfit(
fontSize: 13,
color: AppColors.warmGray,
),
),
trailing: const Icon(
Icons.chevron_right,
color: AppColors.lightGray,
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LearnArticleScreen(articleId: item.articleId),
),
);
},
))
.toList(),
),
),
],
);
}
}
class _LearnItem {
final IconData icon;
final String title;
final String subtitle;
final String articleId;
const _LearnItem({
required this.icon,
required this.title,
required this.subtitle,
required this.articleId,
});
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
import '../../theme/app_theme.dart';
/// Dedicated Appearance Settings for the Husband App
/// These settings only affect the husband's experience
class HusbandAppearanceScreen extends ConsumerWidget {
const HusbandAppearanceScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userProfile = ref.watch(userProfileProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: Text(
'Appearance',
style: Theme.of(context).appBarTheme.titleTextStyle,
),
centerTitle: true,
),
body: userProfile == null
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16.0),
children: [
_buildThemeModeSelector(
context, ref, userProfile.husbandThemeMode, isDark),
const SizedBox(height: 24),
_buildAccentColorSelector(
context, ref, userProfile.husbandAccentColor, isDark),
],
),
);
}
Widget _buildThemeModeSelector(BuildContext context, WidgetRef ref,
AppThemeMode currentMode, bool isDark) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme Mode',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
SegmentedButton<AppThemeMode>(
segments: const [
ButtonSegment(
value: AppThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode),
),
ButtonSegment(
value: AppThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode),
),
ButtonSegment(
value: AppThemeMode.system,
label: Text('System'),
icon: Icon(Icons.brightness_auto),
),
],
selected: {currentMode},
onSelectionChanged: (Set<AppThemeMode> newSelection) async {
if (newSelection.isNotEmpty) {
final profile = ref.read(userProfileProvider);
if (profile != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
profile.copyWith(husbandThemeMode: newSelection.first),
);
}
}
},
),
],
);
}
Widget _buildAccentColorSelector(
BuildContext context, WidgetRef ref, String currentAccent, bool isDark) {
// Navy/blue themed colors for husband app
final accents = [
{'color': AppColors.navyBlue, 'value': '0xFF1A3A5C'},
{'color': AppColors.steelBlue, 'value': '0xFF5C7892'},
{'color': AppColors.sageGreen, 'value': '0xFFA8C5A8'},
{'color': AppColors.info, 'value': '0xFF7BB8E8'},
{'color': AppColors.teal, 'value': '0xFF5B9AA0'},
{'color': const Color(0xFF6B5B95), 'value': '0xFF6B5B95'}, // Purple
{'color': const Color(0xFF3D5A80), 'value': '0xFF3D5A80'}, // Dark Blue
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Accent Color',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: accents.map((accent) {
final color = accent['color'] as Color;
final value = accent['value'] as String;
final isSelected = currentAccent == value;
return GestureDetector(
onTap: () async {
final profile = ref.read(userProfileProvider);
if (profile != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
profile.copyWith(husbandAccentColor: value),
);
}
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(
color: isDark ? Colors.white : AppColors.charcoal,
width: 3,
)
: Border.all(
color: isDark ? Colors.white30 : Colors.black12,
width: 1,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: color.withValues(alpha: 0.4),
blurRadius: 8,
offset: const Offset(0, 4),
)
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
],
);
}
}

View File

@@ -2,23 +2,85 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../models/user_profile.dart';
import '../../models/user_profile.dart';
import '../../models/teaching_plan.dart';
import '../../providers/user_provider.dart';
import '../../theme/app_theme.dart';
import '../../services/bible_xml_parser.dart';
import '../../services/notification_service.dart';
import '../settings/sharing_settings_screen.dart';
class HusbandDevotionalScreen extends ConsumerStatefulWidget {
const HusbandDevotionalScreen({super.key});
@override
ConsumerState<HusbandDevotionalScreen> createState() => _HusbandDevotionalScreenState();
ConsumerState<HusbandDevotionalScreen> createState() =>
_HusbandDevotionalScreenState();
}
class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScreen> {
class _HusbandDevotionalScreenState
extends ConsumerState<HusbandDevotionalScreen> {
final _parser = BibleXmlParser();
Map<String, String> _scriptures = {};
bool _loading = true;
BibleTranslation? _currentTranslation;
@override
void initState() {
super.initState();
// Initial fetch handled in post-frame or via listen, but let's trigger once here if possible
// We need the ref, which is available.
WidgetsBinding.instance.addPostFrameCallback((_) {
_fetchScriptures();
});
}
Future<void> _fetchScriptures() async {
final user = ref.read(userProfileProvider);
if (user == null) return;
final translation = user.bibleTranslation;
if (translation == _currentTranslation && _scriptures.isNotEmpty) return;
setState(() => _loading = true);
try {
final assetPath =
'assets/bible_xml/${translation.name.toUpperCase()}.xml';
// Define verses to fetch
final versesToFetch = [
'1 Corinthians 11:3',
'1 Timothy 3:4',
'1 Timothy 3:5',
'1 Timothy 3:12',
'Titus 1:6',
];
final Map<String, String> results = {};
for (final ref in versesToFetch) {
final text = await _parser.getVerseFromAsset(assetPath, ref);
results[ref] = text ?? 'Verse not found.';
}
if (mounted) {
setState(() {
_scriptures = results;
_currentTranslation = translation;
_loading = false;
});
}
} catch (e) {
debugPrint('Error loading scriptures: $e');
if (mounted) setState(() => _loading = false);
}
}
void _showAddTeachingDialog([TeachingPlan? existingPlan]) {
final titleController = TextEditingController(text: existingPlan?.topic);
final scriptureController = TextEditingController(text: existingPlan?.scriptureReference);
final scriptureController =
TextEditingController(text: existingPlan?.scriptureReference);
final notesController = TextEditingController(text: existingPlan?.notes);
DateTime selectedDate = existingPlan?.date ?? DateTime.now();
@@ -69,7 +131,8 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
context: context,
initialDate: selectedDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
lastDate:
DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
setState(() => selectedDate = picked);
@@ -89,41 +152,50 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
),
ElevatedButton(
onPressed: () async {
if (titleController.text.isEmpty) return;
if (titleController.text.isEmpty) return;
final user = ref.read(userProfileProvider);
if (user == null) return;
final user = ref.read(userProfileProvider);
if (user == null) return;
TeachingPlan newPlan;
if (existingPlan != null) {
newPlan = existingPlan.copyWith(
topic: titleController.text,
scriptureReference: scriptureController.text,
notes: notesController.text,
date: selectedDate,
);
} else {
newPlan = TeachingPlan.create(
topic: titleController.text,
scriptureReference: scriptureController.text,
notes: notesController.text,
date: selectedDate,
);
}
TeachingPlan newPlan;
if (existingPlan != null) {
newPlan = existingPlan.copyWith(
topic: titleController.text,
scriptureReference: scriptureController.text,
notes: notesController.text,
date: selectedDate,
);
} else {
newPlan = TeachingPlan.create(
topic: titleController.text,
scriptureReference: scriptureController.text,
notes: notesController.text,
date: selectedDate,
);
}
List<TeachingPlan> updatedList = List.from(user.teachingPlans ?? []);
if (existingPlan != null) {
final index = updatedList.indexWhere((p) => p.id == existingPlan.id);
if (index != -1) updatedList[index] = newPlan;
} else {
updatedList.add(newPlan);
}
List<TeachingPlan> updatedList =
List.from(user.teachingPlans ?? []);
if (existingPlan != null) {
final index =
updatedList.indexWhere((p) => p.id == existingPlan.id);
if (index != -1) updatedList[index] = newPlan;
} else {
updatedList.add(newPlan);
}
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedList),
);
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedList),
);
if (mounted) Navigator.pop(context);
// Trigger notification for new teaching plans
if (existingPlan == null) {
NotificationService().showTeachingPlanNotification(
teacherName: user.name,
);
}
if (context.mounted) Navigator.pop(context);
},
child: const Text('Save'),
),
@@ -137,10 +209,11 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
final user = ref.read(userProfileProvider);
if (user == null || user.teachingPlans == null) return;
final updatedList = user.teachingPlans!.where((p) => p.id != plan.id).toList();
final updatedList =
user.teachingPlans!.where((p) => p.id != plan.id).toList();
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedList),
);
user.copyWith(teachingPlans: updatedList),
);
}
void _toggleComplete(TeachingPlan plan) async {
@@ -151,17 +224,24 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
if (p.id == plan.id) return p.copyWith(isCompleted: !p.isCompleted);
return p;
}).toList();
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedList),
);
user.copyWith(teachingPlans: updatedList),
);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(userProfileProvider);
final upcomingPlans = user?.teachingPlans ?? [];
upcomingPlans.sort((a,b) => a.date.compareTo(b.date));
upcomingPlans.sort((a, b) => a.date.compareTo(b.date));
// Listen for translation changes to re-fetch
ref.listen(userProfileProvider, (prev, next) {
if (next?.bibleTranslation != prev?.bibleTranslation) {
_fetchScriptures();
}
});
return Scaffold(
appBar: AppBar(
@@ -174,7 +254,7 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Informational Card (Headship)
_buildHeadshipCard(),
_buildHeadshipCard(user?.bibleTranslation.label ?? 'ESV'),
const SizedBox(height: 24),
Row(
@@ -190,12 +270,13 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
),
IconButton(
onPressed: () => _showAddTeachingDialog(),
icon: const Icon(Icons.add_circle, color: AppColors.navyBlue, size: 28),
icon: const Icon(Icons.add_circle,
color: AppColors.navyBlue, size: 28),
),
],
),
const SizedBox(height: 12),
if (upcomingPlans.isEmpty)
Container(
width: double.infinity,
@@ -203,7 +284,7 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
border: Border.all(color: Colors.grey.withValues(alpha: 0.2)),
),
child: Column(
children: [
@@ -234,45 +315,54 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red.withOpacity(0.8),
color: Colors.red.withValues(alpha: 0.8),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => _deletePlan(plan),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: ListTile(
onTap: () => _showAddTeachingDialog(plan),
leading: IconButton(
icon: Icon(
plan.isCompleted ? Icons.check_circle : Icons.circle_outlined,
color: plan.isCompleted ? Colors.green : Colors.grey
),
onPressed: () => _toggleComplete(plan),
icon: Icon(
plan.isCompleted
? Icons.check_circle
: Icons.circle_outlined,
color: plan.isCompleted
? Colors.green
: Colors.grey),
onPressed: () => _toggleComplete(plan),
),
title: Text(
plan.topic,
style: GoogleFonts.outfit(
fontWeight: FontWeight.w600,
decoration: plan.isCompleted ? TextDecoration.lineThrough : null,
decoration: plan.isCompleted
? TextDecoration.lineThrough
: null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (plan.scriptureReference.isNotEmpty)
Text(plan.scriptureReference, style: const TextStyle(fontWeight: FontWeight.w500)),
Text(plan.scriptureReference,
style: const TextStyle(
fontWeight: FontWeight.w500)),
if (plan.notes.isNotEmpty)
Text(
plan.notes,
maxLines: 2,
plan.notes,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
DateFormat.yMMMd().format(plan.date),
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
DateFormat.yMMMd().format(plan.date),
style: TextStyle(
fontSize: 11, color: Colors.grey[600]),
),
],
),
isThreeLine: true,
@@ -281,15 +371,29 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
);
},
),
const SizedBox(height: 40),
const SizedBox(height: 24),
// Prayer Request Section
_buildPrayerRequestSection(context, ref, user),
const SizedBox(height: 40),
],
),
),
);
}
Widget _buildHeadshipCard() {
Widget _buildHeadshipCard(String version) {
// Combine 1 Timothy verses
String timothyText = 'Loading...';
if (!_loading) {
timothyText =
'${_scriptures['1 Timothy 3:4'] ?? '...'} ${_scriptures['1 Timothy 3:5'] ?? ''} ... ${_scriptures['1 Timothy 3:12'] ?? ''}';
// Cleanup potential double spaces or missing
timothyText = timothyText.replaceAll(' ', ' ').trim();
}
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
@@ -304,28 +408,48 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
children: [
const Icon(Icons.menu_book, color: Color(0xFF8B5E3C)),
const SizedBox(width: 12),
Text(
'Biblical Principles',
style: GoogleFonts.lora(
fontSize: 18,
fontWeight: FontWeight.bold,
color: const Color(0xFF5D4037),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Biblical Principles',
style: GoogleFonts.lora(
fontSize: 18,
fontWeight: FontWeight.bold,
color: const Color(0xFF5D4037),
),
),
Text(
version,
style: GoogleFonts.outfit(
fontSize: 12, color: const Color(0xFF8B5E3C)),
),
],
),
],
),
const SizedBox(height: 16),
_buildVerseText(
'1 Corinthians 11:3',
'“The head of every man is Christ, the head of a wife is her husband, and the head of Christ is God.”',
_loading
? 'Loading...'
: (_scriptures['1 Corinthians 11:3'] ?? 'Verse not found.'),
'Supports family structure under Christs authority.',
),
const SizedBox(height: 16),
const Divider(height: 1, color: Color(0xFFE0C097)),
const SizedBox(height: 16),
_buildVerseText(
'1 Tim 3:45, 12 & Titus 1:6',
'1 Timothy 3:45, 12',
timothyText,
'Qualifications for church elders include managing their own households well.',
),
const SizedBox(height: 16),
_buildVerseText(
'Titus 1:6',
_loading
? 'Loading...'
: (_scriptures['Titus 1:6'] ?? 'Verse not found.'),
'Husbands who lead faithfully at home are seen as candidates for formal spiritual leadership.',
),
],
@@ -346,13 +470,17 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
),
),
const SizedBox(height: 4),
Text(
text,
style: GoogleFonts.lora(
fontSize: 15,
fontStyle: FontStyle.italic,
height: 1.4,
color: const Color(0xFF3E2723),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: Text(
text,
key: ValueKey(text), // Animate change
style: GoogleFonts.lora(
fontSize: 15,
fontStyle: FontStyle.italic,
height: 1.4,
color: const Color(0xFF3E2723),
),
),
),
const SizedBox(height: 4),
@@ -366,4 +494,169 @@ class _HusbandDevotionalScreenState extends ConsumerState<HusbandDevotionalScree
],
);
}
Widget _buildPrayerRequestSection(
BuildContext context, WidgetRef ref, UserProfile? user) {
// Check if connected (partnerName is set)
final isConnected =
user?.partnerId != null && (user?.partnerId?.isNotEmpty ?? false);
// Get today's cycle entry to check for prayer requests
final entries = ref.watch(cycleEntriesProvider);
final todayEntry = entries.isNotEmpty
? entries.firstWhere(
(e) => DateUtils.isSameDay(e.date, DateTime.now()),
orElse: () => entries.first,
)
: null;
final prayerRequest = todayEntry?.prayerRequest;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.lavender.withValues(alpha: 0.15),
AppColors.blushPink.withValues(alpha: 0.15),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.lavender.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('🙏', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Wife\'s Prayer Requests',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.navyBlue,
),
),
),
],
),
const SizedBox(height: 16),
if (!isConnected) ...[
Text(
'Connect with your wife to see her prayer requests and pray for her.',
style: GoogleFonts.outfit(
fontSize: 14,
color: AppColors.warmGray,
),
),
const SizedBox(height: 16),
Center(
child: ElevatedButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const SharingSettingsScreen(),
),
),
icon: const Icon(Icons.link),
label: const Text('Connect with Wife'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.navyBlue,
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
),
] else if (prayerRequest != null && prayerRequest.isNotEmpty) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${user?.partnerName ?? "Wife"} shared:',
style: GoogleFonts.outfit(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.warmGray,
),
),
const SizedBox(height: 8),
Text(
prayerRequest,
style: GoogleFonts.lora(
fontSize: 15,
fontStyle: FontStyle.italic,
height: 1.5,
color: AppColors.charcoal,
),
),
],
),
),
const SizedBox(height: 12),
Center(
child: TextButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Praying for her! 🙏'),
backgroundColor: AppColors.sageGreen,
),
);
},
icon: const Icon(Icons.favorite, size: 18),
label: const Text('I\'m Praying'),
style: TextButton.styleFrom(
foregroundColor: AppColors.rose,
),
),
),
] else ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
const Icon(Icons.favorite_border,
color: AppColors.warmGray, size: 32),
const SizedBox(height: 8),
Text(
'No prayer requests today',
style: GoogleFonts.outfit(
fontSize: 14,
color: AppColors.warmGray,
),
),
Text(
'Check back later or encourage her to share.',
style: GoogleFonts.outfit(
fontSize: 12,
color: AppColors.warmGray.withValues(alpha: 0.8),
),
),
],
),
),
],
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../data/learn_content.dart';
class HusbandLearnArticlesScreen extends StatelessWidget {
const HusbandLearnArticlesScreen({super.key});
@override
Widget build(BuildContext context) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Learn',
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.displayMedium?.color,
),
),
const SizedBox(height: 24),
_buildSection(context, 'Understanding Her', const [
_LearnItem(
icon: Icons.loop,
title: 'The 4 Phases of Her Cycle',
subtitle: 'What\'s happening in her body each month',
articleId: 'four_phases',
),
_LearnItem(
icon: Icons.psychology_outlined,
title: 'Why Does Her Mood Change?',
subtitle: 'Hormones explained simply',
articleId: 'mood_changes',
),
_LearnItem(
icon: Icons.medical_information_outlined,
title: 'PMS is Real',
subtitle: 'Medical facts for supportive husbands',
articleId: 'pms_is_real',
),
]),
const SizedBox(height: 24),
_buildSection(context, 'Biblical Manhood', const [
_LearnItem(
icon: Icons.favorite,
title: 'Loving Like Christ',
subtitle: 'Ephesians 5 in daily practice',
articleId: 'loving_like_christ',
),
_LearnItem(
icon: Icons.handshake,
title: 'Servant Leadership at Home',
subtitle: 'What it really means',
articleId: 'servant_leadership',
),
_LearnItem(
icon: Icons.auto_awesome,
title: 'Praying for Your Wife',
subtitle: 'Practical guide',
articleId: 'praying_for_wife',
),
]),
const SizedBox(height: 24),
_buildSection(context, 'NFP for Husbands', const [
_LearnItem(
icon: Icons.show_chart,
title: 'Reading the Charts Together',
subtitle: 'Understanding fertility signs',
articleId: 'reading_charts',
),
_LearnItem(
icon: Icons.schedule,
title: 'Abstinence as Spiritual Discipline',
subtitle: 'Growing together during fertile days',
articleId: 'abstinence_discipline',
),
]),
],
),
),
);
}
Widget _buildSection(
BuildContext context, String title, List<_LearnItem> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
letterSpacing: 0.5,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: items
.map((item) => ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
item.icon,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
title: Text(
item.title,
style: GoogleFonts.outfit(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
subtitle: Text(
item.subtitle,
style: GoogleFonts.outfit(
fontSize: 13,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(context).disabledColor,
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
CombinedLearnArticleDetailScreen(
articleId: item.articleId),
),
);
},
))
.toList(),
),
),
],
);
}
}
class _LearnItem {
final IconData icon;
final String title;
final String subtitle;
final String articleId;
const _LearnItem({
required this.icon,
required this.title,
required this.subtitle,
required this.articleId,
});
}
class CombinedLearnArticleDetailScreen extends StatelessWidget {
final String articleId;
const CombinedLearnArticleDetailScreen({super.key, required this.articleId});
@override
Widget build(BuildContext context) {
final article = LearnContent.getArticle(articleId);
if (article == null) {
return Scaffold(
appBar: AppBar(title: const Text('Article Not Found')),
body: const Center(child: Text('Article not found')),
);
}
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
leading: IconButton(
icon:
Icon(Icons.arrow_back, color: Theme.of(context).iconTheme.color),
onPressed: () => Navigator.pop(context),
),
title: Text(
article.category,
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
article.title,
style: GoogleFonts.outfit(
fontSize: 26,
fontWeight: FontWeight.w700,
color: Theme.of(context).textTheme.headlineMedium?.color,
height: 1.2,
),
),
const SizedBox(height: 8),
Text(
article.subtitle,
style: GoogleFonts.outfit(
fontSize: 15,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
const SizedBox(height: 24),
Container(
height: 3,
width: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 24),
...article.sections
.map((section) => _buildSection(context, section)),
],
),
),
);
}
Widget _buildSection(BuildContext context, LearnSection section) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (section.heading != null) ...[
Text(
section.heading!,
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleLarge?.color,
),
),
const SizedBox(height: 10),
],
_buildRichText(context, section.content),
],
),
);
}
Widget _buildRichText(BuildContext context, String content) {
final List<InlineSpan> spans = [];
final RegExp boldPattern = RegExp(r'\*\*(.*?)\*\*');
int currentIndex = 0;
for (final match in boldPattern.allMatches(content)) {
if (match.start > currentIndex) {
spans.add(TextSpan(
text: content.substring(currentIndex, match.start),
style: GoogleFonts.outfit(
fontSize: 15,
color: Theme.of(context).textTheme.bodyLarge?.color,
height: 1.7,
),
));
}
spans.add(TextSpan(
text: match.group(1),
style: GoogleFonts.outfit(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleMedium?.color,
height: 1.7,
),
));
currentIndex = match.end;
}
if (currentIndex < content.length) {
spans.add(TextSpan(
text: content.substring(currentIndex),
style: GoogleFonts.outfit(
fontSize: 15,
color: Theme.of(context).textTheme.bodyLarge?.color,
height: 1.7,
),
));
}
return RichText(
text: TextSpan(children: spans),
);
}
}

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../models/cycle_entry.dart';
import '../../providers/user_provider.dart';
class HusbandNotesScreen extends ConsumerWidget {
@@ -16,7 +15,7 @@ class HusbandNotesScreen extends ConsumerWidget {
(entry.notes != null && entry.notes!.isNotEmpty) ||
(entry.husbandNotes != null && entry.husbandNotes!.isNotEmpty))
.toList();
// Sort entries by date, newest first
notesEntries.sort((a, b) => b.date.compareTo(a.date));
@@ -33,7 +32,8 @@ class HusbandNotesScreen extends ConsumerWidget {
itemBuilder: (context, index) {
final entry = notesEntries[index];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -41,9 +41,10 @@ class HusbandNotesScreen extends ConsumerWidget {
children: [
Text(
DateFormat.yMMMMd().format(entry.date),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
if (entry.notes != null && entry.notes!.isNotEmpty)
@@ -51,7 +52,8 @@ class HusbandNotesScreen extends ConsumerWidget {
title: 'Her Notes',
content: entry.notes!,
),
if (entry.husbandNotes != null && entry.husbandNotes!.isNotEmpty)
if (entry.husbandNotes != null &&
entry.husbandNotes!.isNotEmpty)
_NoteSection(
title: 'Your Notes',
content: entry.husbandNotes!,
@@ -93,4 +95,4 @@ class _NoteSection extends StatelessWidget {
],
);
}
}
}

View File

@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_theme.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
import '../../services/mock_data_service.dart';
import 'husband_appearance_screen.dart';
import '../settings/sharing_settings_screen.dart';
class HusbandSettingsScreen extends ConsumerWidget {
const HusbandSettingsScreen({super.key});
Future<void> _resetApp(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Reset App?'),
content: const Text(
'This will clear all data and return you to onboarding. Are you sure?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Reset', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) {
await ref.read(userProfileProvider.notifier).clearProfile();
await ref.read(cycleEntriesProvider.notifier).clearEntries();
if (context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);
}
}
}
Future<void> _loadDemoData(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Load Demo Data?'),
content: const Text(
'This will populate the app with mock cycle entries and a wife profile.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child:
const Text('Load Data', style: TextStyle(color: Colors.blue)),
),
],
),
);
if (confirmed == true) {
final mockService = MockDataService();
// Load mock entries
final entries = mockService.generateMockCycleEntries();
for (var entry in entries) {
await ref.read(cycleEntriesProvider.notifier).addEntry(entry);
}
// Update mock profile
final mockWife = mockService.generateMockWifeProfile();
final currentProfile = ref.read(userProfileProvider);
if (currentProfile != null) {
final updatedProfile = currentProfile.copyWith(
partnerName: mockWife.name,
averageCycleLength: mockWife.averageCycleLength,
averagePeriodLength: mockWife.averagePeriodLength,
lastPeriodStartDate: mockWife.lastPeriodStartDate,
favoriteFoods: mockWife.favoriteFoods,
);
await ref
.read(userProfileProvider.notifier)
.updateProfile(updatedProfile);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Demo data loaded')),
);
}
}
}
void _showTranslationPicker(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Container(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Choose Translation',
style: GoogleFonts.outfit(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleLarge?.color,
),
),
const SizedBox(height: 16),
...BibleTranslation.values.map((translation) => ListTile(
title: Text(
translation.label,
style: GoogleFonts.outfit(fontWeight: FontWeight.w500),
),
trailing: ref.watch(userProfileProvider)?.bibleTranslation ==
translation
? const Icon(Icons.check, color: AppColors.sageGreen)
: null,
onTap: () async {
final profile = ref.read(userProfileProvider);
if (profile != null) {
await ref
.read(userProfileProvider.notifier)
.updateProfile(
profile.copyWith(bibleTranslation: translation),
);
}
if (context.mounted) Navigator.pop(context);
},
)),
],
),
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
// Theme aware colors
final user = ref.watch(userProfileProvider);
final cardColor =
Theme.of(context).cardTheme.color; // Using theme card color
final textColor = Theme.of(context).textTheme.bodyLarge?.color;
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Settings',
style: GoogleFonts.outfit(
fontSize: 28,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.displayMedium?.color ??
AppColors.navyBlue,
),
),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
ListTile(
leading: Icon(Icons.notifications_outlined,
color: Theme.of(context).colorScheme.primary),
title: Text('Notifications',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500, color: textColor)),
trailing: Switch(value: true, onChanged: (val) {}),
),
const Divider(height: 1),
ListTile(
leading: Icon(Icons.link,
color: Theme.of(context).colorScheme.primary),
title: Text(
user?.partnerId != null
? 'Partner Settings'
: 'Connect with Wife',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500, color: textColor)),
subtitle: user?.partnerId != null &&
user?.partnerName != null
? Text('Linked with ${user!.partnerName}',
style: GoogleFonts.outfit(
fontSize: 12,
color: Theme.of(context).colorScheme.primary))
: null,
trailing: Icon(Icons.chevron_right,
color: Theme.of(context).disabledColor),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SharingSettingsScreen(),
),
);
},
),
const Divider(height: 1),
ListTile(
leading: Icon(Icons.menu_book_outlined,
color: Theme.of(context).colorScheme.primary),
title: Text('Bible Translation',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500, color: textColor)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
ref.watch(userProfileProvider
.select((u) => u?.bibleTranslation.label)) ??
'ESV',
style: GoogleFonts.outfit(
fontSize: 14,
color:
Theme.of(context).textTheme.bodyMedium?.color ??
AppColors.warmGray,
),
),
Icon(Icons.chevron_right,
color: Theme.of(context).disabledColor),
],
),
onTap: () => _showTranslationPicker(context, ref),
),
const Divider(height: 1),
ListTile(
leading: Icon(Icons.palette_outlined,
color: Theme.of(context).colorScheme.primary),
title: Text('Appearance',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500, color: textColor)),
trailing: Icon(Icons.chevron_right,
color: Theme.of(context).disabledColor),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const HusbandAppearanceScreen(),
),
);
},
),
],
),
),
const SizedBox(height: 20),
Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
ListTile(
leading: const Icon(Icons.sync, color: Colors.blue),
title: Text('Sync Data',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500, color: Colors.blue)),
onTap: () async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Syncing data...')),
);
await ref.read(cycleEntriesProvider.notifier).syncData();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Sync complete'),
backgroundColor: Colors.green,
),
);
}
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.cloud_download_outlined,
color: Colors.blue),
title: Text('Load Demo Data',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500, color: Colors.blue)),
onTap: () => _loadDemoData(context, ref),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),
title: Text('Reset App',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500, color: Colors.red)),
onTap: () => _resetApp(context, ref),
),
],
),
),
],
),
),
);
}
}

View File

@@ -1,156 +0,0 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../data/learn_content.dart';
import '../../theme/app_theme.dart';
/// Screen to display full learn article content
class LearnArticleScreen extends StatelessWidget {
final String articleId;
const LearnArticleScreen({super.key, required this.articleId});
@override
Widget build(BuildContext context) {
final article = LearnContent.getArticle(articleId);
if (article == null) {
return Scaffold(
appBar: AppBar(title: const Text('Article Not Found')),
body: const Center(child: Text('Article not found')),
);
}
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
appBar: AppBar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Theme.of(context).iconTheme.color),
onPressed: () => Navigator.pop(context),
),
title: Text(
article.category,
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
article.title,
style: GoogleFonts.outfit(
fontSize: 26,
fontWeight: FontWeight.w700,
color: Theme.of(context).textTheme.headlineMedium?.color,
height: 1.2,
),
),
const SizedBox(height: 8),
Text(
article.subtitle,
style: GoogleFonts.outfit(
fontSize: 15,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
const SizedBox(height: 24),
// Divider
Container(
height: 3,
width: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 24),
// Sections
...article.sections.map((section) => _buildSection(context, section)),
],
),
),
);
}
Widget _buildSection(BuildContext context, LearnSection section) {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (section.heading != null) ...[
Text(
section.heading!,
style: GoogleFonts.outfit(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleLarge?.color,
),
),
const SizedBox(height: 10),
],
_buildRichText(context, section.content),
],
),
);
}
Widget _buildRichText(BuildContext context, String content) {
// Handle basic markdown-like formatting
final List<InlineSpan> spans = [];
final RegExp boldPattern = RegExp(r'\*\*(.*?)\*\*');
int currentIndex = 0;
for (final match in boldPattern.allMatches(content)) {
// Add text before the match
if (match.start > currentIndex) {
spans.add(TextSpan(
text: content.substring(currentIndex, match.start),
style: GoogleFonts.outfit(
fontSize: 15,
color: Theme.of(context).textTheme.bodyLarge?.color,
height: 1.7,
),
));
}
// Add bold text
spans.add(TextSpan(
text: match.group(1),
style: GoogleFonts.outfit(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.titleMedium?.color,
height: 1.7,
),
));
currentIndex = match.end;
}
// Add remaining text
if (currentIndex < content.length) {
spans.add(TextSpan(
text: content.substring(currentIndex),
style: GoogleFonts.outfit(
fontSize: 15,
color: Theme.of(context).textTheme.bodyLarge?.color,
height: 1.7,
),
));
}
return RichText(
text: TextSpan(children: spans),
);
}
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:christian_period_tracker/models/scripture.dart';
class HusbandLearnScreen extends StatelessWidget {
const HusbandLearnScreen({super.key});
@@ -45,7 +44,8 @@ class HusbandLearnScreen extends StatelessWidget {
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -54,21 +54,24 @@ class HusbandLearnScreen extends StatelessWidget {
_buildTipCard(
context,
title: 'Understanding Female Cycles',
content: 'Learn about the different phases of your wife\'s menstrual cycle and how they affect her health.',
content:
'Learn about the different phases of your wife\'s menstrual cycle and how they affect her health.',
icon: Icons.calendar_month,
),
const SizedBox(height: 16),
_buildTipCard(
context,
title: 'Supportive Role',
content: 'Be supportive during different phases, understanding when she may need more emotional support or rest.',
content:
'Be supportive during different phases, understanding when she may need more emotional support or rest.',
icon: Icons.support_agent,
),
const SizedBox(height: 16),
_buildTipCard(
context,
title: 'Nutritional Support',
content: 'Understand how nutrition affects reproductive health and discuss dietary choices together.',
content:
'Understand how nutrition affects reproductive health and discuss dietary choices together.',
icon: Icons.food_bank,
),
],
@@ -90,7 +93,8 @@ class HusbandLearnScreen extends StatelessWidget {
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -99,21 +103,24 @@ class HusbandLearnScreen extends StatelessWidget {
_buildTipCard(
context,
title: 'Safe Sexual Practices',
content: 'Use protection consistently to prevent sexually transmitted infections and maintain mutual health.',
content:
'Use protection consistently to prevent sexually transmitted infections and maintain mutual health.',
icon: Icons.security,
),
const SizedBox(height: 16),
_buildTipCard(
context,
title: 'Regular Testing',
content: 'Schedule regular STI screenings together with your partner for early detection and treatment.',
content:
'Schedule regular STI screenings together with your partner for early detection and treatment.',
icon: Icons.medical_information,
),
const SizedBox(height: 16),
_buildTipCard(
context,
title: 'Open Communication',
content: 'Discuss health concerns openly to ensure both partners understand each other\'s needs and maintain trust.',
content:
'Discuss health concerns openly to ensure both partners understand each other\'s needs and maintain trust.',
icon: Icons.chat,
),
],
@@ -135,7 +142,8 @@ class HusbandLearnScreen extends StatelessWidget {
const SizedBox(height: 16),
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -144,14 +152,16 @@ class HusbandLearnScreen extends StatelessWidget {
_buildTipCard(
context,
title: 'Educate Yourself',
content: 'Take the initiative to learn about reproductive health and partner needs.',
content:
'Take the initiative to learn about reproductive health and partner needs.',
icon: Icons.school,
),
const SizedBox(height: 16),
_buildTipCard(
context,
title: 'Active Listening',
content: 'Listen attentively when your wife discusses her health concerns or feelings.',
content:
'Listen attentively when your wife discusses her health concerns or feelings.',
icon: Icons.mic_none,
),
],
@@ -162,7 +172,10 @@ class HusbandLearnScreen extends StatelessWidget {
);
}
Widget _buildTipCard(BuildContext context, {required String title, required String content, required IconData icon}) {
Widget _buildTipCard(BuildContext context,
{required String title,
required String content,
required IconData icon}) {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
@@ -174,10 +187,14 @@ class HusbandLearnScreen extends StatelessWidget {
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.2),
color: Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(icon, size: 24, color: Theme.of(context).colorScheme.primary),
child: Icon(icon,
size: 24, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(width: 16),
Expanded(
@@ -186,7 +203,10 @@ class HusbandLearnScreen extends StatelessWidget {
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
@@ -201,4 +221,4 @@ class HusbandLearnScreen extends StatelessWidget {
),
);
}
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_theme.dart';
import '../husband/learn_article_screen.dart';
import '../husband/husband_learn_articles_screen.dart';
class WifeLearnScreen extends StatelessWidget {
const WifeLearnScreen({super.key});
@@ -23,7 +23,7 @@ class WifeLearnScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSection(context, 'Understanding My Cycle', [
_buildSection(context, 'Understanding My Cycle', const [
_LearnItem(
icon: Icons.loop,
title: 'The 4 Phases',
@@ -34,11 +34,12 @@ class WifeLearnScreen extends StatelessWidget {
icon: Icons.psychology_outlined,
title: 'Mood & Hormones',
subtitle: 'Why I feel different each week',
articleId: 'wife_mood_changes', // Reusing similar concept, maybe new article
articleId:
'wife_mood_changes', // Reusing similar concept, maybe new article
),
]),
const SizedBox(height: 24),
_buildSection(context, 'Disease Prevention', [
_buildSection(context, 'Disease Prevention', const [
_LearnItem(
icon: Icons.health_and_safety_outlined,
title: 'Preventing Infections',
@@ -53,7 +54,7 @@ class WifeLearnScreen extends StatelessWidget {
),
]),
const SizedBox(height: 24),
_buildSection(context, 'Partnership', [
_buildSection(context, 'Partnership', const [
_LearnItem(
icon: Icons.favorite_border,
title: 'Communication',
@@ -73,7 +74,8 @@ class WifeLearnScreen extends StatelessWidget {
);
}
Widget _buildSection(BuildContext context, String title, List<_LearnItem> items) {
Widget _buildSection(
BuildContext context, String title, List<_LearnItem> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -92,7 +94,10 @@ class WifeLearnScreen extends StatelessWidget {
color: Theme.of(context).cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.05)),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.05)),
),
child: Column(
children: items
@@ -101,7 +106,10 @@ class WifeLearnScreen extends StatelessWidget {
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.2),
color: Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
@@ -133,7 +141,9 @@ class WifeLearnScreen extends StatelessWidget {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LearnArticleScreen(articleId: item.articleId),
builder: (context) =>
CombinedLearnArticleDetailScreen(
articleId: item.articleId),
),
);
},
@@ -158,4 +168,4 @@ class _LearnItem {
required this.subtitle,
required this.articleId,
});
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,17 @@
import 'dart:async'; // Add this import for Timer
// import 'dart:convert'; // For encoding/decoding // Removed unused import to fix lint
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter/services.dart'; // For Clipboard
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
import '../../theme/app_theme.dart';
import 'package:christian_period_tracker/models/user_profile.dart';
import 'package:christian_period_tracker/models/cycle_entry.dart';
import '../../models/user_profile.dart';
import '../home/home_screen.dart';
import '../husband/husband_home_screen.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/user_provider.dart';
import '../../services/notification_service.dart';
import '../../services/sync_service.dart';
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@@ -35,6 +37,18 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
int _maxCycleLength = 35;
bool _isPadTrackingEnabled = false;
// Connection options
late String _userId;
String? _partnerId;
bool _useExampleData = false;
bool _skipPartnerConnection = false;
@override
void initState() {
super.initState();
_userId = const Uuid().v4();
}
@override
void dispose() {
_pageController.dispose();
@@ -45,14 +59,39 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
if (_isNavigating) return;
_isNavigating = true;
// Husband Flow: Role (0) -> Name (1) -> Finish
// Wife Flow: Role (0) -> Name (1) -> Relationship (2) -> [Fertility (3)] -> Cycle (4)
// Husband Flow: Role (0) -> Name (1) -> Connect (2) -> Finish
// Wife Flow: Role (0) -> Name (1) -> Relationship (2) -> [Fertility (3)] -> Cycle (4) -> [Connect (5) if married]
int nextPage = _currentPage + 1;
// Early Server Registration (After Name/Role selection)
if (_currentPage == 1) {
// Don't await this, let it happen in background to keep UI snappy?
// Actually, await it to ensure ID is valid before they reach "Connect"?
// "Connect" is Page 2 for Husband.
// So yes, we should probably await or just fire and hope response is fast.
// But _nextPage is async.
// Let's fire and forget, but maybe add a small delay or ensure it happens.
// Since it's local network often, it should be fast.
_registerEarly();
}
// Logic for skipping pages
// Logic for skipping pages
if (_role == UserRole.husband) {
if (_currentPage == 1) {
if (_currentPage == 2) {
// Finish after connect page
if (!_useExampleData) {
final id = await _showConnectDialog();
if (id != null && id.isNotEmpty) {
setState(() => _partnerId = id);
} else if (id == null) {
// Cancelled
if (mounted) setState(() => _isNavigating = false);
return;
}
}
await _completeOnboarding();
return;
}
@@ -63,10 +102,24 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
// Skip fertility goal (page 3) if not married
nextPage = 4;
}
if (_currentPage == 4 &&
_relationshipStatus != RelationshipStatus.married) {
// Skip connect page (page 5) if not married - finish now
await _completeOnboarding();
return;
}
if (_currentPage == 5) {
// Finish after connect page (married wife)
if (!_skipPartnerConnection) {
await _showInviteDialog();
}
await _completeOnboarding();
return;
}
}
if (nextPage <= 4) {
// Max pages
final maxPages = _role == UserRole.husband ? 2 : 5;
if (nextPage <= maxPages) {
await _pageController.animateToPage(
nextPage,
duration: const Duration(milliseconds: 400),
@@ -111,31 +164,114 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
});
}
Future<void> _registerEarly() async {
// Register the user on the server early so partner can link to them
// immediately.
try {
final userDetails = {
'name': _name,
'role': _role.name,
'partnerId': null, // No partner yet
'createdAt': DateTime.now().toIso8601String(),
};
await SyncService().pushSyncData(
userId: _userId,
entries: [],
teachingPlans: [],
prayerRequests: [],
userDetails: userDetails,
);
debugPrint('Early registration successful for $_name');
} catch (e) {
debugPrint('Early registration failed: $e');
}
}
Future<void> _completeOnboarding() async {
// 1. Check for Server-Linked Partner (Auto-Discovery)
// If the husband linked to us while we were finishing the form,
// the server will have the partnerId.
try {
final syncData = await SyncService().pullSyncData(_userId);
if (syncData.containsKey('userProfile')) {
final serverProfile = syncData['userProfile'] as Map<String, dynamic>;
if (serverProfile['partnerId'] != null) {
_partnerId = serverProfile['partnerId'];
debugPrint('Auto-discovered partner: $_partnerId');
}
}
} catch (e) {
debugPrint('Error checking for partner link: $e');
}
// 2. Create User Profile
final userProfile = UserProfile(
id: const Uuid().v4(),
id: _userId,
name: _name,
role: _role,
relationshipStatus: _role == UserRole.husband
? RelationshipStatus.married
: _relationshipStatus,
partnerId: _partnerId,
fertilityGoal: (_role == UserRole.wife &&
_relationshipStatus == RelationshipStatus.married)
? _fertilityGoal
: null,
averageCycleLength: _averageCycleLength,
minCycleLength: _minCycleLength,
maxCycleLength: _maxCycleLength,
lastPeriodStartDate: _lastPeriodStart,
isIrregularCycle: _isIrregularCycle,
isPadTrackingEnabled: _isPadTrackingEnabled,
hasCompletedOnboarding: true,
isPadTrackingEnabled: _isPadTrackingEnabled,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
// 3. Save Profile (triggers local save)
await ref.read(userProfileProvider.notifier).updateProfile(userProfile);
// 4. Force Final Sync (Push everything including completed status)
// Note: CycleEntriesNotifier handles data sync, but we want to ensure
// profile is consistent. The Provider doesn't push profile changes automatically yet,
// so we do it manually or rely on the next data change.
// For safety, let's just push one last time or let the Home Screen handle it.
// But since we just updated the profile, we should sync it.
try {
final userDetails = {
'name': userProfile.name,
'role': userProfile.role.name,
'partnerId': userProfile.partnerId,
'createdAt': userProfile.createdAt.toIso8601String(),
};
await SyncService().pushSyncData(
userId: _userId,
entries: [],
teachingPlans: [],
prayerRequests: [],
userDetails: userDetails,
);
} catch (e) {
debugPrint('Final onboarding sync failed: $e');
}
// Generate example data if requested - REMOVED
/*
if (_useExampleData) {
await ref
.read(cycleEntriesProvider.notifier)
.generateExampleData(userProfile.id);
}
*/
// Trigger partner connection notification if applicable
if (!_skipPartnerConnection) {
await NotificationService().showPartnerUpdateNotification(
title: 'Connection Successful!',
body: 'You are now connected with your partner. Tap to start sharing.',
);
}
if (mounted) {
// Navigate to appropriate home screen
if (_role == UserRole.husband) {
@@ -155,6 +291,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// final isDark = theme.brightness == Brightness.dark; - unused
final isDark = theme.brightness == Brightness.dark;
// Different background color for husband flow
@@ -174,14 +311,18 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
padding: const EdgeInsets.all(24),
child: SmoothPageIndicator(
controller: _pageController,
count: isHusband ? 2 : 5,
count: isHusband
? 3
: (_relationshipStatus == RelationshipStatus.married
? 6
: 5),
effect: WormEffect(
dotHeight: 8,
dotWidth: 8,
spacing: 12,
activeDotColor:
isHusband ? AppColors.navyBlue : AppColors.sageGreen,
dotColor: theme.colorScheme.outline.withOpacity(0.2),
dotColor: theme.colorScheme.outline.withValues(alpha: 0.2),
),
),
),
@@ -190,17 +331,22 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
Expanded(
child: PageView(
controller: _pageController,
physics:
const NeverScrollableScrollPhysics(), // Disable swipe
physics: const NeverScrollableScrollPhysics(), // Disable swipe
onPageChanged: (index) {
setState(() => _currentPage = index);
},
children: [
_buildRolePage(), // Page 0
_buildNamePage(), // Page 1
_buildRelationshipPage(), // Page 2 (Wife only)
_buildFertilityGoalPage(), // Page 3 (Wife married only)
_buildCyclePage(), // Page 4 (Wife only)
if (_role == UserRole.husband)
_buildHusbandConnectPage() // Page 2 (Husband only)
else ...[
_buildRelationshipPage(), // Page 2 (Wife only)
_buildFertilityGoalPage(), // Page 3 (Wife married only)
_buildCyclePage(), // Page 4 (Wife only)
if (_relationshipStatus == RelationshipStatus.married)
_buildWifeConnectPage(), // Page 5 (Wife married only)
],
],
),
),
@@ -225,7 +371,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
gradient: LinearGradient(
colors: [
AppColors.blushPink,
AppColors.rose.withOpacity(0.7)
AppColors.rose.withValues(alpha: 0.7)
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -282,7 +428,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
// Dynamic colors based on role selection
final activeColor =
role == UserRole.wife ? AppColors.sageGreen : AppColors.navyBlue;
final activeBg = activeColor.withOpacity(isDark ? 0.3 : 0.1);
final activeBg = activeColor.withValues(alpha: isDark ? 0.3 : 0.1);
return GestureDetector(
onTap: () => setState(() => _role = role),
@@ -295,7 +441,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
border: Border.all(
color: isSelected
? activeColor
: theme.colorScheme.outline.withOpacity(0.1),
: theme.colorScheme.outline.withValues(alpha: 0.1),
width: isSelected ? 2 : 1,
),
),
@@ -306,7 +452,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
decoration: BoxDecoration(
color: isSelected
? activeColor
: theme.colorScheme.surfaceVariant,
: theme.colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
@@ -340,8 +486,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
],
),
),
if (isSelected)
Icon(Icons.check_circle, color: activeColor),
if (isSelected) Icon(Icons.check_circle, color: activeColor),
],
),
),
@@ -351,8 +496,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
Widget _buildNamePage() {
final theme = Theme.of(context);
final isHusband = _role == UserRole.husband;
final activeColor =
isHusband ? AppColors.navyBlue : AppColors.sageGreen;
final activeColor = isHusband ? AppColors.navyBlue : AppColors.sageGreen;
return Padding(
padding: const EdgeInsets.all(32),
@@ -413,13 +557,12 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: (_name.isNotEmpty && !_isNavigating)
? _nextPage
: null,
onPressed:
(_name.isNotEmpty && !_isNavigating) ? _nextPage : null,
style: ElevatedButton.styleFrom(
backgroundColor: activeColor,
),
child: Text(isHusband ? 'Finish Setup' : 'Continue'),
child: const Text('Continue'),
),
),
),
@@ -476,9 +619,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: (_relationshipStatus != null && !_isNavigating)
? _nextPage
: null,
onPressed: !_isNavigating ? _nextPage : null,
child: const Text('Continue'),
),
),
@@ -503,13 +644,13 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? AppColors.sageGreen.withOpacity(isDark ? 0.3 : 0.1)
? AppColors.sageGreen.withValues(alpha: isDark ? 0.3 : 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.sageGreen
: theme.colorScheme.outline.withOpacity(0.1),
: theme.colorScheme.outline.withValues(alpha: 0.1),
width: isSelected ? 2 : 1,
),
),
@@ -535,7 +676,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
),
),
if (isSelected)
Icon(Icons.check_circle, color: AppColors.sageGreen),
const Icon(Icons.check_circle, color: AppColors.sageGreen),
],
),
),
@@ -557,11 +698,8 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface)),
const SizedBox(height: 32),
_buildGoalOption(
FertilityGoal.tryingToConceive,
'Trying to Conceive',
'Track fertile days',
Icons.child_care_outlined),
_buildGoalOption(FertilityGoal.tryingToConceive, 'Trying to Conceive',
'Track fertile days', Icons.child_care_outlined),
const SizedBox(height: 12),
_buildGoalOption(
FertilityGoal.tryingToAvoid,
@@ -618,13 +756,13 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? AppColors.sageGreen.withOpacity(isDark ? 0.3 : 0.1)
? AppColors.sageGreen.withValues(alpha: isDark ? 0.3 : 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppColors.sageGreen
: theme.colorScheme.outline.withOpacity(0.1),
: theme.colorScheme.outline.withValues(alpha: 0.1),
width: isSelected ? 2 : 1,
),
),
@@ -650,7 +788,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
),
),
if (isSelected)
Icon(Icons.check_circle, color: AppColors.sageGreen),
const Icon(Icons.check_circle, color: AppColors.sageGreen),
],
),
),
@@ -659,7 +797,8 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
Widget _buildCyclePage() {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
// final isDark = theme.brightness == Brightness.dark; - unused
// final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.all(32),
@@ -692,8 +831,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
),
Text('$_averageCycleLength days',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.sageGreen)),
fontWeight: FontWeight.w600, color: AppColors.sageGreen)),
],
),
@@ -720,12 +858,14 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
children: [
Expanded(
child: RangeSlider(
values: RangeValues(_minCycleLength.toDouble(), _maxCycleLength.toDouble()),
values: RangeValues(
_minCycleLength.toDouble(), _maxCycleLength.toDouble()),
min: 21,
max: 45,
divisions: 24,
activeColor: AppColors.sageGreen,
labels: RangeLabels('$_minCycleLength days', '$_maxCycleLength days'),
labels: RangeLabels(
'$_minCycleLength days', '$_maxCycleLength days'),
onChanged: (values) {
setState(() {
_minCycleLength = values.start.round();
@@ -739,8 +879,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
Center(
child: Text('$_minCycleLength - $_maxCycleLength days',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.sageGreen)),
fontWeight: FontWeight.w600, color: AppColors.sageGreen)),
),
],
@@ -790,7 +929,7 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
color: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withOpacity(0.1))),
color: theme.colorScheme.outline.withValues(alpha: 0.1))),
child: Row(
children: [
Icon(Icons.calendar_today,
@@ -840,4 +979,527 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
),
);
}
/// Husband Connect Page - Choose to connect with wife or use example data
Widget _buildHusbandConnectPage() {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.navyBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.link, size: 32, color: AppColors.navyBlue),
),
const SizedBox(height: 24),
Text(
'Connect with your wife',
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'See her cycle info and prayer requests to support her better.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
// Option 1: Connect with Wife (placeholder for now)
_buildConnectOption(
icon: Icons.qr_code_scanner,
title: 'Connect with Wife',
subtitle: 'Enter connection code from her app',
isSelected: !_useExampleData,
onTap: () => setState(() => _useExampleData = false),
color: AppColors.navyBlue,
isDark: isDark,
),
const SizedBox(height: 16),
// Option 2: Use Example Data
_buildConnectOption(
icon: Icons.auto_awesome,
title: 'Use Example Data',
subtitle: 'Explore the app with sample data',
isSelected: _useExampleData,
onTap: () => setState(() => _useExampleData = true),
color: AppColors.navyBlue,
isDark: isDark,
),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 54,
child: OutlinedButton(
onPressed: _previousPage,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.navyBlue,
side: const BorderSide(color: AppColors.navyBlue),
),
child: const Text('Back'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: !_isNavigating ? _nextPage : null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.navyBlue,
),
child: const Text('Finish Setup'),
),
),
),
],
),
],
),
);
}
/// Wife Connect Page - Invite husband or skip
Widget _buildWifeConnectPage() {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.sageGreen.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.favorite,
size: 32, color: AppColors.sageGreen),
),
const SizedBox(height: 24),
Text(
'Invite your husband',
style: theme.textTheme.displaySmall?.copyWith(
fontSize: 28,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'Share your cycle info and prayer requests so he can support you.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 32),
// Option 1: Invite Husband
_buildConnectOption(
icon: Icons.share,
title: 'Invite Husband',
subtitle: 'Generate a connection code to share',
isSelected: !_skipPartnerConnection,
onTap: () => setState(() => _skipPartnerConnection = false),
color: AppColors.sageGreen,
isDark: isDark,
),
const SizedBox(height: 16),
// Option 2: Skip for Now
_buildConnectOption(
icon: Icons.schedule,
title: 'Skip for Now',
subtitle: 'You can invite him later in settings',
isSelected: _skipPartnerConnection,
onTap: () => setState(() => _skipPartnerConnection = true),
color: AppColors.sageGreen,
isDark: isDark,
),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 54,
child: OutlinedButton(
onPressed: _previousPage,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.sageGreen,
side: const BorderSide(color: AppColors.sageGreen),
),
child: const Text('Back'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
height: 54,
child: ElevatedButton(
onPressed: !_isNavigating ? _nextPage : null,
child: const Text('Get Started'),
),
),
),
],
),
],
),
);
}
/// Helper for connection option cards
Widget _buildConnectOption({
required IconData icon,
required String title,
required String subtitle,
required bool isSelected,
required VoidCallback onTap,
required Color color,
required bool isDark,
}) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? color.withValues(alpha: isDark ? 0.3 : 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? color
: theme.colorScheme.outline.withValues(alpha: 0.1),
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected
? color
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected
? Colors.white
: theme.colorScheme.onSurfaceVariant,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isSelected) Icon(Icons.check_circle, color: color),
],
),
),
);
}
Future<String?> _showConnectDialog() async {
// Ensure we exist before connecting
await _ensureServerRegistration();
final controller = TextEditingController();
String? error;
bool isLoading = false;
// State for the dialog: 'input', 'confirm'
String step = 'input';
String? partnerName;
String? partnerRole;
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
if (step == 'confirm') {
return AlertDialog(
title: const Text('Confirm Connection'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Found Partner: $partnerName'),
if (partnerRole != null) Text('Role: $partnerRole'),
const SizedBox(height: 16),
const Text('Do you want to connect with this user?'),
if (isLoading) ...[
const SizedBox(height: 16),
const CircularProgressIndicator(),
],
],
),
actions: [
if (!isLoading)
TextButton(
onPressed: () {
setState(() {
step = 'input';
error = null;
});
},
child: const Text('Back'),
),
ElevatedButton(
onPressed: isLoading
? null
: () async {
setState(() => isLoading = true);
try {
// Final Link
final input = controller.text.trim();
await SyncService().verifyPartnerId(_userId, input);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Connected to $partnerName!')),
);
Navigator.pop(context, input);
}
} catch (e) {
if (context.mounted) {
setState(() {
isLoading = false;
error = 'Connection Request Failed';
step = 'input';
});
}
}
},
child: const Text('Confirm & Link'),
),
],
);
}
return AlertDialog(
title: const Text('Connect with Partner'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Enter your partner\'s User ID:'),
const SizedBox(height: 16),
TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: 'Paste ID here',
errorText: error,
),
enabled: !isLoading,
),
if (isLoading) ...[
const SizedBox(height: 16),
const CircularProgressIndicator(),
const SizedBox(height: 8),
const Text('Searching...'),
],
],
),
actions: [
if (!isLoading)
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: isLoading
? null
: () async {
final input = controller.text.trim();
if (input.isEmpty) return;
setState(() {
isLoading = true;
error = null;
});
try {
// Preview First
final result =
await SyncService().previewPartnerId(input);
if (context.mounted) {
setState(() {
isLoading = false;
partnerName = result['partnerName'];
partnerRole = result['partnerRole'];
step = 'confirm';
});
}
} catch (e) {
if (context.mounted) {
setState(() {
isLoading = false;
// Show actual error for debugging
error = e
.toString()
.replaceAll('Exception:', '')
.trim();
});
}
}
},
child: const Text('Find Partner'),
),
],
);
},
),
);
}
Future<void> _ensureServerRegistration() async {
await _registerEarly();
}
Future<void> _showInviteDialog() async {
// 1. Ensure we are actually registered so they can find us
await _ensureServerRegistration();
Timer? pollTimer;
await showDialog(
context: context,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
// Poll for connection
if (pollTimer == null) {
pollTimer =
Timer.periodic(const Duration(seconds: 3), (timer) async {
if (!mounted) {
timer.cancel();
return;
}
// Check if we are connected yet
try {
final result = await SyncService().pullSyncData(_userId);
if (result.containsKey('userProfile')) {
final profile = result['userProfile'];
final partnerId = profile['partnerId'];
if (partnerId != null) {
// SUCCESS!
timer.cancel();
if (context.mounted) {
// We could also fetch partner name here if needed,
// but for now we just know we are linked.
// Or pull again to get teaching plans etc if they synced.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Husband Connected Successfully!')),
);
Navigator.pop(context); // Close dialog
}
}
}
} catch (e) {
debugPrint('Poll error: $e');
}
});
}
return AlertDialog(
title: const Text('Invite Partner'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Share this code with your partner:'),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SelectableText(
_userId,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18),
),
IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: _userId));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied to clipboard!')),
);
},
),
],
),
const SizedBox(height: 16),
const Text('Waiting for him to connect...'),
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
),
actions: [
TextButton(
onPressed: () {
pollTimer?.cancel();
Navigator.pop(context);
},
child: const Text('Cancel / Done'),
),
],
);
},
),
);
pollTimer?.cancel();
}
}

View File

@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../providers/prayer_provider.dart';
import '../../models/prayer_request.dart';
import '../../theme/app_theme.dart';
class PrayerRequestScreen extends ConsumerWidget {
const PrayerRequestScreen({super.key});
void _showAddDialog(BuildContext context, WidgetRef ref) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('New Prayer Request',
style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'What can we pray for?',
border: OutlineInputBorder(),
),
maxLines: 3,
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
if (controller.text.isNotEmpty) {
ref
.read(prayerRequestsProvider.notifier)
.addRequest(controller.text.trim());
Navigator.pop(context);
}
},
child: const Text('Add'),
),
],
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final requests = ref.watch(prayerRequestsProvider);
final theme = Theme.of(context);
// Separate active and answered
final active = requests.where((r) => !r.isAnswered).toList();
final answered = requests.where((r) => r.isAnswered).toList();
return Scaffold(
appBar: AppBar(
title: Text('Prayer Requests',
style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
centerTitle: true,
),
body: requests.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.spa_outlined,
size: 64, color: theme.disabledColor),
const SizedBox(height: 16),
Text(
'No prayer requests yet.',
style: GoogleFonts.outfit(
fontSize: 16,
color: theme.textTheme.bodyMedium?.color,
),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () => _showAddDialog(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add First Request'),
),
],
),
)
: ListView(
padding: const EdgeInsets.all(16),
children: [
if (active.isNotEmpty) ...[
Text('Active Requests',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w600,
fontSize: 18,
color: AppColors.charcoal)),
const SizedBox(height: 8),
...active.map((req) => _PrayerCard(request: req)),
],
if (active.isNotEmpty && answered.isNotEmpty)
const Divider(height: 32),
if (answered.isNotEmpty) ...[
Text('Answered Prayers',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w600,
fontSize: 18,
color: AppColors.sageGreen)),
const SizedBox(height: 8),
...answered.map((req) => _PrayerCard(request: req)),
],
],
),
floatingActionButton: requests.isNotEmpty
? FloatingActionButton(
onPressed: () => _showAddDialog(context, ref),
child: const Icon(Icons.add),
)
: null,
);
}
}
class _PrayerCard extends ConsumerWidget {
final PrayerRequest request;
const _PrayerCard({required this.request});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isAnswered = request.isAnswered;
final dateStr = DateFormat('MMM d, yyyy').format(request.createdAt);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Text(
request.request,
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w500,
decoration:
isAnswered ? TextDecoration.lineThrough : TextDecoration.none,
color: isAnswered
? theme.disabledColor
: theme.textTheme.bodyLarge?.color,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
dateStr,
style: GoogleFonts.outfit(fontSize: 12, color: theme.disabledColor),
),
),
trailing: Checkbox(
value: isAnswered,
activeColor: AppColors.sageGreen,
onChanged: (val) {
ref.read(prayerRequestsProvider.notifier).toggleAnswered(request);
},
),
onLongPress: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Request?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete',
style: TextStyle(color: Colors.red))),
],
),
);
if (confirm == true) {
ref.read(prayerRequestsProvider.notifier).deleteRequest(request.id);
}
},
),
);
}
}

View File

@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
import '../../theme/app_theme.dart';
import '../../widgets/pad_settings_dialog.dart';
class AppearanceScreen extends ConsumerWidget {
const AppearanceScreen({super.key});
@@ -43,41 +42,42 @@ class AppearanceScreen extends ConsumerWidget {
),
const SizedBox(height: 16),
SegmentedButton<AppThemeMode>(
segments: const [
ButtonSegment(
value: AppThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode),
),
ButtonSegment(
value: AppThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode),
),
ButtonSegment(
value: AppThemeMode.system,
label: Text('System'),
icon: Icon(Icons.brightness_auto),
),
],
selected: {currentMode},
onSelectionChanged: (Set<AppThemeMode> newSelection) {
if (newSelection.isNotEmpty) {
ref
.read(userProfileProvider.notifier)
.updateThemeMode(newSelection.first);
}
},
style: SegmentedButton.styleFrom(
fixedSize: const Size.fromHeight(48),
)
),
segments: const [
ButtonSegment(
value: AppThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode),
),
ButtonSegment(
value: AppThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode),
),
ButtonSegment(
value: AppThemeMode.system,
label: Text('System'),
icon: Icon(Icons.brightness_auto),
),
],
selected: {
currentMode
},
onSelectionChanged: (Set<AppThemeMode> newSelection) {
if (newSelection.isNotEmpty) {
ref
.read(userProfileProvider.notifier)
.updateThemeMode(newSelection.first);
}
},
style: SegmentedButton.styleFrom(
fixedSize: const Size.fromHeight(48),
)),
],
);
}
Widget _buildAccentColorSelector(BuildContext context, WidgetRef ref,
String currentAccent) {
Widget _buildAccentColorSelector(
BuildContext context, WidgetRef ref, String currentAccent) {
final accents = [
{'color': AppColors.sageGreen, 'value': '0xFFA8C5A8'},
{'color': AppColors.rose, 'value': '0xFFE8A0B0'},
@@ -125,7 +125,7 @@ class AppearanceScreen extends ConsumerWidget {
boxShadow: [
if (isSelected)
BoxShadow(
color: color.withOpacity(0.4),
color: color.withValues(alpha: 0.4),
blurRadius: 8,
offset: const Offset(0, 4),
)
@@ -141,4 +141,4 @@ class AppearanceScreen extends ConsumerWidget {
],
);
}
}
}

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
class CycleSettingsScreen extends ConsumerStatefulWidget {
@@ -45,8 +44,10 @@ class _CycleSettingsScreenState extends ConsumerState<CycleSettingsScreen> {
final userProfile = ref.read(userProfileProvider);
if (userProfile != null) {
final updatedProfile = userProfile.copyWith(
averageCycleLength: int.tryParse(_cycleLengthController.text) ?? userProfile.averageCycleLength,
averagePeriodLength: int.tryParse(_periodLengthController.text) ?? userProfile.averagePeriodLength,
averageCycleLength: int.tryParse(_cycleLengthController.text) ??
userProfile.averageCycleLength,
averagePeriodLength: int.tryParse(_periodLengthController.text) ??
userProfile.averagePeriodLength,
lastPeriodStartDate: _lastPeriodStartDate,
isIrregularCycle: _isIrregularCycle,
isPadTrackingEnabled: _isPadTrackingEnabled,

View File

@@ -24,28 +24,40 @@ class ExportDataScreen extends ConsumerWidget {
ListTile(
leading: const Icon(Icons.picture_as_pdf),
title: const Text('Export as PDF'),
subtitle: const Text('Generate a printable PDF report of your cycle data.'),
subtitle: const Text(
'Generate a printable PDF report of your cycle data.'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
try {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Generating PDF report...')),
);
await PdfService.generateCycleReport(userProfile, cycleEntries);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PDF report generated successfully!')),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Generating PDF report...')),
);
}
await PdfService.generateCycleReport(
userProfile, cycleEntries);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('PDF report generated successfully!')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to generate PDF: $e')),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to generate PDF: $e')),
);
}
}
},
),
ListTile(
leading: const Icon(Icons.sync),
title: const Text('Sync with Calendar'),
subtitle: const Text('Export to Apple, Google, or Outlook Calendar.'),
subtitle: const Text(
'Export to Apple, Google, or Outlook Calendar.'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
// Show options dialog
@@ -53,7 +65,8 @@ class ExportDataScreen extends ConsumerWidget {
context: context,
builder: (context) => AlertDialog(
title: const Text('Calendar Sync Options'),
content: const Text('Would you like to include predicted future periods for the next 12 months?'),
content: const Text(
'Would you like to include predicted future periods for the next 12 months?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -67,26 +80,37 @@ class ExportDataScreen extends ConsumerWidget {
),
);
if (includePredictions == null) return; // User cancelled dialog (though I didn't add cancel button, tapping outside returns null)
if (includePredictions == null) {
return; // User cancelled dialog
}
try {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Generating calendar file...')),
);
await IcsService.generateCycleCalendar(
cycleEntries,
user: userProfile,
includePredictions: includePredictions
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Calendar file generated! Open it to add to your calendar.')),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Generating calendar file...')),
);
}
await IcsService.generateCycleCalendar(cycleEntries,
user: userProfile,
includePredictions: includePredictions);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Calendar file generated! Open it to add to your calendar.')),
);
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to generate calendar file: $e')),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Failed to generate calendar file: $e')),
);
}
}
},
),

View File

@@ -18,47 +18,60 @@ class GoalSettingsScreen extends ConsumerWidget {
appBar: AppBar(
title: const Text('Cycle Goal'),
),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const Text(
'What is your current goal?',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Select your primary goal to get personalized insights and predictions.',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
_buildGoalOption(
context,
ref,
title: 'Track Cycle Only',
subtitle: 'Monitor period and health without fertility focus',
value: FertilityGoal.justTracking,
groupValue: userProfile.fertilityGoal,
icon: Icons.calendar_today,
),
_buildGoalOption(
context,
ref,
title: 'Achieve Pregnancy',
subtitle: 'Identify fertile window and ovulation',
value: FertilityGoal.tryingToConceive,
groupValue: userProfile.fertilityGoal,
icon: Icons.child_friendly,
),
_buildGoalOption(
context,
ref,
title: 'Avoid Pregnancy',
subtitle: 'Track fertility for natural family planning',
value: FertilityGoal.tryingToAvoid,
groupValue: userProfile.fertilityGoal,
icon: Icons.security,
),
],
body: RadioGroup<FertilityGoal>(
groupValue: userProfile.fertilityGoal,
onChanged: (FertilityGoal? newValue) {
if (newValue != null) {
final currentProfile = ref.read(userProfileProvider);
if (currentProfile != null) {
ref.read(userProfileProvider.notifier).updateProfile(
currentProfile.copyWith(fertilityGoal: newValue),
);
}
}
},
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const Text(
'What is your current goal?',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Select your primary goal to get personalized insights and predictions.',
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
_buildGoalOption(
context,
ref,
title: 'Track Cycle Only',
subtitle: 'Monitor period and health without fertility focus',
value: FertilityGoal.justTracking,
groupValue: userProfile.fertilityGoal,
icon: Icons.calendar_today,
),
_buildGoalOption(
context,
ref,
title: 'Achieve Pregnancy',
subtitle: 'Identify fertile window and ovulation',
value: FertilityGoal.tryingToConceive,
groupValue: userProfile.fertilityGoal,
icon: Icons.child_friendly,
),
_buildGoalOption(
context,
ref,
title: 'Avoid Pregnancy',
subtitle: 'Track fertility for natural family planning',
value: FertilityGoal.tryingToAvoid,
groupValue: userProfile.fertilityGoal,
icon: Icons.security,
),
],
),
),
);
}
@@ -85,20 +98,10 @@ class GoalSettingsScreen extends ConsumerWidget {
),
child: RadioListTile<FertilityGoal>(
value: value,
groupValue: groupValue,
onChanged: (FertilityGoal? newValue) {
if (newValue != null) {
final currentProfile = ref.read(userProfileProvider);
if (currentProfile != null) {
ref.read(userProfileProvider.notifier).updateProfile(
currentProfile.copyWith(fertilityGoal: newValue),
);
}
}
},
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(subtitle),
secondary: Icon(icon, color: isSelected ? Theme.of(context).colorScheme.primary : null),
secondary: Icon(icon,
color: isSelected ? Theme.of(context).colorScheme.primary : null),
activeColor: Theme.of(context).colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/user_profile.dart'; // Import UserProfile
import '../../providers/user_provider.dart';
class NotificationSettingsScreen extends ConsumerWidget {
@@ -26,29 +25,30 @@ class NotificationSettingsScreen extends ConsumerWidget {
children: [
SwitchListTile(
title: const Text('Period Estimate'),
subtitle: const Text('Get notified when your period is predicted to start soon.'),
subtitle: const Text(
'Get notified when your period is predicted to start soon.'),
value: userProfile.notifyPeriodEstimate,
onChanged: (value) async {
await ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(notifyPeriodEstimate: value));
await ref.read(userProfileProvider.notifier).updateProfile(
userProfile.copyWith(notifyPeriodEstimate: value));
},
),
const Divider(),
SwitchListTile(
title: const Text('Period Start'),
subtitle: const Text('Get notified when a period starts (or husband needs to know).'),
subtitle: const Text(
'Get notified when a period starts (or husband needs to know).'),
value: userProfile.notifyPeriodStart,
onChanged: (value) async {
await ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(notifyPeriodStart: value));
await ref.read(userProfileProvider.notifier).updateProfile(
userProfile.copyWith(notifyPeriodStart: value));
},
),
const Divider(),
SwitchListTile(
title: const Text('Low Supply Alert'),
subtitle: const Text('Get notified when pad inventory is running low.'),
subtitle:
const Text('Get notified when pad inventory is running low.'),
value: userProfile.notifyLowSupply,
onChanged: (value) async {
await ref
@@ -58,14 +58,15 @@ class NotificationSettingsScreen extends ConsumerWidget {
),
if (userProfile.isPadTrackingEnabled) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text(
'Pad Change Reminders',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
CheckboxListTile(
@@ -100,12 +101,14 @@ class NotificationSettingsScreen extends ConsumerWidget {
),
CheckboxListTile(
title: const Text('Change Now (Time\'s Up)'),
subtitle: const Text('Get notified when it\'s recommended to change.'),
subtitle:
const Text('Get notified when it\'s recommended to change.'),
value: userProfile.notifyPadNow,
onChanged: (value) async {
if (value != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
userProfile.copyWith(notifyPadNow: value));
await ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(notifyPadNow: value));
}
},
),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:health/health.dart';
import '../../providers/user_provider.dart';
import '../../services/health_service.dart';
@@ -23,24 +23,30 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
}
Future<void> _checkPermissions() async {
final hasPermissions = await _healthService.hasPermissions(_healthService.menstruationDataTypes);
final hasPermissions = await _healthService
.hasPermissions(_healthService.menstruationDataTypes);
setState(() {
_hasPermissions = hasPermissions;
});
}
Future<void> _requestPermissions() async {
final authorized = await _healthService.requestAuthorization(_healthService.menstruationDataTypes);
final authorized = await _healthService
.requestAuthorization(_healthService.menstruationDataTypes);
if (authorized) {
_hasPermissions = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Health app access granted!')),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Health app access granted!')),
);
}
} else {
_hasPermissions = false;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Health app access denied.')),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Health app access denied.')),
);
}
}
setState(() {}); // Rebuild to update UI
}
@@ -48,14 +54,17 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
Future<void> _syncPeriodDays(bool sync) async {
if (sync) {
if (!_hasPermissions) {
// Request permissions if not already granted
final authorized = await _healthService.requestAuthorization(_healthService.menstruationDataTypes);
if (!authorized) {
if (mounted) {
final authorized = await _healthService
.requestAuthorization(_healthService.menstruationDataTypes);
if (mounted) {
if (!authorized) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cannot sync without health app permissions.')),
const SnackBar(
content: Text('Cannot sync without health app permissions.')),
);
return;
}
} else {
return;
}
_hasPermissions = true;
@@ -70,7 +79,8 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
final cycleEntries = ref.read(cycleEntriesProvider);
if (userProfile != null) {
final success = await _healthService.writeMenstruationData(cycleEntries);
final success =
await _healthService.writeMenstruationData(cycleEntries);
if (mounted) {
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -90,7 +100,7 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
);
}
}
setState(() {}); // Rebuild to update UI
setState(() {});
}
Future<void> _setPin() async {
@@ -98,38 +108,40 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
if (pin != null && pin.length >= 4) {
final user = ref.read(userProfileProvider);
if (user != null) {
await ref.read(userProfileProvider.notifier).updateProfile(user.copyWith(privacyPin: pin));
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('PIN Set Successfully')));
await ref
.read(userProfileProvider.notifier)
.updateProfile(user.copyWith(privacyPin: pin));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('PIN Set Successfully')));
}
}
}
}
Future<void> _removePin() async {
final user = ref.read(userProfileProvider);
if (user == null) return;
// Require current PIN
final currentPin = await _showPinDialog(context, title: 'Enter Current PIN');
final currentPin =
await _showPinDialog(context, title: 'Enter Current PIN');
if (currentPin == user.privacyPin) {
await ref.read(userProfileProvider.notifier).updateProfile(
// To clear fields, copyWith might need to handle nulls explicitly if written that way,
// but here we might pass empty string or handle logic.
// Actually, copyWith signature usually ignores nulls.
// I'll assume updating with empty string or handle it in provider,
// but for now let's just use empty string to signify removal if logic supports it.
// Wait, copyWith `privacyPin: privacyPin ?? this.privacyPin`.
// If I pass null, it keeps existing. I can't clear it via standard copyWith unless I change copyWith logic or pass emptiness.
// I'll update the userProfile object directly and save? No, Hive object.
// For now, let's treat "empty string" as no PIN if I can pass it.
user.copyWith(privacyPin: '', isBioProtected: false, isHistoryProtected: false)
);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('PIN Removed')));
await ref.read(userProfileProvider.notifier).updateProfile(user.copyWith(
privacyPin: '', isBioProtected: false, isHistoryProtected: false));
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('PIN Removed')));
}
} else {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Incorrect PIN')));
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Incorrect PIN')));
}
}
}
Future<String?> _showPinDialog(BuildContext context, {required String title}) {
Future<String?> _showPinDialog(BuildContext context,
{required String title}) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
@@ -143,7 +155,9 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
decoration: const InputDecoration(hintText: 'Enter 4-digit PIN'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('OK'),
@@ -155,9 +169,13 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
@override
Widget build(BuildContext context) {
bool syncPeriodToHealth = _hasPermissions;
final user = ref.watch(userProfileProvider);
final hasPin = user?.privacyPin != null && user!.privacyPin!.isNotEmpty;
if (user == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final hasPin = user.privacyPin != null && user.privacyPin!.isNotEmpty;
bool syncPeriodToHealth = _hasPermissions;
return Scaffold(
appBar: AppBar(
@@ -166,111 +184,135 @@ class _PrivacySettingsScreenState extends ConsumerState<PrivacySettingsScreen> {
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Security Section
Text('App Security', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary)),
Text('App Security',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Theme.of(context).colorScheme.primary)),
const SizedBox(height: 8),
ListTile(
title: const Text('Privacy PIN'),
subtitle: Text(hasPin ? 'PIN is set' : 'Protect sensitive data with a PIN'),
trailing: hasPin ? const Icon(Icons.lock, color: Colors.green) : const Icon(Icons.lock_open),
subtitle: Text(
hasPin ? 'PIN is set' : 'Protect sensitive data with a PIN'),
trailing: hasPin
? const Icon(Icons.lock, color: Colors.green)
: const Icon(Icons.lock_open),
onTap: () {
if (hasPin) {
showModalBottomSheet(context: context, builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Change PIN'),
onTap: () {
Navigator.pop(context);
_setPin();
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Remove PIN'),
onTap: () {
Navigator.pop(context);
_removePin();
},
),
],
));
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Change PIN'),
onTap: () {
Navigator.pop(context);
_setPin();
},
),
ListTile(
leading:
const Icon(Icons.delete, color: Colors.red),
title: const Text('Remove PIN'),
onTap: () {
Navigator.pop(context);
_removePin();
},
),
],
));
} else {
_setPin();
}
},
),
if (hasPin) ...[
SwitchListTile(
title: const Text('Use Biometrics'),
subtitle: const Text('Unlock with FaceID / Fingerprint'),
value: user?.isBioProtected ?? false,
value: user.isBioProtected,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isBioProtected: val));
ref
.read(userProfileProvider.notifier)
.updateProfile(user.copyWith(isBioProtected: val));
},
),
const Divider(),
const Text('Protected Features', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
const Text('Protected Features',
style:
TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
SwitchListTile(
title: const Text('Daily Logs'),
value: user?.isLogProtected ?? false,
value: user.isLogProtected,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isLogProtected: val));
ref
.read(userProfileProvider.notifier)
.updateProfile(user.copyWith(isLogProtected: val));
},
),
SwitchListTile(
SwitchListTile(
title: const Text('Calendar'),
value: user?.isCalendarProtected ?? false,
value: user.isCalendarProtected,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isCalendarProtected: val));
ref
.read(userProfileProvider.notifier)
.updateProfile(user.copyWith(isCalendarProtected: val));
},
),
SwitchListTile(
SwitchListTile(
title: const Text('Supplies / Pad Tracker'),
value: user?.isSuppliesProtected ?? false,
value: user.isSuppliesProtected,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isSuppliesProtected: val));
ref
.read(userProfileProvider.notifier)
.updateProfile(user.copyWith(isSuppliesProtected: val));
},
),
SwitchListTile(
title: const Text('Cycle History'),
value: user?.isHistoryProtected ?? false,
value: user.isHistoryProtected,
onChanged: (val) {
ref.read(userProfileProvider.notifier).updateProfile(user!.copyWith(isHistoryProtected: val));
ref
.read(userProfileProvider.notifier)
.updateProfile(user.copyWith(isHistoryProtected: val));
},
),
],
const Divider(height: 32),
// Health Section
Text('Health App Integration', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary)),
Text('Health App Integration',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Theme.of(context).colorScheme.primary)),
const SizedBox(height: 8),
ListTile(
title: const Text('Health Source'),
subtitle: _hasPermissions
? const Text('Connected to Health App.')
: const Text('Not connected. Tap to grant access.'),
trailing: _hasPermissions ? const Icon(Icons.check_circle, color: Colors.green) : const Icon(Icons.warning, color: Colors.orange),
trailing: _hasPermissions
? const Icon(Icons.check_circle, color: Colors.green)
: const Icon(Icons.warning, color: Colors.orange),
onTap: _requestPermissions,
),
SwitchListTile(
title: const Text('Sync Period Days'),
subtitle: const Text('Automatically sync period dates.'),
value: syncPeriodToHealth,
onChanged: _hasPermissions ? (value) async {
if (value) {
await _syncPeriodDays(true);
} else {
await _syncPeriodDays(false);
}
setState(() {
syncPeriodToHealth = value; // Update local state for toggle
});
} : null,
onChanged: _hasPermissions
? (value) async {
if (value) {
await _syncPeriodDays(true);
} else {
await _syncPeriodDays(false);
}
setState(() {
syncPeriodToHealth = value;
});
}
: null,
),
],
),

View File

@@ -17,70 +17,85 @@ class RelationshipSettingsScreen extends ConsumerWidget {
),
body: userProfile == null
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const Text(
'Select your current relationship status to customize your experience.',
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
// Sample Data Button
Center(
child: TextButton.icon(
onPressed: () {
final user = ref.read(userProfileProvider);
if (user != null) {
final samplePlan = TeachingPlan.create(
topic: 'Walking in Love',
scriptureReference: 'Ephesians 5:1-2',
notes: 'As Christ loved us and gave himself up for us, a fragrant offering and sacrifice to God. Let our marriage reflect this sacrificial love.',
date: DateTime.now(),
);
final List<TeachingPlan> updatedPlans = [...(user.teachingPlans ?? []), samplePlan];
ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedPlans)
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sample Teaching Plan Loaded! Check Devotional page.')),
);
}
},
icon: const Icon(Icons.science_outlined),
label: const Text('Load Sample Teaching Plan (Demo)'),
: RadioGroup<RelationshipStatus>(
groupValue: userProfile.relationshipStatus,
onChanged: (RelationshipStatus? newValue) {
if (newValue != null) {
ref
.read(userProfileProvider.notifier)
.updateRelationshipStatus(newValue);
}
},
child: ListView(
padding: const EdgeInsets.all(16.0),
children: [
const Text(
'Select your current relationship status to customize your experience.',
style: TextStyle(fontSize: 16),
),
),
const SizedBox(height: 24),
_buildOption(
context,
ref,
title: 'Single',
subtitle: 'Tracking for potential future',
value: RelationshipStatus.single,
groupValue: userProfile.relationshipStatus,
icon: Icons.person_outline,
),
_buildOption(
context,
ref,
title: 'Engaged',
subtitle: 'Preparing for marriage',
value: RelationshipStatus.engaged,
groupValue: userProfile.relationshipStatus,
icon: Icons.favorite_border,
),
_buildOption(
context,
ref,
title: 'Married',
subtitle: 'Tracking together with husband',
value: RelationshipStatus.married,
groupValue: userProfile.relationshipStatus,
icon: Icons.favorite,
),
],
const SizedBox(height: 16),
// Sample Data Button
Center(
child: TextButton.icon(
onPressed: () {
final user = ref.read(userProfileProvider);
if (user != null) {
final samplePlan = TeachingPlan.create(
topic: 'Walking in Love',
scriptureReference: 'Ephesians 5:1-2',
notes:
'As Christ loved us and gave himself up for us, a fragrant offering and sacrifice to God. Let our marriage reflect this sacrificial love.',
date: DateTime.now(),
);
final List<TeachingPlan> updatedPlans = [
...(user.teachingPlans ?? []),
samplePlan
];
ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(teachingPlans: updatedPlans));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Sample Teaching Plan Loaded! Check Devotional page.')),
);
}
},
icon: const Icon(Icons.science_outlined),
label: const Text('Load Sample Teaching Plan (Demo)'),
),
),
const SizedBox(height: 24),
_buildOption(
context,
ref,
title: 'Single',
subtitle: 'Tracking for potential future',
value: RelationshipStatus.single,
groupValue: userProfile.relationshipStatus,
icon: Icons.person_outline,
),
_buildOption(
context,
ref,
title: 'Engaged',
subtitle: 'Preparing for marriage',
value: RelationshipStatus.engaged,
groupValue: userProfile.relationshipStatus,
icon: Icons.favorite_border,
),
_buildOption(
context,
ref,
title: 'Married',
subtitle: 'Tracking together with husband',
value: RelationshipStatus.married,
groupValue: userProfile.relationshipStatus,
icon: Icons.favorite,
),
],
),
),
);
}
@@ -98,7 +113,7 @@ class RelationshipSettingsScreen extends ConsumerWidget {
return Card(
elevation: isSelected ? 2 : 0,
shape: RoundedRectangleBorder(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: isSelected
? BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
@@ -106,17 +121,10 @@ class RelationshipSettingsScreen extends ConsumerWidget {
),
child: RadioListTile<RelationshipStatus>(
value: value,
groupValue: groupValue,
onChanged: (RelationshipStatus? newValue) {
if (newValue != null) {
ref
.read(userProfileProvider.notifier)
.updateRelationshipStatus(newValue);
}
},
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(subtitle),
secondary: Icon(icon, color: isSelected ? Theme.of(context).colorScheme.primary : null),
secondary: Icon(icon,
color: isSelected ? Theme.of(context).colorScheme.primary : null),
activeColor: Theme.of(context).colorScheme.primary,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),

View File

@@ -1,159 +1,238 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_theme.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
import '../../services/sync_service.dart';
class SharingSettingsScreen extends ConsumerWidget {
class SharingSettingsScreen extends ConsumerStatefulWidget {
const SharingSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userProfile = ref.watch(userProfileProvider);
ConsumerState<SharingSettingsScreen> createState() =>
_SharingSettingsScreenState();
}
if (userProfile == null) {
return Scaffold(
appBar: AppBar(
title: const Text('Sharing Settings'),
),
body: const Center(child: CircularProgressIndicator()),
);
class _SharingSettingsScreenState extends ConsumerState<SharingSettingsScreen> {
final _partnerIdController = TextEditingController();
bool _isLoading = false;
String? _errorText;
@override
void initState() {
super.initState();
final user = ref.read(userProfileProvider);
if (user != null && user.partnerId != null) {
_partnerIdController.text = user.partnerId!;
}
}
@override
void dispose() {
_partnerIdController.dispose();
super.dispose();
}
Future<void> _savePartnerId() async {
final input = _partnerIdController.text.trim();
if (input.isEmpty) return;
final user = ref.read(userProfileProvider);
if (user != null) {
setState(() {
_isLoading = true;
_errorText = null;
});
try {
// 1. Preview first
final result = await SyncService().previewPartnerId(input);
final partnerName = result['partnerName'];
if (!mounted) return;
// 2. Show Confirmation Dialog
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirm Partner'),
content: Text(
'Found partner: $partnerName.\n\nDo you want to link with them?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Confirm Link'),
),
],
),
);
if (confirm != true) {
if (mounted) setState(() => _isLoading = false);
return;
}
// 3. Perform Link
final verifyResult =
await SyncService().verifyPartnerId(user.id, input);
if (!mounted) return;
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(
partnerId: input,
partnerName: verifyResult['partnerName'],
),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connected to ${verifyResult['partnerName']}!')),
);
setState(() {
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_errorText = e.toString().replaceAll('Exception:', '').trim();
});
}
}
}
}
@override
Widget build(BuildContext context) {
final user = ref.watch(userProfileProvider);
if (user == null) return const Scaffold();
return Scaffold(
appBar: AppBar(
title: const Text('Sharing Settings'),
),
appBar: AppBar(title: const Text('Sharing & Partner')),
body: ListView(
padding: const EdgeInsets.all(16.0),
children: [
ListTile(
leading: const Icon(Icons.link),
title: const Text('Link with Husband'),
subtitle: Text(userProfile.partnerName != null ? 'Linked to ${userProfile.partnerName}' : 'Not linked'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showShareDialog(context, ref),
),
_buildPartnerSection(user),
const Divider(),
SwitchListTile(
title: const Text('Share Moods'),
value: userProfile.shareMoods,
onChanged: (value) {
ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(shareMoods: value));
},
),
SwitchListTile(
title: const Text('Share Symptoms'),
value: userProfile.shareSymptoms,
onChanged: (value) {
ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(shareSymptoms: value));
},
),
SwitchListTile(
title: const Text('Share Cravings'),
value: userProfile.shareCravings,
onChanged: (value) {
ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(shareCravings: value));
},
),
SwitchListTile(
title: const Text('Share Energy Levels'),
value: userProfile.shareEnergyLevels,
onChanged: (value) {
ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(shareEnergyLevels: value));
},
),
SwitchListTile(
title: const Text('Share Sleep Data'),
value: userProfile.shareSleep,
onChanged: (value) {
ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(shareSleep: value));
},
),
SwitchListTile(
title: const Text('Share Intimacy Details'),
value: userProfile.shareIntimacy,
onChanged: (value) {
ref
.read(userProfileProvider.notifier)
.updateProfile(userProfile.copyWith(shareIntimacy: value));
},
),
_buildSharingToggles(user),
],
),
);
}
void _showShareDialog(BuildContext context, WidgetRef ref) {
// Generate a simple pairing code
final userProfile = ref.read(userProfileProvider);
final pairingCode = userProfile?.id?.substring(0, 6).toUpperCase() ?? 'ABC123';
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.share_outlined, color: AppColors.navyBlue),
const SizedBox(width: 8),
const Text('Share with Husband'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Share this code with your husband so he can connect to your cycle data:',
style: GoogleFonts.outfit(fontSize: 14, color: AppColors.warmGray),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: AppColors.navyBlue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.navyBlue.withOpacity(0.3)),
),
child: SelectableText(
pairingCode,
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: 4,
color: AppColors.navyBlue,
Widget _buildPartnerSection(user) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Partner Connection',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
// My ID
Text('My ID (Share this with your partner):',
style: Theme.of(context).textTheme.bodySmall),
Row(
children: [
Expanded(
child: SelectableText(
user.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
const SizedBox(height: 16),
Text(
'He can enter this in his app under Settings > Connect with Wife.',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
textAlign: TextAlign.center,
),
],
),
actions: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.navyBlue,
foregroundColor: Colors.white,
),
child: const Text('Done'),
IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: user.id));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ID copied to clipboard')),
);
},
),
],
),
const SizedBox(height: 24),
// Partner ID Input
TextField(
controller: _partnerIdController,
decoration: InputDecoration(
labelText: 'Enter Partner ID',
border: const OutlineInputBorder(),
hintText: 'Paste partner ID here',
errorText: _errorText,
),
enabled: !_isLoading,
),
const SizedBox(height: 8),
if (_isLoading)
const Padding(
padding: EdgeInsets.all(8.0),
child: Center(child: CircularProgressIndicator()),
)
else
ElevatedButton(
onPressed: _savePartnerId,
child: const Text('Link Partner'),
),
],
),
);
}
Widget _buildSharingToggles(user) {
final notifier = ref.read(userProfileProvider.notifier);
final isPadTrackingEnabled = user.isPadTrackingEnabled ?? false;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('What to Share',
style: Theme.of(context).textTheme.titleMedium),
),
SwitchListTile(
title: const Text('Share Moods'),
value: user.shareMoods,
onChanged: (val) =>
notifier.updateProfile(user.copyWith(shareMoods: val)),
),
SwitchListTile(
title: const Text('Share Symptoms'),
value: user.shareSymptoms,
onChanged: (val) =>
notifier.updateProfile(user.copyWith(shareSymptoms: val)),
),
SwitchListTile(
title: const Text('Share Energy Levels'),
value: user.shareEnergyLevels,
onChanged: (val) =>
notifier.updateProfile(user.copyWith(shareEnergyLevels: val)),
),
SwitchListTile(
title: const Text('Share Pad Supplies'),
subtitle: !isPadTrackingEnabled
? const Text('Enable Pad Tracking to share supplies')
: null,
value: isPadTrackingEnabled && (user.sharePadSupplies ?? true),
onChanged: isPadTrackingEnabled
? (val) =>
notifier.updateProfile(user.copyWith(sharePadSupplies: val))
: null,
),
SwitchListTile(
title: const Text('Share Intimacy'),
subtitle: const Text('Always shared with your husband'),
value: true, // Always true
onChanged: null, // Disabled - always on
),
],
);
}
}

View File

@@ -5,10 +5,9 @@ import '../../theme/app_theme.dart';
import '../../models/user_profile.dart';
import '../../providers/user_provider.dart';
import '../../services/notification_service.dart';
import '../../widgets/pad_settings_dialog.dart'; // We can reuse the logic, but maybe embed it directly or just link it.
// Actually, let's rebuild the UI here properly as a screen instead of a dialog,
// or for now, since we already have the dialog logic working well, let's just
// have this screen trigger the dialog or embed the same widgets.
// or for now, since we already have the dialog logic working well, let's just
// have this screen trigger the dialog or embed the same widgets.
// However, the user asked to "make a new setting page", so a full screen is better.
// I'll copy the logic from the dialog into this screen for a seamless experience.
@@ -16,16 +15,18 @@ class SuppliesSettingsScreen extends ConsumerStatefulWidget {
const SuppliesSettingsScreen({super.key});
@override
ConsumerState<SuppliesSettingsScreen> createState() => _SuppliesSettingsScreenState();
ConsumerState<SuppliesSettingsScreen> createState() =>
_SuppliesSettingsScreenState();
}
class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen> {
class _SuppliesSettingsScreenState
extends ConsumerState<SuppliesSettingsScreen> {
bool _isTrackingEnabled = false;
int _typicalFlow = 2;
int _typicalFlow = 2;
bool _isAutoInventoryEnabled = true;
bool _showPadTimerMinutes = true;
bool _showPadTimerSeconds = false;
// Inventory
List<SupplyItem> _supplies = [];
int _lowInventoryThreshold = 5;
@@ -41,7 +42,7 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
_lowInventoryThreshold = user.lowInventoryThreshold;
_showPadTimerMinutes = user.showPadTimerMinutes;
_showPadTimerSeconds = user.showPadTimerSeconds;
// Load supplies
if (user.padSupplies != null) {
_supplies = List.from(user.padSupplies!);
@@ -65,19 +66,21 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
padInventoryCount: totalCount,
lowInventoryThreshold: _lowInventoryThreshold,
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
await ref
.read(userProfileProvider.notifier)
.updateProfile(updatedProfile);
// Check for Low Supply Alert
if (updatedProfile.notifyLowSupply &&
if (updatedProfile.notifyLowSupply &&
totalCount <= updatedProfile.lowInventoryThreshold) {
NotificationService().showLocalNotification(
id: 2001,
title: 'Low Pad Supply',
body: 'Your inventory is low ($totalCount left). Time to restock!',
);
NotificationService().showLocalNotification(
id: 2001,
title: 'Low Pad Supply',
body: 'Your inventory is low ($totalCount left). Time to restock!',
);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Preferences saved')),
@@ -121,7 +124,7 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Toggle
// Toggle
Row(
children: [
Expanded(
@@ -130,21 +133,21 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Switch(
value: _isTrackingEnabled,
onChanged: (val) => setState(() => _isTrackingEnabled = val),
activeColor: AppColors.menstrualPhase,
activeThumbColor: AppColors.menstrualPhase,
),
],
),
if (_isTrackingEnabled) ...[
const Divider(height: 32),
// Inventory Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -161,7 +164,8 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
onPressed: () => _addOrEditSupply(),
icon: const Icon(Icons.add),
label: const Text('Add Item'),
style: TextButton.styleFrom(foregroundColor: AppColors.menstrualPhase),
style: TextButton.styleFrom(
foregroundColor: AppColors.menstrualPhase),
),
],
),
@@ -170,7 +174,7 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
@@ -193,10 +197,12 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
tileColor: Theme.of(context).cardTheme.color,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.black.withOpacity(0.05)),
side: BorderSide(
color: Colors.black.withValues(alpha: 0.05)),
),
leading: CircleAvatar(
backgroundColor: AppColors.menstrualPhase.withOpacity(0.1),
backgroundColor:
AppColors.menstrualPhase.withValues(alpha: 0.1),
child: Text(
item.count.toString(),
style: GoogleFonts.outfit(
@@ -205,10 +211,14 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
),
),
),
title: Text(item.brand, style: GoogleFonts.outfit(fontWeight: FontWeight.w600)),
subtitle: Text(item.type.label, style: GoogleFonts.outfit(fontSize: 12)),
title: Text(item.brand,
style:
GoogleFonts.outfit(fontWeight: FontWeight.w600)),
subtitle: Text(item.type.label,
style: GoogleFonts.outfit(fontSize: 12)),
trailing: IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
icon:
const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () {
setState(() {
_supplies.removeAt(index);
@@ -221,7 +231,7 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
),
const Divider(height: 32),
// Typical Flow
Text(
'Typical Flow Intensity',
@@ -234,7 +244,9 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
const SizedBox(height: 8),
Row(
children: [
Text('Light', style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray)),
Text('Light',
style: GoogleFonts.outfit(
fontSize: 12, color: AppColors.warmGray)),
Expanded(
child: Slider(
value: _typicalFlow.toDouble(),
@@ -242,42 +254,46 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
max: 5,
divisions: 4,
activeColor: AppColors.menstrualPhase,
onChanged: (val) => setState(() => _typicalFlow = val.round()),
onChanged: (val) =>
setState(() => _typicalFlow = val.round()),
),
),
Text('Heavy', style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray)),
Text('Heavy',
style: GoogleFonts.outfit(
fontSize: 12, color: AppColors.warmGray)),
],
),
Center(
child: Text(
'$_typicalFlow/5',
style: GoogleFonts.outfit(
fontWeight: FontWeight.bold,
color: AppColors.menstrualPhase
)
),
child: Text('$_typicalFlow/5',
style: GoogleFonts.outfit(
fontWeight: FontWeight.bold,
color: AppColors.menstrualPhase)),
),
const SizedBox(height: 24),
// Auto Deduct Toggle
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'Auto-deduct on Log',
style: GoogleFonts.outfit(fontWeight: FontWeight.w500, color: AppColors.charcoal),
),
subtitle: Text(
'Reduce count when you log a pad',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
),
value: _isAutoInventoryEnabled,
onChanged: (val) => setState(() => _isAutoInventoryEnabled = val),
activeColor: AppColors.menstrualPhase,
contentPadding: EdgeInsets.zero,
title: Text(
'Auto-deduct on Log',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
),
subtitle: Text(
'Reduce count when you log a pad',
style: GoogleFonts.outfit(
fontSize: 12, color: AppColors.warmGray),
),
value: _isAutoInventoryEnabled,
onChanged: (val) =>
setState(() => _isAutoInventoryEnabled = val),
activeThumbColor: AppColors.menstrualPhase,
),
const Divider(height: 32),
Text(
'Timer Display Settings',
style: GoogleFonts.outfit(
@@ -289,35 +305,40 @@ class _SuppliesSettingsScreenState extends ConsumerState<SuppliesSettingsScreen>
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'Show Minutes',
style: GoogleFonts.outfit(fontWeight: FontWeight.w500, color: AppColors.charcoal),
),
value: _showPadTimerMinutes,
onChanged: (val) {
setState(() {
_showPadTimerMinutes = val;
if (!val) {
_showPadTimerSeconds = false;
}
});
},
activeColor: AppColors.menstrualPhase,
contentPadding: EdgeInsets.zero,
title: Text(
'Show Minutes',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
),
value: _showPadTimerMinutes,
onChanged: (val) {
setState(() {
_showPadTimerMinutes = val;
if (!val) {
_showPadTimerSeconds = false;
}
});
},
activeThumbColor: AppColors.menstrualPhase,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'Show Seconds',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: _showPadTimerMinutes ? AppColors.charcoal : AppColors.warmGray
),
),
value: _showPadTimerSeconds,
onChanged: _showPadTimerMinutes ? (val) => setState(() => _showPadTimerSeconds = val) : null,
activeColor: AppColors.menstrualPhase,
contentPadding: EdgeInsets.zero,
title: Text(
'Show Seconds',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: _showPadTimerMinutes
? AppColors.charcoal
: AppColors.warmGray),
),
value: _showPadTimerSeconds,
onChanged: _showPadTimerMinutes
? (val) => setState(() => _showPadTimerSeconds = val)
: null,
activeThumbColor: AppColors.menstrualPhase,
),
],
],
@@ -345,11 +366,12 @@ class _SupplyDialogState extends State<_SupplyDialog> {
@override
void initState() {
super.initState();
_brandController = TextEditingController(text: widget.initialItem?.brand ?? '');
_brandController =
TextEditingController(text: widget.initialItem?.brand ?? '');
_type = widget.initialItem?.type ?? PadType.regular;
_count = widget.initialItem?.count ?? 0;
}
@override
void dispose() {
_brandController.dispose();
@@ -371,11 +393,13 @@ class _SupplyDialogState extends State<_SupplyDialog> {
),
const SizedBox(height: 16),
DropdownButtonFormField<PadType>(
value: _type,
items: PadType.values.map((t) => DropdownMenuItem(
value: t,
child: Text(t.label),
)).toList(),
initialValue: _type,
items: PadType.values
.map((t) => DropdownMenuItem(
value: t,
child: Text(t.label),
))
.toList(),
onChanged: (val) => setState(() => _type = val!),
decoration: const InputDecoration(labelText: 'Type'),
),
@@ -383,14 +407,18 @@ class _SupplyDialogState extends State<_SupplyDialog> {
TextField(
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Quantity'),
controller: TextEditingController(text: _count.toString()), // Hacky for demo, binding needed properly
controller: TextEditingController(
text: _count
.toString()), // Hacky for demo, binding needed properly
onChanged: (val) => _count = int.tryParse(val) ?? 0,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel')),
ElevatedButton(
onPressed: () {
if (_brandController.text.isEmpty) return;

View File

@@ -9,13 +9,15 @@ import '../providers/user_provider.dart';
import 'husband/husband_home_screen.dart';
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
final bool isStartup;
const SplashScreen({super.key, this.isStartup = false});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerProviderStateMixin {
class _SplashScreenState extends ConsumerState<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
@@ -44,16 +46,18 @@ class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerPr
_controller.forward();
// Navigate after splash
Future.delayed(const Duration(milliseconds: 1200), () {
_navigateToNextScreen();
});
// Navigate after splash ONLY if not managed by AppStartupWidget
if (!widget.isStartup) {
Future.delayed(const Duration(milliseconds: 1200), () {
_navigateToNextScreen();
});
}
}
void _navigateToNextScreen() {
final user = ref.read(userProfileProvider);
final hasProfile = user != null;
Widget nextScreen;
if (!hasProfile) {
nextScreen = const OnboardingScreen();
@@ -103,7 +107,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerPr
gradient: LinearGradient(
colors: [
AppColors.blushPink,
AppColors.rose.withOpacity(0.8),
AppColors.rose.withValues(alpha: 0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -111,7 +115,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerPr
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: AppColors.rose.withOpacity(0.3),
color: AppColors.rose.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
@@ -124,7 +128,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerPr
),
),
const SizedBox(height: 24),
// App Name placeholder
Text(
'Period Tracker',
@@ -135,7 +139,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerPr
),
),
const SizedBox(height: 8),
// Tagline
Text(
'Faith-Centered Wellness',
@@ -147,7 +151,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> with SingleTickerPr
),
),
const SizedBox(height: 48),
// Scripture
Padding(
padding: const EdgeInsets.symmetric(horizontal: 48),

View File

@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/user_profile.dart';
import '../models/scripture.dart';
import '../theme/app_theme.dart';
import '../providers/user_provider.dart';
class BibleUtils {
static Future<void> showTranslationPicker(BuildContext context, WidgetRef ref) async {
static Future<void> showTranslationPicker(
BuildContext context, WidgetRef ref) async {
final user = ref.read(userProfileProvider);
if (user == null) return;
@@ -28,21 +28,21 @@ class BibleUtils {
),
),
...BibleTranslation.values.map((t) => ListTile(
title: Text(t.label),
trailing: user.bibleTranslation == t
? const Icon(Icons.check, color: AppColors.sageGreen)
: null,
onTap: () => Navigator.pop(context, t),
)),
title: Text(t.label),
trailing: user.bibleTranslation == t
? const Icon(Icons.check, color: AppColors.sageGreen)
: null,
onTap: () => Navigator.pop(context, t),
)),
],
),
),
);
if (selected != null) {
await ref.read(userProfileProvider.notifier).updateProfile(
user.copyWith(bibleTranslation: selected)
);
await ref
.read(userProfileProvider.notifier)
.updateProfile(user.copyWith(bibleTranslation: selected));
}
}
}

View File

@@ -1,7 +1,6 @@
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/foundation.dart';
import 'package:xml/xml.dart';
import '../models/scripture.dart'; // Assuming Scripture model might need BibleTranslation
// Helper for background XML parsing
XmlDocument parseXmlString(String xml) {
@@ -14,18 +13,24 @@ class BibleXmlParser {
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',
'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',
'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',
'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',
@@ -50,7 +55,6 @@ class BibleXmlParser {
};
}
// Cache for parsed XML documents to avoid reloading/reparsing
static final Map<String, XmlDocument> _xmlCache = {};
@@ -59,8 +63,8 @@ class BibleXmlParser {
if (_xmlCache.containsKey(assetPath)) {
return _xmlCache[assetPath]!;
}
print('Loading and parsing XML asset: $assetPath'); // Debug log
debugPrint('Loading and parsing XML asset: $assetPath'); // Debug log
final String xmlString = await rootBundle.loadString(assetPath);
final document = await compute(parseXmlString, xmlString);
_xmlCache[assetPath] = document;
@@ -71,13 +75,11 @@ class BibleXmlParser {
/// 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) {
String? getVerseFromXml(
XmlDocument document, String bookName, int chapterNum, int verseNum) {
// Standardize book name for lookup
String lookupBookName = _bookAbbreviations[bookName.toLowerCase()] ?? bookName;
String lookupBookName =
_bookAbbreviations[bookName.toLowerCase()] ?? bookName;
// Use root element to avoid full document search
XmlElement root = document.rootElement;
@@ -88,7 +90,7 @@ class BibleXmlParser {
(element) {
final nameAttr = element.getAttribute('bname');
return nameAttr?.toLowerCase() == lookupBookName.toLowerCase() ||
nameAttr?.toLowerCase() == bookName.toLowerCase();
nameAttr?.toLowerCase() == bookName.toLowerCase();
},
orElse: () => XmlElement(XmlName('notfound')),
);
@@ -99,54 +101,54 @@ class BibleXmlParser {
(element) {
final nameAttr = element.getAttribute('n');
return nameAttr?.toLowerCase() == lookupBookName.toLowerCase() ||
nameAttr?.toLowerCase() == bookName.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
// debugPrint('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')),
);
(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')),
);
(element) => element.getAttribute('n') == chapterNum.toString(),
orElse: () => XmlElement(XmlName('notfound')),
);
}
if (chapterElement.name.local == 'notfound') {
// print('Chapter "$chapterNum" not found for book "$bookName".');
// debugPrint('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')),
);
(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')),
);
(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".');
// debugPrint('Verse "$verseNum" not found for Chapter "$chapterNum", book "$bookName".');
return null;
}
@@ -158,7 +160,7 @@ class BibleXmlParser {
Future<String?> getVerseFromAsset(String assetPath, String reference) async {
final parsedRef = parseReference(reference);
if (parsedRef == null) {
print('Invalid reference format: $reference');
debugPrint('Invalid reference format: $reference');
return null;
}

View File

@@ -36,9 +36,10 @@ class CycleInfo {
class CycleService {
/// Calculates the current cycle information based on user profile
/// Calculates the current cycle information based on user profile and cycle entries
static CycleInfo calculateCycleInfo(UserProfile? user, List<CycleEntry> entries) {
static CycleInfo calculateCycleInfo(
UserProfile? user, List<CycleEntry> entries) {
if (user == null) {
return CycleInfo(
return CycleInfo(
phase: CyclePhase.follicular,
dayOfCycle: 1,
daysUntilPeriod: 28,
@@ -51,69 +52,70 @@ class CycleService {
// Find the most recent period start from entries if available and more recent
// We look for a sequence of period days and take the first one
if (entries.isNotEmpty) {
final sortedEntries = List<CycleEntry>.from(entries)..sort((a, b) => b.date.compareTo(a.date));
final sortedEntries = List<CycleEntry>.from(entries)
..sort((a, b) => b.date.compareTo(a.date));
for (var entry in sortedEntries) {
if (entry.isPeriodDay) {
// Check if this is a "start" of a period (previous day was not period or no entry)
// Simplified logic: Just take the most recent period day found and assume it's part of the current/last period
// A better approach for "Day 1" is to find the First day of the contiguous block.
// However, for basic calculation, if we find a period day at Date X,
// and today is Date Y.
// If X is very recent, we are in the period.
// Correct logic: Identify the START DATE of the last period group.
// 1. Find the latest period entry.
// 2. Look backwards from there as long as there are consecutive period days.
DateTime potentialStart = entry.date;
// Check if we have a period day "tomorrow" relative to this entry? No, we are iterating backwards (descending).
// So if we found a period day, we need to check if the NEXT entry (which is earlier in time) is also a period day.
// If so, that earlier day is the better candidate for "Start".
// Let's iterate linearly.
// Since we sorted DESC, `entry` is the LATEST period day.
// We need to see if there are consecutive period days before it.
// But wait, the user might have logged Day 1, Day 2, Day 3.
// `entry` will be Day 3.
// We want Day 1.
// Let's try a different approach:
// Get all period days sorted DESC.
final periodDays = sortedEntries.where((e) => e.isPeriodDay).toList();
if (periodDays.isNotEmpty) {
// Take the latest block
DateTime latestParams = periodDays.first.date;
// Now find the "start" of this block
// We iterate backwards from the *latest* date found
DateTime currentSearch = latestParams;
DateTime startOfBlock = latestParams;
// Check if we have an entry for the day before
bool foundPrevious = true;
while(foundPrevious) {
final dayBefore = currentSearch.subtract(const Duration(days: 1));
final hasDayBefore = periodDays.any((e) => DateUtils.isSameDay(e.date, dayBefore));
if (hasDayBefore) {
currentSearch = dayBefore;
startOfBlock = dayBefore;
} else {
foundPrevious = false;
}
}
// If this calculated start is more recent than the user profile one, use it
if (lastPeriodStart == null || startOfBlock.isAfter(lastPeriodStart)) {
lastPeriodStart = startOfBlock;
}
}
break; // We only care about the most recent period block
// Check if this is a "start" of a period (previous day was not period or no entry)
// Simplified logic: Just take the most recent period day found and assume it's part of the current/last period
// A better approach for "Day 1" is to find the First day of the contiguous block.
// However, for basic calculation, if we find a period day at Date X,
// and today is Date Y.
// If X is very recent, we are in the period.
// Correct logic: Identify the START DATE of the last period group.
// 1. Find the latest period entry.
// 2. Look backwards from there as long as there are consecutive period days.
// Check if we have a period day "tomorrow" relative to this entry? No, we are iterating backwards (descending).
// So if we found a period day, we need to check if the NEXT entry (which is earlier in time) is also a period day.
// If so, that earlier day is the better candidate for "Start".
// Let's iterate linearly.
// Since we sorted DESC, `entry` is the LATEST period day.
// We need to see if there are consecutive period days before it.
// But wait, the user might have logged Day 1, Day 2, Day 3.
// `entry` will be Day 3.
// We want Day 1.
// Let's try a different approach:
// Get all period days sorted DESC.
final periodDays = sortedEntries.where((e) => e.isPeriodDay).toList();
if (periodDays.isNotEmpty) {
// Take the latest block
DateTime latestParams = periodDays.first.date;
// Now find the "start" of this block
// We iterate backwards from the *latest* date found
DateTime currentSearch = latestParams;
DateTime startOfBlock = latestParams;
// Check if we have an entry for the day before
bool foundPrevious = true;
while (foundPrevious) {
final dayBefore = currentSearch.subtract(const Duration(days: 1));
final hasDayBefore =
periodDays.any((e) => DateUtils.isSameDay(e.date, dayBefore));
if (hasDayBefore) {
currentSearch = dayBefore;
startOfBlock = dayBefore;
} else {
foundPrevious = false;
}
}
// If this calculated start is more recent than the user profile one, use it
if (lastPeriodStart == null ||
startOfBlock.isAfter(lastPeriodStart)) {
lastPeriodStart = startOfBlock;
}
}
break; // We only care about the most recent period block
}
}
}
@@ -126,52 +128,70 @@ class CycleService {
isPeriodExpected: false,
);
}
// Check if the calculated last period is in the future (invalid state validation)
if (lastPeriodStart.isAfter(DateTime.now())) {
// Fallback to today if data is weird, or just use it (maybe user logged future?)
// Let's stick to standard logic:
// Fallback to today if data is weird, or just use it (maybe user logged future?)
// Let's stick to standard logic:
}
final cycleLength = user.averageCycleLength;
final now = DateTime.now();
// Normalize dates to midnight for accurate day counting
final startOfToday = DateTime(now.year, now.month, now.day);
final startOfCycle = DateTime(lastPeriodStart.year, lastPeriodStart.month, lastPeriodStart.day);
final daysSinceLastPeriod = startOfToday.difference(startOfCycle).inDays + 1;
final startOfCycle = DateTime(
lastPeriodStart.year, lastPeriodStart.month, lastPeriodStart.day);
final daysSinceLastPeriod =
startOfToday.difference(startOfCycle).inDays + 1;
// If negative (future date), handle gracefully
if (daysSinceLastPeriod < 1) {
return CycleInfo(
return CycleInfo(
phase: CyclePhase.follicular,
dayOfCycle: 1,
daysUntilPeriod: cycleLength,
isPeriodExpected: false,
);
}
// Handle cases where last period was long ago (more than one cycle)
final dayOfCycle = ((daysSinceLastPeriod - 1) % cycleLength) + 1;
final daysUntilPeriod = cycleLength - dayOfCycle;
final int calculatedDayOfCycle =
((daysSinceLastPeriod - 1) % cycleLength) + 1;
// Check if we are in the predicted menstrual phase but no period is logged
bool isPeriodLoggedToday =
entries.any((e) => DateUtils.isSameDay(e.date, now) && e.isPeriodDay);
CyclePhase phase;
if (dayOfCycle <= user.averagePeriodLength) { // Use variable period length
phase = CyclePhase.menstrual;
} else if (dayOfCycle <= 13) {
int dayOfCycle = calculatedDayOfCycle;
if (calculatedDayOfCycle <= user.averagePeriodLength) {
if (isPeriodLoggedToday) {
phase = CyclePhase.menstrual;
} else {
// No period logged today, but we are in the predicted window.
// Stay in Luteal and extend the day count.
phase = CyclePhase.luteal;
dayOfCycle = daysSinceLastPeriod;
}
} else if (calculatedDayOfCycle <= 13) {
phase = CyclePhase.follicular;
} else if (dayOfCycle <= 16) {
} else if (calculatedDayOfCycle <= 16) {
phase = CyclePhase.ovulation;
} else {
phase = CyclePhase.luteal;
}
final daysUntilPeriod =
dayOfCycle >= cycleLength ? 0 : cycleLength - dayOfCycle;
return CycleInfo(
phase: phase,
dayOfCycle: dayOfCycle,
daysUntilPeriod: daysUntilPeriod,
isPeriodExpected: daysUntilPeriod <= 0 || dayOfCycle <= 5,
isPeriodExpected: daysUntilPeriod <= 0 || calculatedDayOfCycle <= 5,
);
}
@@ -180,13 +200,14 @@ class CycleService {
if (user == null || user.lastPeriodStartDate == null) return null;
final lastPeriodStart = user.lastPeriodStartDate!;
// Normalize dates
final checkDate = DateTime(date.year, date.month, date.day);
final startCycle = DateTime(lastPeriodStart.year, lastPeriodStart.month, lastPeriodStart.day);
final startCycle = DateTime(
lastPeriodStart.year, lastPeriodStart.month, lastPeriodStart.day);
final daysDifference = checkDate.difference(startCycle).inDays;
// If date is before the last known period, we can't reliably predict using this simple logic
// (though in reality we could project backwards, but let's stick to forward/current)
if (daysDifference < 0) return null;
@@ -201,7 +222,8 @@ class CycleService {
}
/// Predicts period days for the next [months] months
static List<DateTime> predictNextPeriodDays(UserProfile? user, {int months = 12}) {
static List<DateTime> predictNextPeriodDays(UserProfile? user,
{int months = 12}) {
if (user == null || user.lastPeriodStartDate == null) return [];
final predictedDays = <DateTime>[];
@@ -209,12 +231,12 @@ class CycleService {
final cycleLength = user.averageCycleLength;
final periodLength = user.averagePeriodLength;
// Start predicting from the NEXT cycle if the current one is finished,
// Start predicting from the NEXT cycle if the current one is finished,
// or just project out from the last start date.
// We want to list all future period days.
DateTime currentCycleStart = lastPeriodStart;
// Project forward for roughly 'months' months
// A safe upper bound for loop is months * 30 days
final limitDate = DateTime.now().add(Duration(days: months * 30));
@@ -227,11 +249,11 @@ class CycleService {
predictedDays.add(periodDay);
}
}
// Move to next cycle
currentCycleStart = currentCycleStart.add(Duration(days: cycleLength));
}
return predictedDays;
}

View File

@@ -1,6 +1,4 @@
import 'package:health/health.dart';
import 'package:collection/collection.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../models/cycle_entry.dart';
@@ -10,14 +8,12 @@ class HealthService {
HealthService._internal();
final Health _health = Health();
// ignore: unused_field
List<HealthDataType> _requestedTypes = [];
// TODO: Fix HealthDataType for menstruation in newer health package versions
static const List<HealthDataType> _menstruationDataTypes = [
// HealthDataType.MENSTRUATION - Not found in recent versions?
HealthDataType.STEPS, // Placeholder to avoid compile error
HealthDataType.MENSTRUATION_FLOW,
];
Future<bool> requestAuthorization(List<HealthDataType> types) async {
@@ -37,9 +33,10 @@ class HealthService {
Future<bool> writeMenstruationData(List<CycleEntry> entries) async {
// This feature is currently disabled until compatible HealthDataType is identified
debugPrint("writeMenstruationData: Currently disabled due to package version incompatibility.");
debugPrint(
"writeMenstruationData: Currently disabled due to package version incompatibility.");
return false;
/*
final periodEntries = entries.where((entry) => entry.isPeriodDay).toList();

View File

@@ -28,18 +28,19 @@ class NotificationService {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final DarwinInitializationSettings initializationSettingsDarwin =
const DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
// Linux initialization (optional, but good for completeness)
final LinuxInitializationSettings initializationSettingsLinux =
const LinuxInitializationSettings initializationSettingsLinux =
LinuxInitializationSettings(defaultActionName: 'Open notification');
final InitializationSettings initializationSettings = InitializationSettings(
const InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsDarwin,
macOS: initializationSettingsDarwin,
@@ -63,10 +64,11 @@ class NotificationService {
required DateTime scheduledDate,
}) async {
if (kIsWeb) {
// Web platform limitation: Background scheduling is complex.
// For this demo/web preview, we'll just log it or rely on the UI confirmation.
print('Web Notification Scheduled: $title - $body at $scheduledDate');
return;
// Web platform limitation: Background scheduling is complex.
// For this demo/web preview, we'll just log it or rely on the UI confirmation.
debugPrint(
'Web Notification Scheduled: $title - $body at $scheduledDate');
return;
}
await flutterLocalNotificationsPlugin.zonedSchedule(
@@ -92,30 +94,72 @@ class NotificationService {
// New method for specific notification types
Future<void> showLocalNotification({
required int id,
required String title,
required String body,
String? channelId,
String? channelName,
required int id,
required String title,
required String body,
String? channelId,
String? channelName,
}) async {
if (kIsWeb) {
print('Web Local Notification: $title - $body');
debugPrint('Web Local Notification: $title - $body');
return;
}
const AndroidNotificationDetails androidNotificationDetails =
AndroidNotificationDetails(
'tracker_general', 'General Notifications',
AndroidNotificationDetails('tracker_general', 'General Notifications',
channelDescription: 'General app notifications',
importance: Importance.max,
priority: Priority.high,
ticker: 'ticker');
const NotificationDetails notificationDetails =
NotificationDetails(android: androidNotificationDetails);
await flutterLocalNotificationsPlugin.show(
id, title, body, notificationDetails,
payload: 'item x');
await flutterLocalNotificationsPlugin
.show(id, title, body, notificationDetails, payload: 'item x');
}
Future<void> showPrayerRequestNotification(
{required String senderName}) async {
await showLocalNotification(
id: 300,
title: 'New Prayer Request',
body: '$senderName sent you a prayer request. Tap to pray with them.',
);
}
Future<void> showTeachingPlanNotification(
{required String teacherName}) async {
await showLocalNotification(
id: 302,
title: 'New Teaching Plan',
body: '$teacherName added a new teaching plan for you.',
);
}
Future<void> showPartnerUpdateNotification(
{required String title, required String body}) async {
await showLocalNotification(
id: 305,
title: title,
body: body,
);
}
Future<void> showCycleUpdateNotification({required String message}) async {
await showLocalNotification(
id: 310,
title: 'Cycle Update',
body: message,
);
}
Future<void> showSymptomNotification(
{required String senderName, required String symptom}) async {
await showLocalNotification(
id: 315,
title: 'Partner Care Reminder',
body: '$senderName logged $symptom. A little extra care might be nice!',
);
}
Future<void> cancelNotification(int id) async {

View File

@@ -1,105 +1,109 @@
import 'dart:typed_data';
import 'package:flutter/services.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:intl/intl.dart';
import '../models/user_profile.dart';
import '../models/cycle_entry.dart';
import 'package:intl/intl.dart';
class PdfService {
static Future<void> generateCycleReport(UserProfile? user, List<CycleEntry> entries) async {
static Future<void> generateCycleReport(
UserProfile user, List<CycleEntry> entries) async {
final pdf = pw.Document();
final font = await PdfGoogleFonts.outfitRegular();
final boldFont = await PdfGoogleFonts.outfitBold();
final logo = pw.MemoryImage(
(await rootBundle.load('assets/images/logo.png')).buffer.asUint8List(),
);
// Group entries by month
final entriesByMonth = <String, List<CycleEntry>>{};
final Map<String, List<CycleEntry>> groupedEntries = {};
for (var entry in entries) {
final month = DateFormat('MMMM yyyy').format(entry.date);
if (!entriesByMonth.containsKey(month)) {
entriesByMonth[month] = [];
if (!groupedEntries.containsKey(month)) {
groupedEntries[month] = [];
}
entriesByMonth[month]!.add(entry);
groupedEntries[month]!.add(entry);
}
// Sort months chronologically (most recent first)
final sortedMonths = groupedEntries.keys.toList()
..sort((a, b) {
final dateA = DateFormat('MMMM yyyy').parse(a);
final dateB = DateFormat('MMMM yyyy').parse(b);
return dateB.compareTo(dateA);
});
pdf.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
theme: pw.ThemeData.withFont(
base: font,
bold: boldFont,
),
margin: const pw.EdgeInsets.all(32),
build: (pw.Context context) {
return [
pw.Header(
level: 0,
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text('Cycle Report', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
pw.Text(DateFormat.yMMMd().format(DateTime.now()), style: const pw.TextStyle(color: PdfColors.grey)),
],
),
),
if (user != null)
pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 20),
child: pw.Column(
pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text('Name: ${user.name}'),
pw.Text('Average Cycle Length: ${user.averageCycleLength} days'),
pw.Text('Average Period Length: ${user.averagePeriodLength} days'),
pw.Text('Cycle History Report',
style: pw.TextStyle(
fontSize: 24,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blueGrey900)),
pw.Text('Generated for ${user.name}',
style: const pw.TextStyle(
fontSize: 14, color: PdfColors.blueGrey600)),
pw.Text(
'Date Range: ${DateFormat('MMM yyyy').format(entries.last.date)} - ${DateFormat('MMM yyyy').format(entries.first.date)}',
style: const pw.TextStyle(
fontSize: 12, color: PdfColors.blueGrey400)),
],
),
),
...entriesByMonth.entries.map((entry) {
final month = entry.key;
final monthEntries = entry.value;
// Sort by date
monthEntries.sort((a, b) => a.date.compareTo(b.date));
return pw.Column(
pw.SizedBox(height: 50, width: 50, child: pw.Image(logo)),
],
),
pw.SizedBox(height: 30),
for (var month in sortedMonths) ...[
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.SizedBox(height: 10),
pw.Text(month, style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold, color: PdfColors.blueGrey800)),
pw.Text(month,
style: pw.TextStyle(
fontSize: 18,
fontWeight: pw.FontWeight.bold,
color: PdfColors.blueGrey800)),
pw.SizedBox(height: 5),
pw.Table.fromTextArray(
pw.TableHelper.fromTextArray(
context: context,
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold),
headers: ['Date', 'Phase', 'Details', 'Notes'],
data: monthEntries.map((e) {
final details = <String>[];
if (e.isPeriodDay) details.add('Period');
if (e.mood != null) details.add('Mood: ${e.mood!.label}');
if (e.symptomCount > 0) details.add('${e.symptomCount} symptoms');
data: groupedEntries[month]!.map((e) {
return [
DateFormat('d, E').format(e.date),
'${e.isPeriodDay ? "Menstrual" : "-"}', // Simplified for report
details.join(', '),
e.notes ?? '',
DateFormat('MMM d').format(e.date),
e.isPeriodDay
? 'Period'
: (e.flowIntensity == FlowIntensity.spotting
? 'Spotting'
: 'Other'),
e.flowIntensity != null
? 'Flow: ${e.flowIntensity.toString().split('.').last}'
: '-',
e.notes ?? '-',
];
}).toList(),
columnWidths: {
0: const pw.FlexColumnWidth(1),
1: const pw.FlexColumnWidth(1),
2: const pw.FlexColumnWidth(2),
3: const pw.FlexColumnWidth(2),
},
),
pw.SizedBox(height: 15),
pw.SizedBox(height: 20),
],
);
}),
),
],
];
},
),
);
await Printing.sharePdf(bytes: await pdf.save(), filename: 'cycle_report.pdf');
await Printing.layoutPdf(
onLayout: (PdfPageFormat format) async => pdf.save(),
);
}
}

View File

@@ -0,0 +1,291 @@
import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import '../models/cycle_entry.dart';
import '../models/teaching_plan.dart';
import '../models/prayer_request.dart';
class SyncService {
// Use 192.168.1.81 for the user's specific setup as they are testing across devices.
// In a real app we'd allow this to be configured.
// static const String _baseUrl = 'http://localhost:8090';
// Dynamic Base URL
static String get _baseUrl {
if (kIsWeb) {
// On web, use the current window's hostname (whether localhost or IP)
// and target port 8090.
final host = Uri.base.host;
// If host is empty (some web views), fallback.
if (host.isNotEmpty) {
return 'http://$host:8090';
}
}
// Mobile / Desktop App Fallback
// Use the specific local IP for this user's physical device testing.
return 'http://192.168.1.81:8090';
}
// Preview Partner Link (Check without linking)
Future<Map<String, dynamic>> previewPartnerId(String targetId) async {
try {
final url = Uri.parse('$_baseUrl/sync/preview');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'targetId': targetId,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to preview: ${response.body}');
}
} catch (e) {
debugPrint('Preview Error: $e');
rethrow;
}
}
// Link Partner
Future<Map<String, dynamic>> verifyPartnerId(
String userId, String targetId) async {
try {
final url = Uri.parse('$_baseUrl/sync/link');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'userId': userId,
'targetId': targetId,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to link: ${response.body}');
}
} catch (e) {
debugPrint('Link Error: $e');
rethrow;
}
}
// Push data to backend
Future<void> pushSyncData({
required String userId,
required List<CycleEntry> entries,
required List<TeachingPlan> teachingPlans,
required List<PrayerRequest> prayerRequests,
Map<String, dynamic>? userDetails,
}) async {
try {
final url = Uri.parse('$_baseUrl/sync/push');
final payload = {
'userId': userId,
'entries': entries.map((e) => _cycleEntryToJson(e)).toList(),
'teachingPlans':
teachingPlans.map((p) => _teachingPlanToJson(p)).toList(),
'prayerRequests':
prayerRequests.map((r) => _prayerRequestToJson(r)).toList(),
if (userDetails != null) 'userDetails': userDetails,
};
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
);
if (response.statusCode == 200) {
debugPrint('Sync Push Successful');
} else {
debugPrint('Sync Push Failed: ${response.body}');
}
} catch (e) {
debugPrint('Sync Push Error: $e');
}
}
// Pull data from backend
Future<Map<String, dynamic>> pullSyncData(String userId,
{String? partnerId}) async {
try {
var urlStr = '$_baseUrl/sync/pull?userId=$userId';
if (partnerId != null && partnerId.isNotEmpty) {
urlStr += '&partnerId=$partnerId';
}
final url = Uri.parse(urlStr);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
// entries
final List entriesJson = data['entries'] ?? [];
final entries =
entriesJson.map((json) => _jsonToCycleEntry(json)).toList();
// teaching plans
final List plansJson = data['teachingPlans'] ?? [];
final plans =
plansJson.map((json) => _jsonToTeachingPlan(json)).toList();
// prayer requests
final List prayersJson = data['prayerRequests'] ?? [];
final prayers =
prayersJson.map((json) => _jsonToPrayerRequest(json)).toList();
return {
'entries': entries,
'teachingPlans': plans,
'prayerRequests': prayers,
if (data['userProfile'] != null)
'userProfile': data['userProfile'] as Map<String, dynamic>,
};
}
} catch (e) {
debugPrint('Sync Pull Error: $e');
}
return {
'entries': <CycleEntry>[],
'teachingPlans': <TeachingPlan>[],
'prayerRequests': <PrayerRequest>[],
};
}
// --- Adapters ---
Map<String, dynamic> _cycleEntryToJson(CycleEntry entry) {
// Convert boolean symptoms to list of strings
final symptomsList = <String>[];
if (entry.hasHeadache) symptomsList.add('headache');
if (entry.hasBloating) symptomsList.add('bloating');
if (entry.hasBreastTenderness) symptomsList.add('breastTenderness');
if (entry.hasFatigue) symptomsList.add('fatigue');
if (entry.hasAcne) symptomsList.add('acne');
if (entry.hasLowerBackPain) symptomsList.add('lowerBackPain');
if (entry.hasConstipation) symptomsList.add('constipation');
if (entry.hasDiarrhea) symptomsList.add('diarrhea');
if (entry.hasInsomnia) symptomsList.add('insomnia');
return {
'id': entry.id,
'date': entry.date.toIso8601String(),
'flowIntensity': entry.flowIntensity?.name,
'isPeriodDay': entry.isPeriodDay,
'symptoms': jsonEncode(symptomsList),
'moods': jsonEncode(entry.mood != null ? [entry.mood!.name] : []),
'notes': entry.notes,
'createdAt': entry.createdAt.toIso8601String(),
'updatedAt': entry.updatedAt.toIso8601String(),
};
}
CycleEntry _jsonToCycleEntry(Map<String, dynamic> json) {
// FlowIntensity enum parsing
FlowIntensity? flow;
if (json['flowIntensity'] != null) {
flow = FlowIntensity.values.firstWhere(
(e) => e.name == json['flowIntensity'],
orElse: () => FlowIntensity.medium,
);
}
// Mood parsing
MoodLevel? mood;
final moodsList = _parseList(json['moods']);
if (moodsList.isNotEmpty) {
try {
mood = MoodLevel.values.firstWhere((e) => e.name == moodsList.first);
} catch (_) {}
}
final symptoms = _parseList(json['symptoms']);
return CycleEntry(
id: json['id'],
date: DateTime.parse(json['date']),
flowIntensity: flow,
isPeriodDay: json['isPeriodDay'] == true,
mood: mood,
hasHeadache: symptoms.contains('headache'),
hasBloating: symptoms.contains('bloating'),
hasBreastTenderness: symptoms.contains('breastTenderness'),
hasFatigue: symptoms.contains('fatigue'),
hasAcne: symptoms.contains('acne'),
hasLowerBackPain: symptoms.contains('lowerBackPain'),
hasConstipation: symptoms.contains('constipation'),
hasDiarrhea: symptoms.contains('diarrhea'),
hasInsomnia: symptoms.contains('insomnia'),
notes: json['notes'],
createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: DateTime.now(),
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: DateTime.now(),
);
}
// Teaching Plan
Map<String, dynamic> _teachingPlanToJson(TeachingPlan plan) {
return {
'id': plan.id,
'date': plan.date.toIso8601String(),
'topic': plan.topic,
'scriptureReference': plan.scriptureReference,
'notes': plan.notes,
'isCompleted': plan.isCompleted,
};
}
TeachingPlan _jsonToTeachingPlan(Map<String, dynamic> json) {
return TeachingPlan(
id: json['id'],
date: DateTime.parse(json['date']),
topic: json['topic'],
scriptureReference: json['scriptureReference'],
notes: json['notes'],
isCompleted: json['isCompleted'] == true,
);
}
// Prayer Request
Map<String, dynamic> _prayerRequestToJson(PrayerRequest request) {
return {
'id': request.id,
'request': request.request,
'isAnswered': request.isAnswered,
'createdAt': request.createdAt.toIso8601String(),
'updatedAt': request.updatedAt.toIso8601String(),
};
}
PrayerRequest _jsonToPrayerRequest(Map<String, dynamic> json) {
return PrayerRequest(
id: json['id'],
request: json['request'],
isAnswered: json['isAnswered'] == true,
createdAt: DateTime.parse(json['createdAt']),
updatedAt: DateTime.parse(json['updatedAt']),
);
}
List<String> _parseList(dynamic jsonVal) {
if (jsonVal == null) return [];
if (jsonVal is String) {
try {
return List<String>.from(jsonDecode(jsonVal));
} catch (_) {
return [];
}
}
return List<String>.from(jsonVal);
}
}

View File

@@ -131,7 +131,7 @@ class AppTheme {
cardTheme: CardThemeData(
color: Colors.white,
elevation: 2,
shadowColor: AppColors.charcoal.withOpacity(0.1),
shadowColor: AppColors.charcoal.withValues(alpha: 0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
@@ -186,11 +186,13 @@ class AppTheme {
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.lightGray.withOpacity(0.5)),
borderSide:
BorderSide(color: AppColors.lightGray.withValues(alpha: 0.5)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.lightGray.withOpacity(0.5)),
borderSide:
BorderSide(color: AppColors.lightGray.withValues(alpha: 0.5)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -231,15 +233,15 @@ class AppTheme {
// Slider Theme
sliderTheme: SliderThemeData(
activeTrackColor: accentColor,
inactiveTrackColor: AppColors.lightGray.withOpacity(0.3),
inactiveTrackColor: AppColors.lightGray.withValues(alpha: 0.3),
thumbColor: accentColor,
overlayColor: accentColor.withOpacity(0.2),
overlayColor: accentColor.withValues(alpha: 0.2),
trackHeight: 4,
),
// Divider
dividerTheme: DividerThemeData(
color: AppColors.lightGray.withOpacity(0.3),
color: AppColors.lightGray.withValues(alpha: 0.3),
thickness: 1,
space: 24,
),
@@ -262,7 +264,7 @@ class AppTheme {
onSecondary: Colors.white,
onSurface: Colors.white,
onSurfaceVariant: Colors.white70,
outline: Colors.white.withOpacity(0.1),
outline: Colors.white.withValues(alpha: 0.1),
),
// Scaffold
@@ -338,11 +340,12 @@ class AppTheme {
// Card Theme
cardTheme: CardThemeData(
color: const Color(0xFF1E1E1E),
elevation: 0, // Material 3 uses color/opacity for elevation in dark mode
elevation:
0, // Material 3 uses color/opacity for elevation in dark mode
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.white.withOpacity(0.05)),
side: BorderSide(color: Colors.white.withValues(alpha: 0.05)),
),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
@@ -367,7 +370,8 @@ class AppTheme {
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: accentColor,
side: BorderSide(color: accentColor.withOpacity(0.5), width: 1.5),
side:
BorderSide(color: accentColor.withValues(alpha: 0.5), width: 1.5),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -385,11 +389,11 @@ class AppTheme {
fillColor: const Color(0xFF1E1E1E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withOpacity(0.1)),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
@@ -423,9 +427,9 @@ class AppTheme {
// Slider Theme
sliderTheme: SliderThemeData(
activeTrackColor: accentColor,
inactiveTrackColor: Colors.white.withOpacity(0.1),
inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
thumbColor: accentColor,
overlayColor: accentColor.withOpacity(0.2),
overlayColor: accentColor.withValues(alpha: 0.2),
trackHeight: 4,
tickMarkShape: const RoundSliderTickMarkShape(),
activeTickMarkColor: Colors.white24,
@@ -434,7 +438,7 @@ class AppTheme {
// Divider
dividerTheme: DividerThemeData(
color: Colors.white.withOpacity(0.05),
color: Colors.white.withValues(alpha: 0.05),
thickness: 1,
space: 24,
),

View File

@@ -97,12 +97,12 @@ class _CycleRingState extends State<CycleRing>
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getPhaseColor(widget.phase)
.withOpacity(isDark ? 0.3 : 0.2),
.withValues(alpha: isDark ? 0.3 : 0.2),
borderRadius: BorderRadius.circular(20),
border: isDark
? Border.all(
color: _getPhaseColor(widget.phase)
.withOpacity(0.5))
.withValues(alpha: 0.5))
: null,
),
child: Row(
@@ -133,8 +133,9 @@ class _CycleRingState extends State<CycleRing>
: 'Period expected',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontSize: 12,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
@@ -181,8 +182,8 @@ class _CycleRingPainter extends CustomPainter {
// Background arc
final bgPaint = Paint()
..color =
(isDark ? Colors.white : AppColors.lightGray).withOpacity(isDark ? 0.05 : 0.1)
..color = (isDark ? Colors.white : AppColors.lightGray)
.withValues(alpha: isDark ? 0.05 : 0.1)
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
@@ -235,16 +236,12 @@ class _CycleRingPainter extends CustomPainter {
return [
AppColors.sageGreen,
AppColors.follicularPhase,
AppColors.sageGreen.withOpacity(0.7)
AppColors.sageGreen.withValues(alpha: 0.7)
];
case CyclePhase.ovulation:
return [AppColors.lavender, AppColors.ovulationPhase, AppColors.rose];
case CyclePhase.luteal:
return [
AppColors.lutealPhase,
AppColors.lavender,
AppColors.blushPink
];
return [AppColors.lutealPhase, AppColors.lavender, AppColors.blushPink];
}
}

View File

@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../theme/app_theme.dart';
import '../../providers/user_provider.dart';
import '../../models/user_profile.dart'; // Import UserProfile
class PadSettingsDialog extends ConsumerStatefulWidget {
const PadSettingsDialog({super.key});
@@ -16,12 +15,12 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
bool _isTrackingEnabled = false;
int _typicalFlow = 2; // Mediumish
int _padAbsorbency = 3; // Regular
// Inventory State
int _padInventoryCount = 0;
int _lowInventoryThreshold = 5;
bool _isAutoInventoryEnabled = true;
final TextEditingController _brandController = TextEditingController();
@override
@@ -32,12 +31,12 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
_isTrackingEnabled = user.isPadTrackingEnabled;
_typicalFlow = user.typicalFlowIntensity ?? 2;
_padAbsorbency = user.padAbsorbency ?? 3;
// Init Inventory
_padInventoryCount = user.padInventoryCount;
_lowInventoryThreshold = user.lowInventoryThreshold;
_isAutoInventoryEnabled = user.isAutoInventoryEnabled;
_brandController.text = user.padBrand ?? '';
}
}
@@ -58,12 +57,18 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
padInventoryCount: _padInventoryCount,
lowInventoryThreshold: _lowInventoryThreshold,
isAutoInventoryEnabled: _isAutoInventoryEnabled,
lastInventoryUpdate: (_padInventoryCount != (user.padInventoryCount)) ? DateTime.now() : user.lastInventoryUpdate,
padBrand: _brandController.text.trim().isEmpty ? null : _brandController.text.trim(),
lastInventoryUpdate: (_padInventoryCount != (user.padInventoryCount))
? DateTime.now()
: user.lastInventoryUpdate,
padBrand: _brandController.text.trim().isEmpty
? null
: _brandController.text.trim(),
);
await ref.read(userProfileProvider.notifier).updateProfile(updatedProfile);
await ref
.read(userProfileProvider.notifier)
.updateProfile(updatedProfile);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
@@ -92,7 +97,7 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
),
),
const SizedBox(height: 24),
// Toggle
Row(
children: [
@@ -101,21 +106,21 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
'Enable Pad Tracking',
style: GoogleFonts.outfit(
fontSize: 16,
color: AppColors.charcoal,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Switch(
value: _isTrackingEnabled,
onChanged: (val) => setState(() => _isTrackingEnabled = val),
activeColor: AppColors.menstrualPhase,
activeThumbColor: AppColors.menstrualPhase,
),
],
),
if (_isTrackingEnabled) ...[
const Divider(height: 32),
// Typical Flow
Text(
'Typical Flow Intensity',
@@ -128,34 +133,40 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
const SizedBox(height: 8),
Row(
children: [
Text('Light', style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray)),
Text('Light',
style: GoogleFonts.outfit(
fontSize: 12, color: AppColors.warmGray)),
Expanded(
child: Slider(
value: _typicalFlow.toDouble(),
min: 1,
max: 5,
divisions: 4,
activeColor: AppColors.menstrualPhase,
onChanged: (val) => setState(() => _typicalFlow = val.round()),
activeColor: AppColors
.menstrualPhase, // Slider still uses activeColor in many versions, check specifically.
// If it's SwitchListTile, it's activeColor, Slider is activeColor.
// Actually, let's keep it as is if it's not deprecated for Slider.
onChanged: (val) =>
setState(() => _typicalFlow = val.round()),
),
),
Text('Heavy', style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray)),
Text('Heavy',
style: GoogleFonts.outfit(
fontSize: 12, color: AppColors.warmGray)),
],
),
Center(
child: Text(
'$_typicalFlow/5',
style: GoogleFonts.outfit(
fontWeight: FontWeight.bold,
color: AppColors.menstrualPhase
)
),
child: Text('$_typicalFlow/5',
style: GoogleFonts.outfit(
fontWeight: FontWeight.bold,
color: AppColors.menstrualPhase)),
),
const SizedBox(height: 20),
// Pad Absorbency
Text(
Text(
'Pad Absorbency/Capacity',
style: GoogleFonts.outfit(
fontSize: 14,
@@ -166,11 +177,15 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
const SizedBox(height: 4),
Text(
'Regular(3), Super(4), Overnight(5)',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray.withOpacity(0.8)),
style: GoogleFonts.outfit(
fontSize: 12,
color: AppColors.warmGray.withValues(alpha: 0.8)),
),
Row(
children: [
Text('Low', style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray)),
Text('Low',
style: GoogleFonts.outfit(
fontSize: 12, color: AppColors.warmGray)),
Expanded(
child: Slider(
value: _padAbsorbency.toDouble(),
@@ -178,24 +193,24 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
max: 5,
divisions: 4,
activeColor: AppColors.menstrualPhase,
onChanged: (val) => setState(() => _padAbsorbency = val.round()),
onChanged: (val) =>
setState(() => _padAbsorbency = val.round()),
),
),
Text('High', style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray)),
Text('High',
style: GoogleFonts.outfit(
fontSize: 12, color: AppColors.warmGray)),
],
),
Center(
child: Text(
'$_padAbsorbency/5',
style: GoogleFonts.outfit(
fontWeight: FontWeight.bold,
color: AppColors.menstrualPhase
)
),
child: Text('$_padAbsorbency/5',
style: GoogleFonts.outfit(
fontWeight: FontWeight.bold,
color: AppColors.menstrualPhase)),
),
const SizedBox(height: 20),
// Brand
Text(
'Preferred Brand',
@@ -211,12 +226,13 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
decoration: InputDecoration(
hintText: 'e.g., Always Infinity, Cora, Period Undies...',
filled: true,
fillColor: AppColors.warmCream.withOpacity(0.5),
fillColor: AppColors.warmCream.withValues(alpha: 0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
@@ -227,9 +243,10 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
// Inventory Management Header
Row(
children: [
Icon(Icons.inventory_2_outlined, size: 20, color: AppColors.navyBlue),
const SizedBox(width: 8),
Text(
const Icon(Icons.inventory_2_outlined,
size: 20, color: AppColors.navyBlue),
const SizedBox(width: 8),
Text(
'Inventory Tracking',
style: GoogleFonts.outfit(
fontSize: 16,
@@ -250,38 +267,45 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
children: [
Text(
'Current Stock',
style: GoogleFonts.outfit(fontWeight: FontWeight.w500, color: AppColors.warmGray),
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: AppColors.warmGray),
),
Text(
'Pad Inventory',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray.withOpacity(0.8)),
style: GoogleFonts.outfit(
fontSize: 12,
color: AppColors.warmGray.withValues(alpha: 0.8)),
),
],
),
),
Container(
decoration: BoxDecoration(
color: AppColors.warmCream.withOpacity(0.5),
color: AppColors.warmCream.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.remove, size: 20, color: AppColors.navyBlue),
icon: const Icon(Icons.remove,
size: 20, color: AppColors.navyBlue),
onPressed: () {
if (_padInventoryCount > 0) setState(() => _padInventoryCount--);
if (_padInventoryCount > 0) {
setState(() => _padInventoryCount--);
}
},
),
Text(
'$_padInventoryCount',
style: GoogleFonts.outfit(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.navyBlue
),
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.navyBlue),
),
IconButton(
icon: const Icon(Icons.add, size: 20, color: AppColors.navyBlue),
icon: const Icon(Icons.add,
size: 20, color: AppColors.navyBlue),
onPressed: () => setState(() => _padInventoryCount++),
),
],
@@ -289,7 +313,7 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
),
],
),
const SizedBox(height: 16),
// Low Stock Threshold
@@ -301,11 +325,15 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
children: [
Text(
'Low Stock Alert',
style: GoogleFonts.outfit(fontWeight: FontWeight.w500, color: AppColors.warmGray),
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: AppColors.warmGray),
),
Text(
'Warn when below $_lowInventoryThreshold',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray.withOpacity(0.8)),
style: GoogleFonts.outfit(
fontSize: 12,
color: AppColors.warmGray.withValues(alpha: 0.8)),
),
],
),
@@ -318,7 +346,8 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
max: 20,
divisions: 19,
activeColor: AppColors.rose,
onChanged: (val) => setState(() => _lowInventoryThreshold = val.round()),
onChanged: (val) =>
setState(() => _lowInventoryThreshold = val.round()),
),
),
],
@@ -326,23 +355,27 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
// Auto Deduct Toggle
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'Auto-deduct on Log',
style: GoogleFonts.outfit(fontWeight: FontWeight.w500, color: AppColors.charcoal),
),
subtitle: Text(
'Reduce count when you log a pad',
style: GoogleFonts.outfit(fontSize: 12, color: AppColors.warmGray),
),
value: _isAutoInventoryEnabled,
onChanged: (val) => setState(() => _isAutoInventoryEnabled = val),
activeColor: AppColors.menstrualPhase,
contentPadding: EdgeInsets.zero,
title: Text(
'Auto-deduct on Log',
style: GoogleFonts.outfit(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface),
),
subtitle: Text(
'Reduce count when you log a pad',
style: GoogleFonts.outfit(
fontSize: 12, color: AppColors.warmGray),
),
value: _isAutoInventoryEnabled,
onChanged: (val) =>
setState(() => _isAutoInventoryEnabled = val),
activeThumbColor: AppColors.menstrualPhase,
),
],
const SizedBox(height: 32),
// Buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
@@ -360,7 +393,8 @@ class _PadSettingsDialogState extends ConsumerState<PadSettingsDialog> {
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.navyBlue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: const Text('Save'),
),

View File

@@ -17,6 +17,9 @@ class PadTrackerCard extends ConsumerStatefulWidget {
class _PadTrackerCardState extends ConsumerState<PadTrackerCard> {
Timer? _timer;
String _timeDisplay = '';
double _progress = 0.0;
Color _statusColor = AppColors.menstrualPhase;
bool _isCountDown = true; // Toggle state
@override
void initState() {
@@ -40,35 +43,92 @@ class _PadTrackerCardState extends ConsumerState<PadTrackerCard> {
void _updateTime() {
final user = ref.read(userProfileProvider);
if (user?.lastPadChangeTime == null) {
if (mounted) setState(() => _timeDisplay = 'Tap to start');
if (mounted) {
setState(() {
_timeDisplay = 'Tap to start';
_progress = 0;
_statusColor = AppColors.menstrualPhase;
});
}
return;
}
final now = DateTime.now();
final difference = now.difference(user!.lastPadChangeTime!);
// We want to show time SINCE change (duration worn)
final hours = difference.inHours;
final minutes = difference.inMinutes.remainder(60);
final seconds = difference.inSeconds.remainder(60);
// Estimate max duration based on flow
// None/Precautionary: 8h, Spotting: 8h, Light: 6h, Medium: 4h, Heavy: 3h
final flowIntensity = user.typicalFlowIntensity ?? 2; // Default to light
Duration maxDuration;
switch (flowIntensity) {
case 0: // No Flow / Precautionary (health guideline: 8h max)
maxDuration = const Duration(hours: 8);
break;
case 1: // Spotting
maxDuration = const Duration(hours: 8);
break;
case 2: // Light
maxDuration = const Duration(hours: 6);
break;
case 3: // Medium
maxDuration = const Duration(hours: 4);
break;
case 4: // Heavy
maxDuration = const Duration(hours: 3);
break;
case 5: // Very Heavy
maxDuration = const Duration(hours: 2);
break;
default:
maxDuration = const Duration(hours: 4);
}
final totalSeconds = maxDuration.inSeconds;
final elapsedSeconds = difference.inSeconds;
double progress = elapsedSeconds / totalSeconds;
progress = progress.clamp(0.0, 1.0);
// Determine Status Color
Color newColor = AppColors.menstrualPhase;
if (progress > 0.9) {
newColor = Colors.red;
} else if (progress > 0.75) {
newColor = Colors.orange;
} else {
newColor = AppColors.menstrualPhase; // Greenish/Theme color
}
// Override if we want to visually show "fresh" vs "old"
String text = '';
if (user.showPadTimerMinutes) {
if (hours > 0) text += '$hours hr ';
text += '$minutes min';
}
if (user.showPadTimerSeconds) {
if (text.isNotEmpty) text += ' ';
text += '$seconds sec';
}
if (text.isEmpty) text = 'Active'; // Fallback
if (_isCountDown) {
final remaining = maxDuration - difference;
final isOverdue = remaining.isNegative;
final absRemaining = remaining.abs();
final hours = absRemaining.inHours;
final mins = absRemaining.inMinutes % 60;
if (isOverdue) {
text = 'Overdue by ${hours}h ${mins}m';
newColor = Colors.red; // Force red if overdue
} else {
text = '${hours}h ${mins}m left';
}
_progress =
isOverdue ? 1.0 : (1.0 - progress); // Depleting if not overdue
} else {
// Count Up
final hours = difference.inHours;
final minutes = difference.inMinutes % 60;
text = '${hours}h ${minutes}m worn';
_progress = progress; // Filling
}
if (mounted) {
setState(() {
_timeDisplay = text;
_progress = _isCountDown && text.contains('Overdue') ? 1.0 : _progress;
_statusColor = newColor;
});
}
}
@@ -76,10 +136,11 @@ class _PadTrackerCardState extends ConsumerState<PadTrackerCard> {
@override
Widget build(BuildContext context) {
final user = ref.watch(userProfileProvider);
if (user == null || !user.isPadTrackingEnabled) return const SizedBox.shrink();
if (user == null || !user.isPadTrackingEnabled) {
return const SizedBox.shrink();
}
// Re-check time on rebuilds in case settings changed
// _updateTime(); // Actually let the timer handle it, or use a key to rebuild on setting changes
final isDark = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: () {
@@ -91,53 +152,89 @@ class _PadTrackerCardState extends ConsumerState<PadTrackerCard> {
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.menstrualPhase.withOpacity(0.1),
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.menstrualPhase.withOpacity(0.3)),
border: Border.all(color: _statusColor.withValues(alpha: 0.3)),
boxShadow: [
BoxShadow(
color: AppColors.menstrualPhase.withOpacity(0.05),
blurRadius: 8,
BoxShadow(
color: _statusColor.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.menstrualPhase.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(Icons.timer_outlined, color: AppColors.menstrualPhase, size: 24),
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: _statusColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child:
Icon(Icons.timer_outlined, color: _statusColor, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Pad Tracker',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : AppColors.charcoal,
),
),
InkWell(
onTap: () {
setState(() {
_isCountDown = !_isCountDown;
_updateTime();
});
},
child: Icon(
_isCountDown
? Icons.arrow_downward
: Icons.arrow_upward,
size: 16,
color: isDark
? Colors.white70
: AppColors.warmGray),
)
],
),
const SizedBox(height: 4),
Text(
_timeDisplay.isNotEmpty ? _timeDisplay : 'Tap to track',
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _statusColor),
),
],
),
),
Icon(Icons.chevron_right,
color: isDark ? Colors.white24 : AppColors.lightGray),
],
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pad Tracker',
style: GoogleFonts.outfit(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.charcoal,
),
),
const SizedBox(height: 4),
Text(
_timeDisplay.isNotEmpty ? _timeDisplay : 'Tap to track',
style: GoogleFonts.outfit(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.menstrualPhase
),
),
],
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: _progress,
backgroundColor: _statusColor.withValues(alpha: 0.1),
valueColor: AlwaysStoppedAnimation<Color>(_statusColor),
minHeight: 6,
),
),
const Icon(Icons.chevron_right, color: AppColors.menstrualPhase),
],
),
),

View File

@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../providers/user_provider.dart';
import '../theme/app_theme.dart';
import '../providers/navigation_provider.dart';
import 'quick_log_dialog.dart';
class QuickLogButtons extends ConsumerWidget {
@@ -63,6 +62,13 @@ class QuickLogButtons extends ConsumerWidget {
color: AppColors.lutealPhase,
onTap: () => _showQuickLogDialog(context, 'pads'),
),
_buildQuickButton(
context,
icon: Icons.church_outlined,
label: 'Prayer',
color: AppColors.softGold, // Or a suitable color
onTap: () => _showQuickLogDialog(context, 'prayer'),
),
],
),
);
@@ -84,8 +90,9 @@ class QuickLogButtons extends ConsumerWidget {
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Container(
width: 100, // Fixed width for grid item
return SizedBox(
width: 75,
// Removed fixed height to prevent overflow on larger text scalings
child: Material(
color: Colors.transparent,
child: InkWell(
@@ -94,10 +101,11 @@ class QuickLogButtons extends ConsumerWidget {
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: color.withOpacity(isDark ? 0.2 : 0.15),
color: color.withValues(alpha: isDark ? 0.2 : 0.15),
borderRadius: BorderRadius.circular(12),
border:
isDark ? Border.all(color: color.withOpacity(0.3)) : null,
border: isDark
? Border.all(color: color.withValues(alpha: 0.3))
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -110,7 +118,9 @@ class QuickLogButtons extends ConsumerWidget {
style: GoogleFonts.outfit(
fontSize: 12, // Slightly larger text
fontWeight: FontWeight.w600,
color: isDark ? Colors.white.withOpacity(0.9) : color,
color: isDark
? Colors.white.withValues(alpha: 0.9)
: AppColors.charcoal,
),
),
],

View File

@@ -4,9 +4,11 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:uuid/uuid.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/cycle_entry.dart';
import '../models/user_profile.dart';
import '../providers/user_provider.dart';
import '../providers/navigation_provider.dart';
import '../screens/log/pad_tracker_screen.dart';
import '../services/notification_service.dart';
import '../theme/app_theme.dart';
class QuickLogDialog extends ConsumerStatefulWidget {
@@ -23,7 +25,9 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
FlowIntensity? _flowIntensity;
MoodLevel? _mood;
int? _energyLevel;
PadType? _selectedPadType;
int _padAbsorbency = 3;
// Symptoms & Cravings
final Map<String, bool> _symptoms = {
'Headache': false,
@@ -37,9 +41,9 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
'Insomnia': false,
'Cramps': false,
};
final TextEditingController _cravingController = TextEditingController();
List<String> _cravings = [];
final List<String> _cravings = [];
List<String> _recentCravings = [];
@override
@@ -102,13 +106,15 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
return _buildSymptomsLog();
case 'cravings':
return _buildCravingsLog();
case 'prayer':
return _buildPrayerLog();
default:
return const Text('Invalid log type.');
}
}
Widget _buildSymptomsLog() {
return Container(
return SizedBox(
width: double.maxFinite,
child: ListView(
shrinkWrap: true,
@@ -137,7 +143,7 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
}
Widget _buildCravingsLog() {
return Container(
return SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -152,37 +158,44 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
),
onSubmitted: (value) {
if (value.isNotEmpty) {
setState(() {
_cravings.add(value.trim());
_cravingController.clear();
});
setState(() {
_cravings.add(value.trim());
_cravingController.clear();
});
}
},
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: _cravings.map((c) => Chip(
label: Text(c),
onDeleted: () {
setState(() => _cravings.remove(c));
},
)).toList(),
children: _cravings
.map((c) => Chip(
label: Text(c),
onDeleted: () {
setState(() => _cravings.remove(c));
},
))
.toList(),
),
const SizedBox(height: 16),
if (_recentCravings.isNotEmpty) ...[
Text('Recent Cravings:', style: GoogleFonts.outfit(fontSize: 12, fontWeight: FontWeight.bold)),
Text('Recent Cravings:',
style: GoogleFonts.outfit(
fontSize: 12, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
Wrap(
spacing: 8,
children: _recentCravings.take(5).map((c) => ActionChip(
label: Text(c),
onPressed: () {
if (!_cravings.contains(c)) {
setState(() => _cravings.add(c));
}
},
)).toList(),
children: _recentCravings
.take(5)
.map((c) => ActionChip(
label: Text(c),
onPressed: () {
if (!_cravings.contains(c)) {
setState(() => _cravings.add(c));
}
},
))
.toList(),
),
]
],
@@ -265,22 +278,162 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
}
Widget _buildPadsLog() {
// This can be a simple button to navigate to the PadTrackerScreen
return ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => const PadTrackerScreen(),
));
},
child: const Text('Track Pad Change'),
final theme = Theme.of(context);
// ignore: unused_local_variable
final user = ref.watch(userProfileProvider);
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Flow Intensity:',
style: GoogleFonts.outfit(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: FlowIntensity.values.map((flow) {
return ChoiceChip(
label: Text(flow.label),
selected: _flowIntensity == flow,
onSelected: (selected) {
if (selected) setState(() => _flowIntensity = flow);
},
);
}).toList(),
),
const SizedBox(height: 16),
Text('Protection Type:',
style: GoogleFonts.outfit(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
DropdownButtonFormField<PadType>(
initialValue: _selectedPadType,
decoration: InputDecoration(
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
),
items: PadType.values.map((type) {
return DropdownMenuItem(
value: type,
child: Text(type.label),
);
}).toList(),
onChanged: (value) => setState(() => _selectedPadType = value),
hint: const Text('Select type'),
),
const SizedBox(height: 16),
Text('Absorbency (1-5):',
style: GoogleFonts.outfit(fontWeight: FontWeight.w600)),
Slider(
value: _padAbsorbency.toDouble(),
min: 1,
max: 5,
divisions: 4,
label: _padAbsorbency.toString(),
onChanged: (val) => setState(() => _padAbsorbency = val.round()),
),
const SizedBox(height: 16),
if (_flowIntensity != null && _selectedPadType != null)
Center(
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.sageGreen.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Estimated duration: ${_calculateRecommendedHours()} hours',
style: GoogleFonts.outfit(
color: AppColors.sageGreen,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
ref.read(navigationProvider.notifier).setIndex(2);
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => const PadTrackerScreen(),
));
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.surfaceContainerHighest,
foregroundColor: theme.colorScheme.onSurface,
),
child: const Text('More Options (Inventory, etc.)'),
),
),
],
),
);
}
int _calculateRecommendedHours() {
if (_selectedPadType == null || _flowIntensity == null) return 6;
final type = _selectedPadType!;
if (type == PadType.menstrualCup ||
type == PadType.menstrualDisc ||
type == PadType.periodUnderwear) {
return 12;
}
int baseHours;
switch (_flowIntensity!) {
case FlowIntensity.heavy:
baseHours = (type == PadType.superPad ||
type == PadType.overnight ||
type == PadType.tamponSuper)
? 4
: 3;
break;
case FlowIntensity.medium:
baseHours = 6;
break;
case FlowIntensity.light:
baseHours = 8;
break;
case FlowIntensity.spotting:
baseHours = 10;
break;
case FlowIntensity.none:
baseHours = 8;
break;
}
return baseHours;
}
Widget _buildPrayerLog() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Enter your prayer request or gratitude:'),
const SizedBox(height: 16),
TextField(
controller:
_cravingController, // Reusing controller for simplicity, or create _prayerController
maxLines: 4,
decoration: const InputDecoration(
hintText: 'I am thankful for...',
border: OutlineInputBorder(),
),
),
],
);
}
Future<void> _saveLog() async {
// Handle text input for cravings if user didn't hit enter
if (widget.logType == 'cravings' && _cravingController.text.isNotEmpty) {
_cravings.add(_cravingController.text.trim());
_cravings.add(_cravingController.text.trim());
}
final cycleNotifier = ref.read(cycleEntriesProvider.notifier);
@@ -288,7 +441,11 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
final entries = ref.read(cycleEntriesProvider);
final entry = entries.firstWhere(
(e) => DateUtils.isSameDay(e.date, today),
orElse: () => CycleEntry(id: const Uuid().v4(), date: today, createdAt: today, updatedAt: today),
orElse: () => CycleEntry(
id: const Uuid().v4(),
date: today,
createdAt: today,
updatedAt: today),
);
CycleEntry updatedEntry = entry;
@@ -308,31 +465,89 @@ class _QuickLogDialogState extends ConsumerState<QuickLogDialog> {
break;
case 'symptoms':
updatedEntry = entry.copyWith(
hasHeadache: _symptoms['Headache'],
hasBloating: _symptoms['Bloating'],
hasBreastTenderness: _symptoms['Breast Tenderness'],
hasFatigue: _symptoms['Fatigue'],
hasAcne: _symptoms['Acne'],
hasLowerBackPain: _symptoms['Back Pain'],
hasConstipation: _symptoms['Constipation'],
hasDiarrhea: _symptoms['Diarrhea'],
hasInsomnia: _symptoms['Insomnia'],
crampIntensity: _symptoms['Cramps'] == true ? 2 : 0, // Default to mild cramps if just toggled
hasHeadache: _symptoms['Headache'],
hasBloating: _symptoms['Bloating'],
hasBreastTenderness: _symptoms['Breast Tenderness'],
hasFatigue: _symptoms['Fatigue'],
hasAcne: _symptoms['Acne'],
hasLowerBackPain: _symptoms['Back Pain'],
hasConstipation: _symptoms['Constipation'],
hasDiarrhea: _symptoms['Diarrhea'],
hasInsomnia: _symptoms['Insomnia'],
crampIntensity: _symptoms['Cramps'] == true
? 2
: 0, // Default to mild cramps if just toggled
);
// Trigger notification if any symptom is selected
final user = ref.read(userProfileProvider);
if (_symptoms.values.any((selected) => selected == true)) {
final selectedSymptom = _symptoms.entries
.firstWhere((element) => element.value == true)
.key;
NotificationService().showSymptomNotification(
senderName: user?.name ?? 'Wife',
symptom: selectedSymptom,
);
}
break;
case 'cravings':
final currentCravings = entry.cravings ?? [];
final newCravings = {...currentCravings, ..._cravings}.toList();
updatedEntry = entry.copyWith(cravings: newCravings);
// Update History
final prefs = await SharedPreferences.getInstance();
final history = prefs.getStringList('recent_cravings') ?? [];
final updatedHistory = {..._cravings, ...history}.take(20).toList();
await prefs.setStringList('recent_cravings', updatedHistory);
await prefs.setStringList('recent_cravings', updatedHistory);
break;
case 'prayer':
final currentPrayer = entry.prayerRequest ?? '';
final newPrayer =
_cravingController.text.trim(); // Using reused controller
if (newPrayer.isNotEmpty) {
updatedEntry = entry.copyWith(
prayerRequest: currentPrayer.isEmpty
? newPrayer
: '$currentPrayer\n$newPrayer');
// Trigger notification
final user = ref.read(userProfileProvider);
NotificationService().showPrayerRequestNotification(
senderName: user?.name ?? 'Wife',
);
} else {
return; // Don't save empty prayer
}
break;
case 'pads':
final userProfile = ref.read(userProfileProvider);
if (userProfile != null) {
final hours = _calculateRecommendedHours();
await ref.read(userProfileProvider.notifier).updateProfile(
userProfile.copyWith(
lastPadChangeTime: DateTime.now(),
// Auto-inventory deduction could go here, but omitted for "Quick" simplicity
// unless we want to match PadTrackerScreen exactly.
),
);
await NotificationService().scheduleNotification(
id: 100,
title: 'Time to change!',
body: 'It\'s been $hours hours since you logged your protection.',
scheduledDate: DateTime.now().add(Duration(hours: hours)),
);
}
updatedEntry = entry.copyWith(
isPeriodDay: _flowIntensity != FlowIntensity.none &&
_flowIntensity != FlowIntensity.spotting,
flowIntensity: _flowIntensity,
);
break;
default:
// pads handled separately
// Already handled or invalid
return;
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../theme/app_theme.dart';
import '../models/cycle_entry.dart';
@@ -35,11 +34,12 @@ class ScriptureCard extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withOpacity(0.05)
: Colors.black.withOpacity(0.05)),
? Colors.white.withValues(alpha: 0.05)
: Colors.black.withValues(alpha: 0.05)),
boxShadow: [
BoxShadow(
color: _getPhaseColor(phase).withOpacity(isDark ? 0.05 : 0.15),
color:
_getPhaseColor(phase).withValues(alpha: isDark ? 0.05 : 0.15),
blurRadius: 15,
offset: const Offset(0, 8),
),
@@ -60,7 +60,7 @@ class ScriptureCard extends StatelessWidget {
height: 32,
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withOpacity(0.1),
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
@@ -68,7 +68,7 @@ class ScriptureCard extends StatelessWidget {
size: 18,
color: isDark
? Colors.white70
: AppColors.charcoal.withOpacity(0.8),
: AppColors.charcoal.withValues(alpha: 0.8),
),
),
const SizedBox(width: 8),
@@ -78,7 +78,7 @@ class ScriptureCard extends StatelessWidget {
fontSize: 12,
color: isDark
? const Color(0xFFE0E0E0)
: AppColors.charcoal.withOpacity(0.7),
: AppColors.charcoal.withValues(alpha: 0.7),
letterSpacing: 0.5,
),
),
@@ -111,11 +111,11 @@ class ScriptureCard extends StatelessWidget {
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: (isDark ? Colors.white : Colors.black)
.withOpacity(0.05),
.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: (isDark ? Colors.white : Colors.black)
.withOpacity(0.1),
.withValues(alpha: 0.1),
),
),
child: Row(
@@ -133,9 +133,8 @@ class ScriptureCard extends StatelessWidget {
Icon(
Icons.swap_horiz,
size: 14,
color: isDark
? Colors.white38
: AppColors.warmGray,
color:
isDark ? Colors.white38 : AppColors.warmGray,
),
],
),
@@ -157,22 +156,22 @@ class ScriptureCard extends StatelessWidget {
switch (phase) {
case CyclePhase.menstrual:
return [
AppColors.menstrualPhase.withOpacity(isDark ? 0.15 : 0.6),
AppColors.menstrualPhase.withValues(alpha: isDark ? 0.15 : 0.6),
baseColor,
];
case CyclePhase.follicular:
return [
AppColors.follicularPhase.withOpacity(isDark ? 0.15 : 0.3),
AppColors.follicularPhase.withValues(alpha: isDark ? 0.15 : 0.3),
baseColor,
];
case CyclePhase.ovulation:
return [
AppColors.ovulationPhase.withOpacity(isDark ? 0.15 : 0.5),
AppColors.ovulationPhase.withValues(alpha: isDark ? 0.15 : 0.5),
baseColor,
];
case CyclePhase.luteal:
return [
AppColors.lutealPhase.withOpacity(isDark ? 0.15 : 0.3),
AppColors.lutealPhase.withValues(alpha: isDark ? 0.15 : 0.3),
baseColor,
];
}

View File

@@ -25,12 +25,13 @@ class TipCard extends StatelessWidget {
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(16),
border:
isDark ? Border.all(color: Colors.white.withOpacity(0.05)) : null,
border: isDark
? Border.all(color: Colors.white.withValues(alpha: 0.05))
: null,
boxShadow: [
BoxShadow(
color: (isDark ? Colors.black : AppColors.charcoal)
.withOpacity(0.05),
.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -43,7 +44,7 @@ class TipCard extends StatelessWidget {
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.sageGreen.withOpacity(isDark ? 0.2 : 0.15),
color: AppColors.sageGreen.withValues(alpha: isDark ? 0.2 : 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
@@ -127,10 +128,10 @@ class TipCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailSection(
'Nutrition', details['nutrition']!, Icons.restaurant),
context, 'Nutrition', details['nutrition']!, Icons.restaurant),
const SizedBox(height: 16),
_buildDetailSection(
'Movement', details['movement']!, Icons.fitness_center),
_buildDetailSection(context, 'Movement', details['movement']!,
Icons.fitness_center),
const SizedBox(height: 16),
Text(
'Note: While these are general trends, your body is unique. Always listen to your own energy levels.',
@@ -153,7 +154,8 @@ class TipCard extends StatelessWidget {
);
}
Widget _buildDetailSection(String title, String content, IconData icon) {
Widget _buildDetailSection(
BuildContext context, String title, String content, IconData icon) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -175,7 +177,7 @@ class TipCard extends StatelessWidget {
content,
style: GoogleFonts.outfit(
fontSize: 13,
color: AppColors.charcoal,
color: Theme.of(context).colorScheme.onSurface,
),
),
],

View File

@@ -178,7 +178,7 @@ packages:
source: hosted
version: "4.10.1"
collection:
dependency: transitive
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
@@ -449,7 +449,7 @@ packages:
source: hosted
version: "0.15.6"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -705,7 +705,7 @@ packages:
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
dependency: "direct main"
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
@@ -1022,7 +1022,7 @@ packages:
source: hosted
version: "0.7.7"
timezone:
dependency: transitive
dependency: "direct main"
description:
name: timezone
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"

View File

@@ -47,6 +47,10 @@ dependencies:
icalendar_parser: ^2.0.0 # For .ics file generation
share_plus: ^7.2.2 # For sharing files
local_auth: ^3.0.0
collection: ^1.18.0
timezone: ^0.9.4
path_provider_platform_interface: ^2.1.2
http: ^1.6.0
dev_dependencies:
flutter_test:

View File

@@ -1,7 +1,6 @@
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';
@@ -12,7 +11,8 @@ 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, int index)
getScriptureForPhaseByIndexFn;
final Scripture? Function(String phase)? getRandomScriptureForPhaseFn;
FakeScriptureDatabase({
@@ -22,7 +22,8 @@ class FakeScriptureDatabase implements ScriptureDatabase {
});
@override
int getScriptureCountForPhase(String phase) => getScriptureCountForPhaseFn(phase);
int getScriptureCountForPhase(String phase) =>
getScriptureCountForPhaseFn(phase);
@override
Scripture? getScriptureForPhaseByIndex(String phase, int index) =>
@@ -30,7 +31,9 @@ class FakeScriptureDatabase implements ScriptureDatabase {
@override
Scripture? getRandomScriptureForPhase(String phase) =>
getRandomScriptureForPhaseFn != null ? getRandomScriptureForPhaseFn!(phase) : null;
getRandomScriptureForPhaseFn != null
? getRandomScriptureForPhaseFn!(phase)
: null;
// Unimplemented methods (not used by ScriptureNotifier)
@override
@@ -40,10 +43,12 @@ class FakeScriptureDatabase implements ScriptureDatabase {
Scripture getHusbandScripture() => throw UnimplementedError();
@override
Future<void> loadScriptures() => Future.value(); // Can be mocked to do nothing
Future<void> loadScriptures() =>
Future.value(); // Can be mocked to do nothing
@override
Scripture? getRecommendedScripture(CycleEntry entry) => throw UnimplementedError();
Scripture? getRecommendedScripture(CycleEntry entry) =>
throw UnimplementedError();
@override
Scripture getScriptureForPhase(String phase) => throw UnimplementedError();
@@ -55,7 +60,7 @@ void main() {
late String testPath;
setUpAll(() async {
testPath = Directory.current.path + '/test_hive_temp_scripture_provider';
testPath = '${Directory.current.path}/test_hive_temp_scripture_provider';
final Directory tempDir = Directory(testPath);
if (!await tempDir.exists()) {
await tempDir.create(recursive: true);
@@ -142,22 +147,25 @@ void main() {
// 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);
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).currentScripture, testScripture2);
expect(container.read(scriptureProvider).currentIndex, 1);
// Second next
notifier.getNextScripture();
expect(container.read(scriptureProvider).currentScripture, testScripture3);
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).currentScripture, testScripture1);
expect(container.read(scriptureProvider).currentIndex, 0);
});
@@ -181,22 +189,25 @@ void main() {
// 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);
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).currentScripture, testScripture3);
expect(container.read(scriptureProvider).currentIndex, 2);
// Second previous
notifier.getPreviousScripture();
expect(container.read(scriptureProvider).currentScripture, testScripture2);
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).currentScripture, testScripture1);
expect(container.read(scriptureProvider).currentIndex, 0);
});
@@ -204,7 +215,8 @@ void main() {
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
getScriptureForPhaseByIndexFn: (phase, index) => scriptures[
index % scriptures.length], // Ensure it always returns a valid one
);
container = ProviderContainer(
@@ -224,7 +236,8 @@ void main() {
final state = container.read(scriptureProvider);
expect(state.currentScripture, isNotNull);
expect(state.currentScripture, isIn(scriptures)); // Ensure it's one of the valid scriptures
expect(state.currentScripture,
isIn(scriptures)); // Ensure it's one of the valid scriptures
expect(state.currentIndex, isNonNegative);
expect(state.currentIndex, lessThan(scriptures.length));
});
@@ -259,4 +272,4 @@ void main() {
expect(container.read(scriptureProvider), initialState);
});
});
}
}

View File

@@ -7,7 +7,6 @@ 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
@@ -36,7 +35,7 @@ void main() {
// 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';
testPath = '${Directory.current.path}/test_hive_temp';
// Ensure the directory exists
final Directory tempDir = Directory(testPath);
@@ -46,7 +45,8 @@ void main() {
// Create and configure the mock BibleXmlParser
final mockBibleXmlParser = MockBibleXmlParser();
when(mockBibleXmlParser.getVerseFromAsset(any, any)).thenAnswer((invocation) async {
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';
@@ -58,7 +58,8 @@ void main() {
'flutter/assets',
(ByteData? message) async {
final String key = utf8.decode(message!.buffer.asUint8List());
if (key == 'assets/scriptures.json' || key == 'assets/scriptures_optimized.json') {
if (key == 'assets/scriptures.json' ||
key == 'assets/scriptures_optimized.json') {
final json = {
"menstrual": [
{
@@ -120,8 +121,10 @@ void main() {
Hive.init(testPath);
Hive.registerAdapter(ScriptureAdapter()); // Register your adapter
Hive.registerAdapter(BibleTranslationAdapter()); // Register BibleTranslationAdapter
database = ScriptureDatabase(bibleXmlParser: mockBibleXmlParser); // Instantiate with mock
Hive.registerAdapter(
BibleTranslationAdapter()); // Register BibleTranslationAdapter
database = ScriptureDatabase(
bibleXmlParser: mockBibleXmlParser); // Instantiate with mock
await database.loadScriptures();
});
@@ -145,11 +148,11 @@ void main() {
class _MockPathProviderPlatform extends PathProviderPlatform {
@override
Future<String?> getApplicationSupportPath() async {
return Directory.current.path + '/test_hive_temp';
return '${Directory.current.path}/test_hive_temp';
}
@override
Future<String?> getApplicationDocumentsPath() async {
return Directory.current.path + '/test_hive_temp';
return '${Directory.current.path}/test_hive_temp';
}
}

View File

@@ -1,6 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:christian_period_tracker/main.dart';
void main() {
testWidgets('App loads correctly', (WidgetTester tester) async {

View File

@@ -1,26 +1,76 @@
import 'dart:convert';
import 'dart:io';
import 'package:xml/xml.dart';
import 'package:flutter/foundation.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',
'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
@@ -35,12 +85,12 @@ final Map<String, String> _translationFiles = {
};
void main() async {
print('Starting asset optimization...');
debugPrint('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.');
debugPrint('Error: assets/scriptures.json not found.');
return;
}
final Map<String, dynamic> data = json.decode(jsonFile.readAsStringSync());
@@ -52,14 +102,14 @@ void main() async {
final path = entry.value;
final file = File(path);
if (file.existsSync()) {
print('Parsing $key from $path...');
debugPrint('Parsing $key from $path...');
try {
xmlDocs[key] = XmlDocument.parse(file.readAsStringSync());
} catch (e) {
print('Error parsing $path: $e');
debugPrint('Error parsing $path: $e');
}
} else {
print('Warning: $path not found.');
debugPrint('Warning: $path not found.');
}
}
@@ -90,7 +140,8 @@ void main() async {
outputFile.writeAsStringSync(json.encode(data)); // Minified
// outputFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(data)); // Pretty print
print('Optimization complete. Wrote to assets/scriptures_optimized.json');
debugPrint(
'Optimization complete. Wrote to assets/scriptures_optimized.json');
}
Future<void> _processList(List list, Map<String, XmlDocument> xmlDocs) async {
@@ -101,31 +152,27 @@ Future<void> _processList(List list, Map<String, XmlDocument> xmlDocs) async {
// Parse reference
final parts = _parseReference(reference);
if (parts == null) {
print('Skipping invalid reference: $reference');
debugPrint('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.
// 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']!)
);
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');
debugPrint('Warning: Could not find $reference in $transKey');
}
}
}
@@ -140,92 +187,96 @@ Map<String, String>? _parseReference(String reference) {
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
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],
// 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;
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;
// 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(
// -- 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('bname');
final nameAttr = element.getAttribute('n');
return nameAttr?.toLowerCase() == lookupBookName.toLowerCase() ||
nameAttr?.toLowerCase() == bookName.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();
}
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();
}

37
walkthrough.md Normal file
View File

@@ -0,0 +1,37 @@
# Sync & Connections Fixed 🚀
We have resolved the Sync issues, implemented secure Partner Verification, and enabled Auto-Discovery of the connection.
## Key Changes
1. **Instant Code Validity:** We now register the user silently as soon as they enter their name. This ensures the "Invalid ID" error never happens when connecting.
2. **Verified Connection:** When the Husband enters the Wife's ID, the app now **verifies** it with the server immediately.
3. **Bi-Directional Link:** Once the Husband connects, the Server automatically links the Wife to the Husband.
4. **Auto-Discovery:** The Wife DOES NOT need to enter a code. She just needs to "Sync" (pull), and her app will discover the connection automatically.
## How to Verify
### 1. Reset (Recommended)
Since we changed the backend logic heavily, please **Reset App** on both devices (or clear browser data).
### 2. Connect (The "One Code" Way)
1. **Wife:** Go to Onboarding -> "Invite Husband" -> **Share the Code**.
2. **Husband:** Go to Onboarding -> "Connect with Wife" -> **Enter the Code**.
* **VERIFY:** The dialog now says "Verifying..." and then "Connected to [Wife Name]!".
* *If the ID is wrong, it will show an error.*
### 3. Sync
1. **Husband:** Finish onboarding. Data syncs automatically.
2. **Wife:** Finish onboarding.
* **Action:** Go to Settings -> Sync (or wait for auto-sync).
* **VERIFY:** The app detects the server link and downloads Husband's data (Teaching Plans, etc.) automatically.
* **VERIFY:** You can see Husband's Teaching Plans in the Devotional section.
## Troubleshooting
* If Sync fails, ensure `dart bin/server.dart` is running (we restarted it for you).
* Check the "Sharing & Partner" settings screen to see the connected Partner ID.

View File

@@ -70,7 +70,7 @@
<body id="app-container">
<div id="loading-indicator" class="loader"></div>
<script src="flutter_bootstrap.js" async></script>
<script src="flutter_bootstrap.js" async onerror="alert('Failed to load flutter_bootstrap.js. Check console for details.'); document.getElementById('loading-indicator').style.borderTopColor = 'red';"></script>
</body>
</html>