Compare commits
10 Commits
96655f9a74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ede7064bda | |||
| 0a72259a9a | |||
| 1c2c56e9e2 | |||
| d28898cb81 | |||
| dc6bcad83f | |||
| 24ffac2415 | |||
| a799e9cf59 | |||
| 512577b092 | |||
| 9ae77e7ab0 | |||
| d50cab3188 |
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"avdmanager.sdkPath": "/home/sterl/Downloads/Tracker-main/tracker/lib/providers"
|
||||
}
|
||||
@@ -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 = "../.."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
198
backend/bin/server.dart
Normal 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
BIN
backend/data/tracker.db
Normal file
Binary file not shown.
282
backend/lib/database.dart
Normal file
282
backend/lib/database.dart
Normal 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
173
backend/pubspec.lock
Normal 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
18
backend/pubspec.yaml
Normal 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
3
devtools_options.yaml
Normal 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
161
lib/app_startup.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
53
lib/models/prayer_request.dart
Normal file
53
lib/models/prayer_request.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/models/prayer_request.g.dart
Normal file
53
lib/models/prayer_request.g.dart
Normal 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;
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
83
lib/providers/prayer_provider.dart
Normal file
83
lib/providers/prayer_provider.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
43
lib/providers/sync_notification_provider.dart
Normal file
43
lib/providers/sync_notification_provider.dart
Normal 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();
|
||||
});
|
||||
39
lib/providers/teaching_plan_provider.dart
Normal file
39
lib/providers/teaching_plan_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
156
lib/screens/husband/husband_appearance_screen.dart
Normal file
156
lib/screens/husband/husband_appearance_screen.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 Christ’s authority.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(height: 1, color: Color(0xFFE0C097)),
|
||||
const SizedBox(height: 16),
|
||||
_buildVerseText(
|
||||
'1 Tim 3:4–5, 12 & Titus 1:6',
|
||||
'1 Timothy 3:4–5, 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
320
lib/screens/husband/husband_learn_articles_screen.dart
Normal file
320
lib/screens/husband/husband_learn_articles_screen.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
311
lib/screens/husband/husband_settings_screen.dart
Normal file
311
lib/screens/husband/husband_settings_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
188
lib/screens/prayer/prayer_request_screen.dart
Normal file
188
lib/screens/prayer/prayer_request_screen.dart
Normal 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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
291
lib/services/sync_service.dart
Normal file
291
lib/services/sync_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
37
walkthrough.md
Normal 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.
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user