first commit - migrated from codeberg

This commit is contained in:
Charlotte Croce 2025-04-20 11:17:03 -04:00
commit 5ead03e1f7
567 changed files with 102721 additions and 0 deletions

31
lib/main.dart Normal file
View file

@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// main.dart
// App entry point
//
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:nokken/src/app.dart';
/// Application entry point
void main() {
// Ensure Flutter is initialized before we do anything else
WidgetsFlutterBinding.ensureInitialized();
// Use FFI implementation for desktop platforms
// Mobile platforms use the standard implementation
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
// Launch the app with Riverpod as the state management provider
runApp(
const ProviderScope(
child: NokkenApp(),
),
);
}

37
lib/src/app.dart Normal file
View file

@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// app.dart
// Main application configuration
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/providers/theme_provider.dart';
import 'package:nokken/src/core/services/navigation/routes/app_router.dart';
import 'package:nokken/src/core/screens/main_screen.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
/// Root widget for the app
/// Handles theme configuration and routing
class NokkenApp extends ConsumerWidget {
const NokkenApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the theme provider to rebuild when theme changes
final themeMode = ref.watch(themeProvider);
// When theme changes, update the AppColors._themeMode var
AppColors.setThemeMode(themeMode);
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Nokken',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
home: const MainScreen(),
onGenerateRoute: AppRouter.generateRoute,
);
}
}

View file

@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// date_constants.dart
// Constants for dates and timing
//
class DateConstants {
/// Map of day abbreviations to full day names
static const Map<String, String> dayNames = {
'Su': 'Sunday',
'M': 'Monday',
'T': 'Tuesday',
'W': 'Wednesday',
'Th': 'Thursday',
'F': 'Friday',
'Sa': 'Saturday'
};
/// Day abbreviations in order (Sunday first)
static const List<String> orderedDays = [
'Su',
'M',
'T',
'W',
'Th',
'F',
'Sa'
];
/// Map from DateTime weekday integers to day abbreviations
/// Note: Flutter uses 1-7 (Monday-Sunday) for weekdays
static const Map<int, String> dayMap = {
DateTime.monday: 'M',
DateTime.tuesday: 'T',
DateTime.wednesday: 'W',
DateTime.thursday: 'Th',
DateTime.friday: 'F',
DateTime.saturday: 'Sa',
DateTime.sunday: 'Su',
};
/// List of month abbreviations
static const List<String> months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
/// Converts day abbreviation to weekday number (0-6)
/// Used for calendar calculations
static int dayAbbreviationToWeekday(String dayAbbr) {
const Map<String, int> dayMap = {
'Su': 0, // Sunday (Flutter uses 7 for Sunday, but we'll normalize to 0)
'M': 1, // Monday
'T': 2, // Tuesday
'W': 3, // Wednesday
'Th': 4, // Thursday
'F': 5, // Friday
'Sa': 6, // Saturday
};
return dayMap[dayAbbr] ?? 0; // Default to Sunday if not found
}
}

View file

@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// main_screen.dart
// Main container screen with bottom navigation
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/bloodwork_tracker/screens/bloodwork_list_screen.dart';
import 'package:nokken/src/features/settings/screens/settings_screen.dart';
import 'package:nokken/src/features/scheduler/screens/daily_tracker_screen.dart';
import 'package:nokken/src/features/medication_tracker/screens/medication_list_screen.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/features/stats/screens/stats_overview_screen.dart';
/// Provider to track the current navigation index
/// Default to index 2 (daily tracker)
final navigationIndexProvider = StateProvider<int>((ref) => 2);
/// Main application screen
/// Serves as the container for the main functional screens
class MainScreen extends ConsumerWidget {
const MainScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentIndex = ref.watch(navigationIndexProvider);
// List of all screens accessible from the bottom navigation
final screens = [
const MedicationListScreen(),
const BloodworkListScreen(),
const DailyTrackerScreen(),
const StatsOverviewScreen(),
const SettingsScreen()
];
return Scaffold(
// Display the currently selected screen
body: screens[currentIndex],
// Bottom navigation bar
bottomNavigationBar: Container(
padding: EdgeInsets.fromLTRB(5, 5, 5, 5),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: AppColors.onPrimary,
width: 1.0,
),
),
),
child: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (index) {
ref.read(navigationIndexProvider.notifier).state = index;
},
destinations: [
// Medications tab
NavigationDestination(
icon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getOutlined('medication')),
),
selectedIcon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getFilled('medication')),
),
label: 'Medications',
),
// Bloodwork tab
NavigationDestination(
icon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getOutlined('bloodwork')),
),
selectedIcon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getFilled('bloodwork')),
),
label: 'Bloodwork',
),
// Daily tracker tab
NavigationDestination(
icon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getOutlined('calendar')),
),
selectedIcon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getFilled('calendar')),
),
label: 'Schedule',
),
// Stats tab
NavigationDestination(
icon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getOutlined('analytics')),
),
selectedIcon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getFilled('analytics')),
),
label: 'Stats',
),
// Settings tab
NavigationDestination(
icon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getOutlined('settings')),
),
selectedIcon: Padding(
padding: AppTheme.navigationBarPadding,
child: Icon(AppIcons.getFilled('settings')),
),
label: 'Settings',
),
],
),
),
);
}
}

View file

@ -0,0 +1,767 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// database_service.dart
// Service for handling database operations
//
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as path;
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
/// Custom exception for database-related errors
class DatabaseException implements Exception {
final String message;
final dynamic error;
DatabaseException(this.message, [this.error]);
@override
String toString() =>
'DatabaseException: $message${error != null ? ' ($error)' : ''}';
}
/// Model for tracking when medications are taken
class TakenMedication {
final String medicationId;
final DateTime date;
final String timeSlot;
final bool taken;
final String? customKey;
TakenMedication({
required this.medicationId,
required this.date,
required this.timeSlot,
required this.taken,
this.customKey,
});
/// Generate a unique key for this record - normalize date to prevent time issues
String get uniqueKey {
if (customKey != null) {
return customKey!;
}
// Create a normalized date string (just year-month-day, no time)
final normalizedDate =
DateTime(date.year, date.month, date.day).toIso8601String();
return '$medicationId-$normalizedDate-$timeSlot';
}
/// Convert to database map
Map<String, dynamic> toMap() {
// Always normalize the date when saving to database
final normalizedDate =
DateTime(date.year, date.month, date.day).toIso8601String();
final map = {
'medication_id': medicationId,
'date': normalizedDate,
'time_slot': timeSlot,
'taken': taken ? 1 : 0,
};
// Add custom key if provided (cast to Object to satisfy type requirements)
if (customKey != null) {
map['custom_key'] = customKey as Object;
}
return map;
}
/// Create from database map
factory TakenMedication.fromMap(Map<String, dynamic> map) {
try {
final date = DateTime.parse(map['date']);
// Always normalize the date to ensure consistency
final normalizedDate = DateTime(date.year, date.month, date.day);
return TakenMedication(
medicationId: map['medication_id'],
date: normalizedDate,
timeSlot: map['time_slot'],
taken: map['taken'] == 1,
customKey: map['custom_key'],
);
} catch (e) {
// Return a default value in case of error
return TakenMedication(
medicationId: map['medication_id'] ?? 'unknown',
date: DateTime.now(),
timeSlot: map['time_slot'] ?? 'unknown',
taken: map['taken'] == 1,
);
}
}
}
/// Service that manages database operations for the application
class DatabaseService {
static Database? _database;
static const int _currentVersion = 3; // Increase version for schema update
/// Get the database instance (lazy initialization)
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
/// Initialize the database connection
Future<Database> _initDatabase() async {
try {
final dbPath = await getDatabasesPath();
final pathToDb = path.join(dbPath, 'nokken.db');
return await openDatabase(
pathToDb,
version: _currentVersion,
onCreate: (db, version) async {
await _createDatabase(db);
},
// No onUpgrade - for testing we'll reset the database
);
} catch (e) {
throw DatabaseException('Failed to initialize database', e);
}
}
/// Create the database schema
Future<void> _createDatabase(Database db) async {
// Create medications table
await db.execute('''
CREATE TABLE medications(
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
dosage TEXT NOT NULL,
startDate TEXT NOT NULL,
frequency INTEGER NOT NULL,
asNeeded INTEGER DEFAULT 0,
timeOfDay TEXT NOT NULL,
daysOfWeek TEXT NOT NULL,
currentQuantity INTEGER NOT NULL,
refillThreshold INTEGER NOT NULL,
notes TEXT,
doctor TEXT,
pharmacy TEXT,
medicationType TEXT NOT NULL,
oralSubtype TEXT,
topicalSubtype TEXT,
injectionFrequency TEXT,
injectionSubtype TEXT,
syringeType TEXT,
syringeCount INTEGER,
drawingNeedleType TEXT,
drawingNeedleCount INTEGER,
drawingNeedleRefills INTEGER,
injectingNeedleType TEXT,
injectingNeedleCount INTEGER,
injectingNeedleRefills INTEGER,
syringeRefills INTEGER,
injectionSiteRotation TEXT,
injectionSiteNotes TEXT
)
''');
// Create taken_medications table
await db.execute('''
CREATE TABLE taken_medications(
medication_id TEXT NOT NULL,
date TEXT NOT NULL,
time_slot TEXT NOT NULL,
taken INTEGER NOT NULL,
custom_key TEXT,
PRIMARY KEY (custom_key)
)
''');
// Create bloodwork table
await db.execute('''
CREATE TABLE bloodwork(
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
appointmentType TEXT NOT NULL,
hormone_readings TEXT,
location TEXT,
doctor TEXT,
notes TEXT
)
''');
// Create mood_entries table
await db.execute('''
CREATE TABLE mood_entries(
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
mood TEXT NOT NULL,
emotions TEXT NOT NULL,
notes TEXT,
sleep_quality TEXT,
energy_level TEXT,
libido_level TEXT,
appetite_level TEXT,
focus_level TEXT,
dysphoria_level TEXT,
exercise_level TEXT
)
''');
}
/// Convert a Medication object to a database map
Map<String, dynamic> _medicationToMap(Medication medication) {
// Start with basic fields
final map = {
'id': medication.id,
'name': medication.name.trim(),
'dosage': medication.dosage.trim(),
'startDate': medication.startDate.toIso8601String(),
'frequency': medication.frequency,
'timeOfDay':
medication.timeOfDay.map((t) => t.toIso8601String()).join(','),
'daysOfWeek': medication.daysOfWeek.toList().join(','),
'currentQuantity': medication.currentQuantity,
'refillThreshold': medication.refillThreshold,
'notes': medication.notes?.trim(),
'medicationType': medication.medicationType.toString().split('.').last,
'doctor': medication.doctor?.trim(),
'pharmacy': medication.pharmacy?.trim(),
'oralSubtype': medication.oralSubtype?.toString().split('.').last,
'topicalSubtype': medication.topicalSubtype?.toString().split('.').last,
'asNeeded': medication.asNeeded
? 1
: 0, // Convert boolean to int (1/0) for SQLite
};
// Add injection details if present, storing each field directly
if (medication.injectionDetails != null) {
map.addAll({
'drawingNeedleType': medication.injectionDetails!.drawingNeedleType,
'drawingNeedleCount': medication.injectionDetails!.drawingNeedleCount,
'drawingNeedleRefills':
medication.injectionDetails!.drawingNeedleRefills,
'injectingNeedleType': medication.injectionDetails!.injectingNeedleType,
'injectingNeedleCount':
medication.injectionDetails!.injectingNeedleCount,
'injectingNeedleRefills':
medication.injectionDetails!.injectingNeedleRefills,
'syringeType': medication.injectionDetails!.syringeType,
'syringeCount': medication.injectionDetails!.syringeCount,
'syringeRefills': medication.injectionDetails!.syringeRefills,
'injectionSiteNotes': medication.injectionDetails!.injectionSiteNotes,
'injectionFrequency':
medication.injectionDetails!.frequency.toString().split('.').last,
'injectionSubtype':
medication.injectionDetails!.subtype.toString().split('.').last,
});
// Add site rotation data if present
if (medication.injectionDetails!.siteRotation != null) {
map['injectionSiteRotation'] =
jsonEncode(medication.injectionDetails!.siteRotation!.toJson());
}
} else {
// Set null for all injection-related fields for non-injection medications
map.addAll({
'drawingNeedleType': null,
'drawingNeedleCount': null,
'drawingNeedleRefills': null,
'injectingNeedleType': null,
'injectingNeedleCount': null,
'injectingNeedleRefills': null,
'syringeType': null,
'syringeCount': null,
'syringeRefills': null,
'injectionSiteNotes': null,
'injectionFrequency': null,
'injectionSubtype': null,
'injectionSiteRotation': null,
});
}
return map;
}
// Convert a database map to a Medication object
Medication _mapToMedication(Map<String, dynamic> map) {
// Convert enum string back to enum value
final medicationType = MedicationType.values.firstWhere(
(e) => e.toString() == 'MedicationType.${map['medicationType']}',
);
// Handle oral subtype if present
OralSubtype? oralSubtype;
if (map['oralSubtype'] != null) {
try {
oralSubtype = OralSubtype.values.firstWhere(
(e) => e.toString() == 'OralSubtype.${map['oralSubtype']}',
);
} catch (_) {
// Default to tablets if parsing fails
oralSubtype =
medicationType == MedicationType.oral ? OralSubtype.tablets : null;
}
}
// Handle topical subtype if present
TopicalSubtype? topicalSubtype;
if (map['topicalSubtype'] != null) {
try {
topicalSubtype = TopicalSubtype.values.firstWhere(
(e) => e.toString() == 'TopicalSubtype.${map['topicalSubtype']}',
);
} catch (_) {
// Default to gel if parsing fails
topicalSubtype = medicationType == MedicationType.topical
? TopicalSubtype.gel
: null;
}
}
// Create injection details if this is an injection medication
InjectionDetails? injectionDetails;
if (medicationType == MedicationType.injection) {
final frequency = InjectionFrequency.values.firstWhere(
(e) =>
e.toString() == 'InjectionFrequency.${map['injectionFrequency']}',
orElse: () => InjectionFrequency.weekly,
);
final subtype = InjectionSubtype.values.firstWhere(
(e) => e.toString() == 'InjectionSubtype.${map['injectionSubtype']}',
orElse: () => InjectionSubtype.intramuscular,
);
// Parse site rotation data if present
InjectionSiteRotation? siteRotation;
if (map['injectionSiteRotation'] != null) {
try {
final rotationJson = jsonDecode(map['injectionSiteRotation']);
siteRotation = InjectionSiteRotation.fromJson(rotationJson);
} catch (_) {
// Silently handle parsing errors
}
}
injectionDetails = InjectionDetails(
drawingNeedleType: map['drawingNeedleType'] as String,
drawingNeedleCount: map['drawingNeedleCount'] as int,
drawingNeedleRefills: map['drawingNeedleRefills'] as int,
injectingNeedleType: map['injectingNeedleType'] as String,
injectingNeedleCount: map['injectingNeedleCount'] as int,
injectingNeedleRefills: map['injectingNeedleRefills'] as int,
syringeType: map['syringeType'] as String? ?? '',
syringeCount: map['syringeCount'] as int? ?? 0,
syringeRefills: map['syringeRefills'] as int? ?? 0,
injectionSiteNotes: map['injectionSiteNotes'] as String? ?? '',
frequency: frequency,
subtype: subtype,
siteRotation: siteRotation,
);
}
// Extract asNeeded from the SQLite integer (1/0) to a boolean
final asNeeded = (map['asNeeded'] as int?) == 1;
// Create the medication object
return Medication(
id: map['id'] as String,
name: map['name'] as String,
dosage: map['dosage'] as String,
startDate: DateTime.parse(map['startDate'] as String),
frequency: map['frequency'] as int,
timeOfDay: (map['timeOfDay'] as String)
.split(',')
.map((t) => t.trim())
.where((t) => t.isNotEmpty)
.map((t) => DateTime.parse(t))
.toList(),
daysOfWeek: (map['daysOfWeek'] as String)
.split(',')
.map((d) => d.trim())
.where((d) => d.isNotEmpty)
.toSet(),
currentQuantity: map['currentQuantity'] as int,
refillThreshold: map['refillThreshold'] as int,
notes: map['notes'] as String?,
doctor: map['doctor'] as String?,
pharmacy: map['pharmacy'] as String?,
medicationType: medicationType,
oralSubtype: oralSubtype,
topicalSubtype: topicalSubtype,
injectionDetails: injectionDetails,
asNeeded: asNeeded, // Add asNeeded field
);
}
//----------------------------------------------------------------------------
// MEDICATION CRUD OPERATIONS
//----------------------------------------------------------------------------
/// Insert a new medication into the database
Future<void> insertMedication(Medication medication) async {
try {
final db = await database;
await db.insert(
'medications',
_medicationToMap(medication),
conflictAlgorithm: ConflictAlgorithm.replace,
);
} catch (e) {
throw DatabaseException('Failed to insert medication', e);
}
}
/// Update an existing medication in the database
Future<void> updateMedication(Medication medication) async {
try {
final db = await database;
await db.update(
'medications',
_medicationToMap(medication),
where: 'id = ?',
whereArgs: [medication.id],
);
} catch (e) {
throw DatabaseException('Failed to update medication', e);
}
}
/// Delete a medication and its taken records
Future<void> deleteMedication(String id) async {
try {
final db = await database;
await db.delete(
'medications',
where: 'id = ?',
whereArgs: [id],
);
// Also delete any taken records for this medication
await db.delete(
'taken_medications',
where: 'medication_id = ?',
whereArgs: [id],
);
} catch (e) {
throw DatabaseException('Failed to delete medication', e);
}
}
/// Get all medications from the database
Future<List<Medication>> getAllMedications() async {
try {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query('medications');
return maps.map(_mapToMedication).toList();
} catch (e) {
throw DatabaseException('Failed to fetch medications', e);
}
}
//----------------------------------------------------------------------------
// TAKEN MEDICATION OPERATIONS
//----------------------------------------------------------------------------
/// Mark a medication as taken or not taken, with optional custom key
Future<void> setMedicationTakenWithCustomKey(String medicationId,
DateTime date, String timeSlot, bool taken, String? customKey) async {
try {
final db = await database;
final takenMed = TakenMedication(
medicationId: medicationId,
date: DateTime(date.year, date.month, date.day), // Strip time component
timeSlot: timeSlot,
taken: taken,
customKey: customKey,
);
// If custom key is provided, we need to handle it specially
if (customKey != null) {
// First check if an entry with this custom key exists
final existingEntries = await db.query(
'taken_medications',
where: 'custom_key = ?',
whereArgs: [customKey],
);
if (existingEntries.isNotEmpty) {
// Update existing entry
await db.update(
'taken_medications',
takenMed.toMap(),
where: 'custom_key = ?',
whereArgs: [customKey],
);
} else {
// Insert new entry
await db.insert(
'taken_medications',
takenMed.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
} else {
// Standard insert/replace for entries without custom key
await db.insert(
'taken_medications',
takenMed.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
} catch (e) {
throw DatabaseException('Failed to save medication taken status', e);
}
}
/// Legacy method for compatibility
Future<void> setMedicationTaken(
String medicationId, DateTime date, String timeSlot, bool taken) async {
await setMedicationTakenWithCustomKey(
medicationId, date, timeSlot, taken, null);
}
/// Get all medications taken on a specific date
Future<Set<String>> getTakenMedicationsForDate(DateTime date) async {
try {
final db = await database;
final dateStr =
DateTime(date.year, date.month, date.day).toIso8601String();
final List<Map<String, dynamic>> maps = await db.query(
'taken_medications',
where: 'date = ? AND taken = 1',
whereArgs: [dateStr],
);
final result = <String>{};
for (final map in maps) {
final takenMed = TakenMedication.fromMap(map);
// Add to result using custom key if available, otherwise use standard key
if (takenMed.customKey != null) {
result.add(takenMed.customKey!);
} else {
result.add(takenMed.uniqueKey);
}
}
return result;
} catch (e) {
throw DatabaseException('Failed to fetch taken medications', e);
}
}
/// Delete taken medication records older than the specified date
Future<void> deleteTakenMedicationsOlderThan(DateTime date) async {
try {
final db = await database;
final cutoffDate =
DateTime(date.year, date.month, date.day).toIso8601String();
await db.delete(
'taken_medications',
where: 'date < ?',
whereArgs: [cutoffDate],
);
} catch (e) {
throw DatabaseException('Failed to delete old taken medications', e);
}
}
//----------------------------------------------------------------------------
// BLOODWORK CRUD OPERATIONS
//----------------------------------------------------------------------------
/// Convert a Bloodwork object to a database map
Map<String, dynamic> _bloodworkToMap(Bloodwork bloodwork) {
final map = {
'id': bloodwork.id,
'date': bloodwork.date.toIso8601String(),
'appointmentType': bloodwork.appointmentType.toString(),
'location': bloodwork.location,
'doctor': bloodwork.doctor,
'notes': bloodwork.notes,
};
// Store hormone readings as JSON
if (bloodwork.hormoneReadings.isNotEmpty) {
map['hormone_readings'] =
jsonEncode(bloodwork.hormoneReadings.map((r) => r.toJson()).toList());
}
return map;
}
/// Convert a database map to a Bloodwork object
Bloodwork _mapToBloodwork(Map<String, dynamic> map) {
try {
// Parse appointment type from string
AppointmentType parsedType;
try {
parsedType = AppointmentType.values.firstWhere(
(e) => e.toString() == map['appointmentType'],
orElse: () => AppointmentType.bloodwork);
} catch (_) {
// For backward compatibility with old records without appointmentType
parsedType = AppointmentType.bloodwork;
}
// Parse hormone readings from JSON
List<HormoneReading> hormoneReadings = [];
if (map['hormone_readings'] != null) {
try {
final List<dynamic> decodedReadings =
jsonDecode(map['hormone_readings']);
hormoneReadings = decodedReadings
.map((json) => HormoneReading.fromJson(json))
.toList();
} catch (e) {
//print('Error parsing hormone readings: $e');
}
}
return Bloodwork(
id: map['id'] as String,
date: DateTime.parse(map['date'] as String),
appointmentType: parsedType,
hormoneReadings: hormoneReadings,
location: map['location'] as String?,
doctor: map['doctor'] as String?,
notes: map['notes'] as String?,
);
} catch (e) {
throw DatabaseException('Invalid bloodwork data: $e');
}
}
/// Insert a new bloodwork record into the database
Future<void> insertBloodwork(Bloodwork bloodwork) async {
try {
final db = await database;
await db.insert(
'bloodwork',
_bloodworkToMap(bloodwork),
conflictAlgorithm: ConflictAlgorithm.replace,
);
} catch (e) {
throw DatabaseException('Failed to insert bloodwork', e);
}
}
/// Update an existing bloodwork record in the database
Future<void> updateBloodwork(Bloodwork bloodwork) async {
try {
final db = await database;
await db.update(
'bloodwork',
_bloodworkToMap(bloodwork),
where: 'id = ?',
whereArgs: [bloodwork.id],
);
} catch (e) {
throw DatabaseException('Failed to update bloodwork', e);
}
}
/// Delete a bloodwork record
Future<void> deleteBloodwork(String id) async {
try {
final db = await database;
await db.delete(
'bloodwork',
where: 'id = ?',
whereArgs: [id],
);
} catch (e) {
throw DatabaseException('Failed to delete bloodwork', e);
}
}
/// Get all bloodwork records from the database
Future<List<Bloodwork>> getAllBloodwork() async {
try {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
'bloodwork',
orderBy: 'date DESC', // Most recent first
);
return maps.map(_mapToBloodwork).toList();
} catch (e) {
throw DatabaseException('Failed to fetch bloodwork records', e);
}
}
/// Get a specific bloodwork record by ID
Future<Bloodwork?> getBloodworkById(String id) async {
try {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query(
'bloodwork',
where: 'id = ?',
whereArgs: [id],
);
if (maps.isEmpty) {
return null;
}
return _mapToBloodwork(maps.first);
} catch (e) {
throw DatabaseException('Failed to fetch bloodwork', e);
}
}
///
/// DEBUGGING
///
/// Debug helper to verify database structure and contents
Future<Map<String, dynamic>> debugDatabaseInfo() async {
Map<String, dynamic> result = {};
try {
final db = await database;
// Get all tables in the database
final tables = await db
.rawQuery("SELECT name FROM sqlite_master WHERE type='table'");
result['tables'] = tables.map((t) => t['name']).toList();
// Check if mood_entries exists
final moodTableExists = tables.any((t) => t['name'] == 'mood_entries');
result['mood_table_exists'] = moodTableExists;
// Get count of mood entries
if (moodTableExists) {
final moodCount =
await db.rawQuery('SELECT COUNT(*) as count FROM mood_entries');
result['mood_entries_count'] = moodCount.first['count'];
// Get last 3 mood entries for reference
final lastEntries =
await db.query('mood_entries', orderBy: 'date DESC', limit: 3);
result['latest_entries'] = lastEntries
.map((e) => {'id': e['id'], 'date': e['date'], 'mood': e['mood']})
.toList();
}
return result;
} catch (e) {
return {'error': e.toString(), 'tables': []};
}
}
/// Get formatted debug info string
Future<String> getDebugInfo() async {
try {
final info = await debugDatabaseInfo();
return 'DB Info: \n${info.toString()}';
} catch (e) {
return 'DB Error: $e';
}
}
}

View file

@ -0,0 +1,246 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// database_service_mood.dart
// Extensions to DatabaseService for mood entries
//
import 'package:nokken/src/core/services/database/database_service.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:sqflite/sqflite.dart';
// At the top of the file
class MoodDatabaseException implements Exception {
final String message;
final dynamic error;
MoodDatabaseException(this.message, [this.error]);
@override
String toString() =>
'MoodDatabaseException: $message${error != null ? ' ($error)' : ''}';
}
/// Extension to add mood entry functionality to DatabaseService
extension MoodEntryDatabase on DatabaseService {
/// Insert a new mood entry into the database
Future<void> insertMoodEntry(MoodEntry entry) async {
try {
final db = await database;
await db.insert(
'mood_entries',
_moodEntryToMap(entry),
conflictAlgorithm: ConflictAlgorithm.replace,
);
} catch (e) {
throw MoodDatabaseException('Failed to insert mood entry', e);
}
}
/// Update an existing mood entry in the database
Future<void> updateMoodEntry(MoodEntry entry) async {
try {
final db = await database;
await db.update(
'mood_entries',
_moodEntryToMap(entry),
where: 'id = ?',
whereArgs: [entry.id],
);
} catch (e) {
throw MoodDatabaseException('Failed to update mood entry', e);
}
}
/// Delete a mood entry
Future<void> deleteMoodEntry(String id) async {
try {
final db = await database;
await db.delete(
'mood_entries',
where: 'id = ?',
whereArgs: [id],
);
} catch (e) {
throw MoodDatabaseException('Failed to delete mood entry: $e');
}
}
/// Get all mood entries from the database
Future<List<MoodEntry>> getAllMoodEntries() async {
try {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query('mood_entries');
return maps.map(_mapToMoodEntry).toList();
} catch (e) {
throw MoodDatabaseException('Failed to fetch mood entries: $e');
}
}
/// Get a mood entry for a specific date
Future<MoodEntry?> getMoodEntryForDate(DateTime date) async {
try {
final db = await database;
final normalizedDate =
DateTime(date.year, date.month, date.day).toIso8601String();
final List<Map<String, dynamic>> maps = await db.query(
'mood_entries',
where: 'date = ?',
whereArgs: [normalizedDate],
);
if (maps.isEmpty) {
return null;
}
return _mapToMoodEntry(maps.first);
} catch (e) {
throw MoodDatabaseException('Failed to fetch mood entry for date: $e');
}
}
/// Convert a MoodEntry object to a database map
Map<String, dynamic> _moodEntryToMap(MoodEntry entry) {
// Normalize the date to just year-month-day
final normalizedDate =
DateTime(entry.date.year, entry.date.month, entry.date.day)
.toIso8601String();
final map = {
'id': entry.id,
'date': normalizedDate,
'mood': entry.mood.toString(),
'emotions': entry.emotions.map((e) => e.toString()).join(','),
'notes': entry.notes,
'sleep_quality': entry.sleepQuality?.toString(),
'energy_level': entry.energyLevel?.toString(),
'libido_level': entry.libidoLevel?.toString(),
'appetite_level': entry.appetiteLevel?.toString(),
'focus_level': entry.focusLevel?.toString(),
'dysphoria_level': entry.dysphoriaLevel?.toString(),
'exercise_level': entry.exerciseLevel?.toString(),
};
return map;
}
/// Convert a database map to a MoodEntry object
MoodEntry _mapToMoodEntry(Map<String, dynamic> map) {
// Parse the mood
final moodStr = map['mood'] as String;
final mood = MoodRating.values.firstWhere(
(e) => e.toString() == moodStr,
orElse: () => MoodRating.okay,
);
// Parse the emotions
final emotionsStr = map['emotions'] as String;
final emotions = emotionsStr
.split(',')
.where((e) => e.isNotEmpty)
.map((emotionStr) {
try {
return Emotion.values.firstWhere(
(e) => e.toString() == emotionStr,
);
} catch (_) {
return null;
}
})
.where((e) => e != null)
.cast<Emotion>()
.toSet();
// Parse optional health categories without defaults
SleepQuality? sleepQuality;
if (map['sleep_quality'] != null) {
try {
sleepQuality = SleepQuality.values.firstWhere(
(e) => e.toString() == map['sleep_quality'],
);
} catch (_) {
// Keep as null if no match found
}
}
EnergyLevel? energyLevel;
if (map['energy_level'] != null) {
try {
energyLevel = EnergyLevel.values.firstWhere(
(e) => e.toString() == map['energy_level'],
);
} catch (_) {
// Keep as null if no match found
}
}
LibidoLevel? libidoLevel;
if (map['libido_level'] != null) {
try {
libidoLevel = LibidoLevel.values.firstWhere(
(e) => e.toString() == map['libido_level'],
);
} catch (_) {
// Keep as null if no match found
}
}
AppetiteLevel? appetiteLevel;
if (map['appetite_level'] != null) {
try {
appetiteLevel = AppetiteLevel.values.firstWhere(
(e) => e.toString() == map['appetite_level'],
);
} catch (_) {
// Keep as null if no match found
}
}
FocusLevel? focusLevel;
if (map['focus_level'] != null) {
try {
focusLevel = FocusLevel.values.firstWhere(
(e) => e.toString() == map['focus_level'],
);
} catch (_) {
// Keep as null if no match found
}
}
DysphoriaLevel? dysphoriaLevel;
if (map['dysphoria_level'] != null) {
try {
dysphoriaLevel = DysphoriaLevel.values.firstWhere(
(e) => e.toString() == map['dysphoria_level'],
);
} catch (_) {
// Keep as null if no match found
}
}
ExerciseLevel? exerciseLevel;
if (map['exercise_level'] != null) {
try {
exerciseLevel = ExerciseLevel.values.firstWhere(
(e) => e.toString() == map['exercise_level'],
);
} catch (_) {
// Keep as null if no match found
}
}
return MoodEntry(
id: map['id'] as String,
date: DateTime.parse(map['date'] as String),
mood: mood,
emotions: emotions,
notes: map['notes'] as String?,
sleepQuality: sleepQuality,
energyLevel: energyLevel,
libidoLevel: libidoLevel,
appetiteLevel: appetiteLevel,
focusLevel: focusLevel,
dysphoriaLevel: dysphoriaLevel,
exerciseLevel: exerciseLevel,
);
}
}

View file

@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// drugs_repository.dart
// Gets drug names from assets/drugs/drugs.txt
//
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Repository for drug-related data
class DrugsRepository {
/// Cached drug names to avoid reloading
List<String>? _cachedDrugNames;
/// The specific asset path to the drugs file
static const String _drugsFilePath = 'assets/drugs/drugs.txt';
/// Loads drug names from the text file
Future<List<String>> loadDrugNames() async {
// Return cached results if available
if (_cachedDrugNames != null && _cachedDrugNames!.isNotEmpty) {
print('Using ${_cachedDrugNames!.length} cached drug names');
return _cachedDrugNames!;
}
try {
print('Attempting to load drugs list from $_drugsFilePath');
// Load from assets/drugs/drugs.txt
final fileContent = await rootBundle.loadString(_drugsFilePath);
if (fileContent.isEmpty) {
print('Warning: Drug file was loaded but is empty');
return [];
}
final drugNames = _parseDrugNames(fileContent);
print('Successfully loaded ${drugNames.length} drug names');
// Cache the results
_cachedDrugNames = drugNames;
return drugNames;
} catch (e) {
print('Error loading drug names from $_drugsFilePath: $e');
return [];
}
}
/// Parse the file content into a list of drug names
List<String> _parseDrugNames(String fileContent) {
final drugNames = fileContent
.split('\n')
.where((drug) => drug.trim().isNotEmpty)
.map((drug) => drug.trim())
.toList();
return drugNames;
}
}
/// Provider for the drugs repository
final drugsRepositoryProvider = Provider<DrugsRepository>((ref) {
return DrugsRepository();
});
/// Provider for the list of drug names
final drugNamesProvider = FutureProvider<List<String>>((ref) async {
final repository = ref.watch(drugsRepositoryProvider);
return repository.loadDrugNames();
});

View file

@ -0,0 +1,114 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medical_providers_repository.dart
// Repository for accessing doctor and pharmacy autocomplete data
//
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
/// Repository class for accessing and managing medical provider data
class MedicalProvidersRepository {
final DatabaseService _databaseService;
MedicalProvidersRepository({required DatabaseService databaseService})
: _databaseService = databaseService;
/// Get all unique doctor names from medications and bloodwork records
Future<List<String>> getAllDoctors() async {
final Set<String> doctors = {};
try {
// Get doctors from medications
final medications = await _databaseService.getAllMedications();
for (final med in medications) {
if (med.doctor != null && med.doctor!.trim().isNotEmpty) {
doctors.add(med.doctor!);
}
}
// Get doctors from bloodwork
final bloodworks = await _databaseService.getAllBloodwork();
for (final bloodwork in bloodworks) {
if (bloodwork.doctor != null && bloodwork.doctor!.trim().isNotEmpty) {
doctors.add(bloodwork.doctor!);
}
}
// Sort alphabetically
final sortedDoctors = doctors.toList()..sort();
return sortedDoctors;
} catch (e) {
return [];
}
}
/// Get all unique pharmacy names from medications
Future<List<String>> getAllPharmacies() async {
final Set<String> pharmacies = {};
try {
// Get pharmacies from medications
final medications = await _databaseService.getAllMedications();
for (final med in medications) {
if (med.pharmacy != null && med.pharmacy!.trim().isNotEmpty) {
pharmacies.add(med.pharmacy!);
}
}
// Sort alphabetically
final sortedPharmacies = pharmacies.toList()..sort();
return sortedPharmacies;
} catch (e) {
return [];
}
}
/// Get all unique locations from bloodwork records
Future<List<String>> getAllLocations() async {
final Set<String> locations = {};
try {
// Get locations from bloodwork
final bloodworks = await _databaseService.getAllBloodwork();
for (final bloodwork in bloodworks) {
if (bloodwork.location != null &&
bloodwork.location!.trim().isNotEmpty) {
locations.add(bloodwork.location!);
}
}
// Sort alphabetically
final sortedLocations = locations.toList()..sort();
return sortedLocations;
} catch (e) {
return [];
}
}
}
/// Provider for the medical providers repository
final medicalProvidersRepositoryProvider =
Provider<MedicalProvidersRepository>((ref) {
final databaseService = ref.watch(databaseServiceProvider);
return MedicalProvidersRepository(databaseService: databaseService);
});
/// Provider for getting all unique doctor names
final doctorsProvider = FutureProvider<List<String>>((ref) async {
final repository = ref.watch(medicalProvidersRepositoryProvider);
return repository.getAllDoctors();
});
/// Provider for getting all unique pharmacy names
final pharmaciesProvider = FutureProvider<List<String>>((ref) async {
final repository = ref.watch(medicalProvidersRepositoryProvider);
return repository.getAllPharmacies();
});
/// Provider for getting all unique locations
final locationsProvider = FutureProvider<List<String>>((ref) async {
final repository = ref.watch(medicalProvidersRepositoryProvider);
return repository.getAllLocations();
});

View file

@ -0,0 +1,245 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// validation_service.dart
// Centralized validation logic for app-wide use
//
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/core/constants/date_constants.dart';
/// Service providing validation rules and messages for data across the application
class ValidationService {
// Private constructor to prevent instantiation
ValidationService._();
//----------------------------------------------------------------------------
// MEDICATION VALIDATION
//----------------------------------------------------------------------------
/// Validate a medication name
static ValidationResult validateMedicationName(String? name) {
if (name == null || name.trim().isEmpty) {
return ValidationResult(
isValid: false,
message: 'Please enter a medication name',
);
}
return ValidationResult.valid();
}
/// Validate a medication dosage
static ValidationResult validateMedicationDosage(String? dosage) {
if (dosage == null || dosage.trim().isEmpty) {
return ValidationResult(
isValid: false,
message: 'Please enter the dosage',
);
}
return ValidationResult.valid();
}
/// Validate frequency (times per day)
static ValidationResult validateFrequency(int frequency) {
if (frequency < 1 || frequency > 10) {
return ValidationResult(
isValid: false,
message: 'Frequency must be between 1 and 10',
);
}
return ValidationResult.valid();
}
/// Validate time of day entries
static ValidationResult validateTimeOfDay(
List<DateTime> timeOfDay, int frequency) {
if (timeOfDay.length != frequency) {
return ValidationResult(
isValid: false,
message: 'Number of times must match frequency',
);
}
return ValidationResult.valid();
}
/// Validate days of week selection
static ValidationResult validateDaysOfWeek(Set<String> daysOfWeek) {
if (daysOfWeek.isEmpty) {
return ValidationResult(
isValid: false,
message: 'At least one day must be selected',
);
}
if (!daysOfWeek.every((day) => DateConstants.orderedDays.contains(day))) {
return ValidationResult(
isValid: false,
message: 'Invalid day selection',
);
}
return ValidationResult.valid();
}
/// Validate medication inventory quantity
static ValidationResult validateQuantity(int quantity) {
if (quantity < 0) {
return ValidationResult(
isValid: false,
message: 'Quantity cannot be negative',
);
}
return ValidationResult.valid();
}
/// Validate injection details based on medication type
static ValidationResult validateInjectionDetails(
MedicationType type, InjectionDetails? details, int frequency) {
if (type == MedicationType.injection && details == null) {
return ValidationResult(
isValid: false,
message: 'Injection details required for injection type',
);
}
if (type != MedicationType.injection && details != null) {
return ValidationResult(
isValid: false,
message: 'Only injection type should have injection details',
);
}
if (type == MedicationType.injection &&
details?.frequency == InjectionFrequency.biweekly &&
frequency != 1) {
return ValidationResult(
isValid: false,
message: 'Biweekly injections must have frequency of 1',
);
}
return ValidationResult.valid();
}
/// Validate oral subtype based on medication type
static ValidationResult validateOralSubtype(
MedicationType type, OralSubtype? subtype) {
if (type == MedicationType.oral && subtype == null) {
return ValidationResult(
isValid: false,
message: 'Oral subtype required for oral medications',
);
}
if (type != MedicationType.oral && subtype != null) {
return ValidationResult(
isValid: false,
message: 'Only oral type should have oral subtype',
);
}
return ValidationResult.valid();
}
/// Validate topical subtype based on medication type
static ValidationResult validateTopicalSubtype(
MedicationType type, TopicalSubtype? subtype) {
if (type == MedicationType.topical && subtype == null) {
return ValidationResult(
isValid: false,
message: 'Topical subtype required for topical medications',
);
}
if (type != MedicationType.topical && subtype != null) {
return ValidationResult(
isValid: false,
message: 'Only topical type should have topical subtype',
);
}
return ValidationResult.valid();
}
/// Validate needle type
static ValidationResult validateNeedleType(String? needleType) {
if (needleType == null || needleType.isEmpty) {
return ValidationResult(
isValid: false, message: 'Please enter needle type');
}
return ValidationResult.valid();
}
/// Validate syringe type
static ValidationResult validateSyringeType(String? syringeType) {
if (syringeType == null || syringeType.isEmpty) {
return ValidationResult(
isValid: false, message: 'Please enter syringe type');
}
return ValidationResult.valid();
}
/// Validate needle count
static ValidationResult validateNeedleCount(String? countStr) {
final count = int.tryParse(countStr ?? '');
if (count == null) {
return ValidationResult(
isValid: false,
message: 'Please enter a valid number',
);
}
return ValidationResult.valid();
}
//----------------------------------------------------------------------------
// FORM INPUT VALIDATORS - Return string for Flutter form validation
//----------------------------------------------------------------------------
/// TextFormField validator for medication name
static String? nameValidator(String? value) {
final result = validateMedicationName(value);
return result.isValid ? null : result.message;
}
/// TextFormField validator for medication dosage
static String? dosageValidator(String? value) {
final result = validateMedicationDosage(value);
return result.isValid ? null : result.message;
}
/// TextFormField validator for needle type
static String? needleTypeValidator(String? value) {
final result = validateNeedleType(value);
return result.isValid ? null : result.message;
}
/// TextFormField validator for syringe type
static String? syringeTypeValidator(String? value) {
final result = validateSyringeType(value);
return result.isValid ? null : result.message;
}
/// TextFormField validator for numeric inputs
static String? numberValidator(String? value) {
if (value == null || int.tryParse(value) == null) {
return 'Please enter a valid number';
}
return null;
}
}
/// Result of a validation check
class ValidationResult {
final bool isValid;
final String? message;
const ValidationResult({
required this.isValid,
this.message,
});
/// Create a valid result with no message
factory ValidationResult.valid() => const ValidationResult(isValid: true);
/// Check if validation passed
bool get hasError => !isValid;
}

View file

@ -0,0 +1,139 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// navigation_service.dart
// Utility service for app-wide navigation functions
//
import 'package:flutter/material.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/core/services/navigation/routes/route_arguments.dart';
import 'package:nokken/src/core/services/navigation/routes/route_names.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
/// Abstracts navigation logic for consistent behavior throughout the app
class NavigationService {
/// Navigate back to the previous screen
static void goBack(BuildContext context) {
Navigator.pop(context);
}
/// Navigate back with a result value
/// Used for dialogs and forms that return data
static bool goBackWithResult<T>(BuildContext context, T result) {
Navigator.of(context).pop(result);
return result as bool;
}
/// Return to the home screen (clears navigation stack)
/// Might need to be updated to stop popping when reaching designated routes
/// (e.x. pop until calendar screen, rather than until daily tracker, which is under calendar)
static void goHome(BuildContext context) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
/// Navigate to the daily tracker screen
static void goToDailyTracker(BuildContext context) {
Navigator.pushNamed(context, RouteNames.dailyTracker);
}
/// Navigate to calendar screen
/// Returns a Future to allow a .then(), which is used to update taken DB when exiting calendar to daily tracker
static Future<void> goToCalendar(BuildContext context) {
return Navigator.pushNamed(context, RouteNames.calendar);
}
/// Navigate to the medication list screen
static void goToMedicationList(BuildContext context) {
Navigator.pushNamed(context, RouteNames.medicationList);
}
/// Navigate to medication details screen
static void goToMedicationDetails(BuildContext context,
{required Medication medication}) {
Navigator.pushNamed(
context,
RouteNames.medicationDetails,
arguments: ArgsMedicationDetails(medication: medication),
);
}
/// Navigate to add/edit medication screen
/// If medication is null, screen opens in 'add' mode
/// If medication is provided, screen opens in 'edit' mode
static void goToMedicationAddEdit(BuildContext context,
{Medication? medication}) {
Navigator.pushNamed(
context,
RouteNames.medicationAddEdit,
arguments: ArgsMedicaitonAddEdit(medication: medication),
);
}
static void goToInjectionSiteTracker(
BuildContext context, {
InjectionSiteRotation? initialRotation,
required Function(InjectionSiteRotation) onSave,
}) {
Navigator.pushNamed(
context,
RouteNames.injectionSiteTracker,
arguments: ArgsInjectionSiteTracker(
initialRotation: initialRotation,
onSave: onSave,
),
);
}
static void goToInjectionSiteViewer(
BuildContext context, {
required Medication medication,
}) {
Navigator.pushNamed(
context,
RouteNames.injectionSiteViewer,
arguments: ArgsMedicationDetails(medication: medication),
);
}
/// Navigate to bloodwork list screen
static void goToBloodworkList(BuildContext context) {
Navigator.pushNamed(context, RouteNames.bloodworkList);
}
/// Navigate to bloodwork overview screen
static void goToBloodLevelList(BuildContext context) {
Navigator.pushNamed(context, RouteNames.bloodLevelList);
}
/// Navigate to bloodwork graph screen
static void goToBloodworkGraph(BuildContext context) {
Navigator.pushNamed(context, RouteNames.bloodworkGraph);
}
/// Navigate to bloodwork graph screen for a specific hormone
static void goToBloodworkGraphWithHormone(
BuildContext context, String hormoneName) {
Navigator.pushNamed(
context,
RouteNames.bloodworkGraph,
arguments: ArgsBloodworkGraph(selectedHormone: hormoneName),
);
}
/// Navigate to add/edit bloodwork screen
/// If bloodwork is null, screen opens in 'add' mode
/// If bloodwork is provided, screen opens in 'edit' mode
static void goToBloodworkAddEdit(BuildContext context,
{Bloodwork? bloodwork}) {
Navigator.pushNamed(
context,
RouteNames.bloodworkAddEdit,
arguments: ArgsBloodworkAddEdit(bloodwork: bloodwork),
);
}
/// Navigate to settings screen
static void goToSettings(BuildContext context) {
Navigator.pushNamed(context, RouteNames.settings);
}
}

View file

@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// app_router.dart
// Centralized navigation router
//
import 'package:flutter/material.dart';
import 'package:nokken/src/features/medication_tracker/screens/injection_site_viewer_screen.dart';
import 'route_names.dart';
import 'package:nokken/src/core/services/navigation/routes/route_arguments.dart';
import 'package:nokken/src/features/scheduler/screens/daily_tracker_screen.dart';
import 'package:nokken/src/features/scheduler/screens/calendar_screen.dart';
import 'package:nokken/src/features/medication_tracker/screens/medication_list_screen.dart';
import 'package:nokken/src/features/medication_tracker/screens/medication_detail_screen.dart';
import 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/add_edit_medication_screen.dart';
import 'package:nokken/src/features/medication_tracker/screens/injection_site_tracker_screen.dart';
import 'package:nokken/src/features/bloodwork_tracker/screens/bloodwork_list_screen.dart';
import 'package:nokken/src/features/bloodwork_tracker/screens/add_edit_bloodwork/add_edit_bloodwork_screen.dart';
import 'package:nokken/src/features/bloodwork_tracker/screens/bloodwork_graph_screen.dart';
import 'package:nokken/src/features/bloodwork_tracker/screens/blood_level_list_screen.dart';
import 'package:nokken/src/features/settings/screens/settings_screen.dart';
/// Router class that handles all navigation within the app
class AppRouter {
/// Called by MaterialApp's onGenerateRoute property
static Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
case RouteNames.dailyTracker:
return MaterialPageRoute(builder: (_) => DailyTrackerScreen());
case RouteNames.calendar:
return MaterialPageRoute(builder: (_) => const CalendarScreen());
case RouteNames.medicationList:
return MaterialPageRoute(builder: (_) => const MedicationListScreen());
case RouteNames.medicationDetails:
final args = settings.arguments as ArgsMedicationDetails;
return MaterialPageRoute(
builder: (_) => MedicationDetailScreen(
medication: args.medication,
),
);
case RouteNames.medicationAddEdit:
if (settings.arguments == null) {
return MaterialPageRoute(
builder: (_) =>
const AddEditMedicationScreen(), // No args = Add new
);
}
final args = settings.arguments as ArgsMedicaitonAddEdit;
return MaterialPageRoute(
builder: (_) => AddEditMedicationScreen(
medication: args.medication,
),
);
case RouteNames.injectionSiteTracker:
final args = settings.arguments as ArgsInjectionSiteTracker;
return MaterialPageRoute(
builder: (_) => InjectionSiteTrackerScreen(
initialRotation: args.initialRotation,
onSave: args.onSave,
),
);
case RouteNames.injectionSiteViewer:
final args = settings.arguments as ArgsMedicationDetails;
return MaterialPageRoute(
builder: (_) => InjectionSiteViewer(
currentSite:
args.medication.injectionDetails!.siteRotation!.nextSite,
),
);
case RouteNames.bloodworkList:
return MaterialPageRoute(builder: (_) => const BloodworkListScreen());
case RouteNames.bloodLevelList:
return MaterialPageRoute(builder: (_) => const BloodLevelListScreen());
case RouteNames.bloodworkAddEdit:
if (settings.arguments == null) {
return MaterialPageRoute(
builder: (_) => const AddEditBloodworkScreen(), // No args = Add new
);
}
final args = settings.arguments as ArgsBloodworkAddEdit;
return MaterialPageRoute(
builder: (_) => AddEditBloodworkScreen(
bloodwork: args.bloodwork,
),
);
case RouteNames.bloodworkGraph:
if (settings.arguments == null) {
return MaterialPageRoute(
builder: (_) => const BloodworkGraphScreen());
}
final args = settings.arguments as ArgsBloodworkGraph;
return MaterialPageRoute(
builder: (_) => BloodworkGraphScreen(
selectedHormone: args.selectedHormone,
),
);
case RouteNames.settings:
return MaterialPageRoute(builder: (_) => const SettingsScreen());
// Fallback for unknown routes
default:
return MaterialPageRoute(
builder: (_) => Scaffold(
body: Center(
child: Text('No route defined for ${settings.name}'),
),
),
);
}
}
}

View file

@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// route_arguments.dart
// Classes for passing typed arguments to routes
//
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
/*
class ArgsCalendarDaily {
final DateTime selectedDay;
ArgsCalendarDaily({required this.selectedDay});
}*/
/// Arguments for the medication details screen
/// Passes the medication to be displayed (required)
class ArgsMedicationDetails {
final Medication medication;
ArgsMedicationDetails({required this.medication});
}
/// Arguments for the medication add/edit screen
/// Can be null when adding a new medication
class ArgsMedicaitonAddEdit {
final Medication? medication;
ArgsMedicaitonAddEdit({this.medication});
}
class ArgsInjectionSiteTracker {
final InjectionSiteRotation? initialRotation;
final Function(InjectionSiteRotation) onSave;
ArgsInjectionSiteTracker({
this.initialRotation,
required this.onSave,
});
}
/// Arguments for the bloodwork add/edit screen
/// Can be null when adding a new bloodwork record
class ArgsBloodworkAddEdit {
final Bloodwork? bloodwork;
ArgsBloodworkAddEdit({this.bloodwork});
}
/// Arguments for the bloodwork graph screen
/// Contains the selected hormone to display
class ArgsBloodworkGraph {
final String? selectedHormone;
ArgsBloodworkGraph({this.selectedHormone});
}

View file

@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// route_names.dart
// Constants for all route paths
//
/// Contains string constants for all route paths
/// Used to maintain consistency in navigation throughout the app
class RouteNames {
static const String dailyTracker = '/calendar/day';
static const String calendar = '/calendar/month';
static const String medicationList = '/medication/list';
static const String medicationDetails = '/medication/details';
static const String medicationAddEdit = '/medication/add-edit';
static const String injectionSiteTracker =
'/medication/injection-site-tracker';
static const String injectionSiteViewer = '/medication/injection-site-viewer';
static const String bloodworkList = '/bloodwork/list';
static const String bloodworkAddEdit = '/bloodwork/add-edit';
static const String bloodLevelList = '/bloodwork/level-list';
static const String bloodworkGraph = '/bloodwork/graph';
static const String settings = '/settings';
}

View file

@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// notification_service.dart
//
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tz;
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'dart:io' show Platform;
class NotificationException implements Exception {
final String message;
NotificationException(this.message);
@override
String toString() => 'NotificationException: $message';
}
class NotificationService {
static final NotificationService _instance = NotificationService._();
factory NotificationService() => _instance;
NotificationService._();
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
bool get _isMobilePlatform => Platform.isAndroid || Platform.isIOS;
Future<void> initialize() async {
if (_isInitialized || !_isMobilePlatform) return;
try {
tz.initializeTimeZones();
const initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const initializationSettingsIOS = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
);
await _notifications.initialize(initializationSettings);
_isInitialized = true;
} catch (e) {
if (_isMobilePlatform) {
throw NotificationException('Failed to initialize notifications: $e');
}
}
}
Future<void> scheduleMedicationReminders(Medication medication) async {
if (!_isMobilePlatform) {
// Silently fail on non-mobile platforms
//print('Skipping notification scheduling on non-mobile platform');
return;
}
try {
await initialize();
// Cancel existing notifications for this medication
await cancelMedicationReminders(medication.id);
// Schedule new notifications for each time
for (final time in medication.timeOfDay) {
final now = DateTime.now();
var scheduledDate = DateTime(
now.year,
now.month,
now.day,
time.hour,
time.minute,
);
if (scheduledDate.isBefore(now)) {
scheduledDate = scheduledDate.add(const Duration(days: 1));
}
final tzDateTime = tz.TZDateTime.from(scheduledDate, tz.local);
await _notifications.zonedSchedule(
medication.id.hashCode + time.hashCode,
'Medication Reminder',
'Time to take ${medication.name} - ${medication.dosage}',
tzDateTime,
const NotificationDetails(
android: AndroidNotificationDetails(
'medication_reminders',
'Medication Reminders',
channelDescription: 'Reminders to take medications',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
matchDateTimeComponents: DateTimeComponents.time,
payload: medication.id,
);
}
// Schedule refill reminder if needed
if (medication.needsRefill()) {
await _notifications.zonedSchedule(
medication.id.hashCode,
'Refill Reminder',
'Time to refill ${medication.name}',
tz.TZDateTime.now(tz.local).add(const Duration(days: 1)),
const NotificationDetails(
android: AndroidNotificationDetails(
'refill_reminders',
'Refill Reminders',
channelDescription: 'Reminders to refill medications',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
payload: 'refill_${medication.id}',
);
}
} catch (e) {
if (_isMobilePlatform) {
throw NotificationException('Failed to schedule reminders: $e');
}
//print('Error scheduling notifications on non-mobile platform: $e');
}
}
Future<void> cancelMedicationReminders(String medicationId) async {
if (!_isMobilePlatform) {
//print('Skipping notification cancellation on non-mobile platform');
return;
}
try {
await _notifications.cancel(medicationId.hashCode);
for (var i = 0; i < 10; i++) {
await _notifications
.cancel(medicationId.hashCode + DateTime.now().hour.hashCode + i);
}
} catch (e) {
if (_isMobilePlatform) {
throw NotificationException('Failed to cancel reminders: $e');
}
//print('Error cancelling notifications on non-mobile platform: $e');
}
}
Future<void> requestPermissions() async {
if (!_isMobilePlatform) {
//print('Skipping permission request on non-mobile platform');
return;
}
try {
await initialize();
final android = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await android?.requestNotificationsPermission();
final ios = _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
await ios?.requestPermissions(
alert: true,
badge: true,
sound: true,
);
} catch (e) {
if (_isMobilePlatform) {
throw NotificationException('Failed to request permissions: $e');
}
//print('Error requesting permissions on non-mobile platform: $e');
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
/// Theme-aware color provider that adjusts based on current theme mode
class AppColors {
// Private constructor to prevent instantiation
AppColors._();
// Current theme mode
static ThemeMode themeMode = ThemeMode.dark;
// Dark and light color schemes
static final ColorScheme _darkScheme = AppTheme.darkColorScheme;
static final ColorScheme _lightScheme = AppTheme.lightColorScheme;
/// Get current scheme based on theme mode
static ColorScheme get current =>
themeMode == ThemeMode.light ? _lightScheme : _darkScheme;
/// Method to change theme mode
static void setThemeMode(ThemeMode mode) {
themeMode = mode;
// A notifier could be added here for more reactive theme changes
}
// Basic theme colors
static Color get primary => current.primary;
static Color get onPrimary => current.onPrimary;
static Color get secondary => current.secondary;
static Color get onSecondary => current.onSecondary;
static Color get tertiary => current.tertiary;
static Color get onTertiary => current.onTertiary;
// Surface colors
static Color get surface => current.surface;
static Color get surfaceContainer => current.surfaceContainer;
static Color get onSurface => current.onSurface;
static Color get onSurfaceVariant => current.onSurfaceVariant;
// Status colors
static Color get error => current.error;
static Color get errorContainer => current.errorContainer;
static Color get success =>
themeMode == ThemeMode.light ? AppTheme.greenLight : AppTheme.greenDark;
static Color get warning =>
themeMode == ThemeMode.light ? AppTheme.orangeLight : AppTheme.orangeDark;
static Color get info => current.primary;
// Medication type colors
static Color get oralMedication => themeMode == ThemeMode.light
? AppTheme.oralMedColorLight
: AppTheme.oralMedColorDark;
static Color get topical => themeMode == ThemeMode.light
? AppTheme.topicalColorLight
: AppTheme.topicalColorDark;
static Color get patch => themeMode == ThemeMode.light
? AppTheme.patchColorLight
: AppTheme.patchColorDark;
static Color get injection => themeMode == ThemeMode.light
? AppTheme.injectionColorLight
: AppTheme.injectionColorDark;
// Appointment type colors
static Color get bloodwork => themeMode == ThemeMode.light
? AppTheme.bloodworkColorLight
: AppTheme.bloodworkColorDark;
static Color get doctorAppointment => themeMode == ThemeMode.light
? AppTheme.doctorApptColorLight
: AppTheme.doctorApptColorDark;
static Color get surgery => themeMode == ThemeMode.light
? AppTheme.surgeryColorLight
: AppTheme.surgeryColorDark;
// Mood colors
static Color get moodGreat => themeMode == ThemeMode.light
? AppTheme.moodGreatLight
: AppTheme.moodGreatDark;
static Color get moodGood => themeMode == ThemeMode.light
? AppTheme.moodGoodLight
: AppTheme.moodGoodDark;
static Color get moodOkay => themeMode == ThemeMode.light
? AppTheme.moodOkayLight
: AppTheme.moodOkayDark;
static Color get moodMeh => themeMode == ThemeMode.light
? AppTheme.moodMehLight
: AppTheme.moodMehDark;
static Color get moodBad => themeMode == ThemeMode.light
? AppTheme.moodBadLight
: AppTheme.moodBadDark;
// Other UI element colors
static Color get shadow => current.shadow;
static Color get outline => current.outline;
static Color get cardColor => current.surfaceContainer;
}

View file

@ -0,0 +1,216 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// app_icons.dart
// Centralized icon management
//
import 'package:flutter/material.dart';
/// Provides outlined and filled versions of each icon
class AppIcons {
// Map of icon pairs where key is a string identifier and value is a pair of outlined/filled icons
static final Map<String, ({IconData outlined, IconData filled})> iconMap = {
// Navigation
'home': (outlined: Icons.home_outlined, filled: Icons.home),
'settings': (outlined: Icons.settings_outlined, filled: Icons.settings),
'profile': (outlined: Icons.person_outline, filled: Icons.person),
'menu': (outlined: Icons.menu_outlined, filled: Icons.menu),
// Time of day
'sun': (outlined: Icons.wb_sunny_outlined, filled: Icons.wb_sunny),
'twilight': (
outlined: Icons.wb_twilight_outlined,
filled: Icons.wb_twilight
),
'night': (outlined: Icons.bedtime_outlined, filled: Icons.bedtime),
// Actions
'add': (outlined: Icons.add, filled: Icons.add),
'remove': (outlined: Icons.remove, filled: Icons.remove),
'edit': (outlined: Icons.edit_outlined, filled: Icons.edit),
'delete': (outlined: Icons.delete_outline, filled: Icons.delete),
'save': (outlined: Icons.save_outlined, filled: Icons.save),
'refresh': (outlined: Icons.refresh_outlined, filled: Icons.refresh),
'redo': (outlined: Icons.redo_outlined, filled: Icons.redo),
'undo': (outlined: Icons.undo_outlined, filled: Icons.undo),
'check': (outlined: Icons.check_circle_outline, filled: Icons.check_circle),
'checkmark': (outlined: Icons.check, filled: Icons.check),
'filter_list': (
outlined: Icons.filter_list_outlined,
filled: Icons.filter_list
),
'expand_more': (
outlined: Icons.expand_more_outlined,
filled: Icons.expand_more
),
// Arrows
'arrow_back': (
outlined: Icons.arrow_back_outlined,
filled: Icons.arrow_back
),
'arrow_forward': (
outlined: Icons.arrow_forward_outlined,
filled: Icons.arrow_forward
),
'arrow_up': (
outlined: Icons.arrow_upward_outlined,
filled: Icons.arrow_upward
),
'arrow_down': (
outlined: Icons.arrow_downward_outlined,
filled: Icons.arrow_downward
),
'arrow_left': (
outlined: Icons.arrow_left_outlined,
filled: Icons.arrow_left
),
'arrow_right': (
outlined: Icons.arrow_right_outlined,
filled: Icons.arrow_right
),
'arrow_circle_left': (
outlined: Icons.arrow_circle_left_outlined,
filled: Icons.arrow_circle_left
),
'arrow_circle_right': (
outlined: Icons.arrow_circle_right_outlined,
filled: Icons.arrow_circle_right
),
'arrow_circle_up': (
outlined: Icons.arrow_circle_up_outlined,
filled: Icons.arrow_circle_up
),
'arrow_circle_down': (
outlined: Icons.arrow_circle_down_outlined,
filled: Icons.arrow_circle_down
),
'arrow_back_ios': (
outlined: Icons.arrow_back_ios_outlined,
filled: Icons.arrow_back_ios
),
'arrow_forward_ios': (
outlined: Icons.arrow_forward_ios_outlined,
filled: Icons.arrow_forward_ios
),
'chevron_left': (
outlined: Icons.chevron_left_outlined,
filled: Icons.chevron_left
),
'chevron_right': (
outlined: Icons.chevron_right_outlined,
filled: Icons.chevron_right
),
// Communication
'message': (outlined: Icons.message_outlined, filled: Icons.message),
'notification': (
outlined: Icons.notifications_outlined,
filled: Icons.notifications
),
'email': (outlined: Icons.email_outlined, filled: Icons.email),
'phone': (outlined: Icons.phone_outlined, filled: Icons.phone),
// Medical and Health
'medication': (
outlined: Icons.medication_outlined,
filled: Icons.medication
),
'pharmacy': (
outlined: Icons.local_pharmacy_outlined,
filled: Icons.local_pharmacy
),
'medical_services': (
outlined: Icons.medical_services_outlined,
filled: Icons.medical_services
),
'vaccine': (outlined: Icons.vaccines_outlined, filled: Icons.vaccines),
'vial': (outlined: Icons.science_outlined, filled: Icons.science),
'medical_info': (
outlined: Icons.medical_information_outlined,
filled: Icons.medical_information
),
'bloodwork': (outlined: Icons.science_outlined, filled: Icons.science),
'surgery': (
outlined: Icons.medical_information_outlined,
filled: Icons.medical_information
),
'doctor': (outlined: Icons.person_outlined, filled: Icons.person),
'inventory': (
outlined: Icons.inventory_2_outlined,
filled: Icons.inventory_2
),
'analytics': (outlined: Icons.analytics_outlined, filled: Icons.analytics),
'lab': (outlined: Icons.biotech_outlined, filled: Icons.biotech),
'topical': (outlined: Icons.spa_outlined, filled: Icons.spa),
'patch': (outlined: Icons.healing_outlined, filled: Icons.healing),
// Location
'location': (
outlined: Icons.location_on_outlined,
filled: Icons.location_on
),
// Status
'success': (
outlined: Icons.check_circle_outline,
filled: Icons.check_circle
),
'warning': (outlined: Icons.warning_outlined, filled: Icons.warning),
'error': (outlined: Icons.error_outline, filled: Icons.error),
'info': (outlined: Icons.info_outline, filled: Icons.info),
'construction': (outlined: Icons.construction, filled: Icons.construction),
// Time related
'calendar': (
outlined: Icons.calendar_today_outlined,
filled: Icons.calendar_today
),
'clock': (outlined: Icons.access_time_outlined, filled: Icons.access_time),
'alarm': (outlined: Icons.alarm_outlined, filled: Icons.alarm),
'schedule': (outlined: Icons.schedule_outlined, filled: Icons.schedule),
'today': (outlined: Icons.today_outlined, filled: Icons.today),
'event': (outlined: Icons.event_outlined, filled: Icons.event),
'history': (outlined: Icons.history_outlined, filled: Icons.history),
'event_note': (
outlined: Icons.event_note_outlined,
filled: Icons.event_note
),
// Misc
'remove_circle': (
outlined: Icons.remove_circle_outline,
filled: Icons.remove_circle
),
};
/// Get the outlined version of an icon
static IconData getOutlined(String name) {
return iconMap[name]?.outlined ?? Icons.error_outline;
}
/// Get the filled version of an icon
static IconData getFilled(String name) {
return iconMap[name]?.filled ?? Icons.error;
}
/// Get icon based on selected state (filled when selected, outlined when not)
static IconData getIcon(String name, {bool selected = false}) {
return selected ? getFilled(name) : getOutlined(name);
}
/// Get the appropriate appointment type icon
static IconData getAppointmentTypeIcon(String type) {
switch (type) {
case 'bloodwork':
return getOutlined('bloodwork');
case 'appointment':
return getOutlined('medical_services');
case 'surgery':
return getOutlined('medical_info');
default:
return getOutlined('event_note');
}
}
}

View file

@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
/// Theme-aware text styles that adjust based on current theme mode
class AppTextStyles {
// Private constructor to prevent instantiation
AppTextStyles._();
/// Get current text theme based on theme mode
static TextTheme get _current => AppColors.themeMode == ThemeMode.light
? _getLightTextTheme()
: _getDarkTextTheme();
/// Create the dark theme text styles
static TextTheme _getDarkTextTheme() => TextTheme(
displayLarge: AppTheme.displayLarge,
displayMedium: AppTheme.displayMedium,
displaySmall: AppTheme.displaySmall,
headlineLarge: AppTheme.headlineLarge,
headlineMedium: AppTheme.headlineMedium,
headlineSmall: AppTheme.headlineSmall,
titleLarge: AppTheme.titleLarge,
titleMedium: AppTheme.titleMedium,
titleSmall: AppTheme.titleSmall,
bodyLarge: AppTheme.bodyLarge,
bodyMedium: AppTheme.bodyMedium,
bodySmall: AppTheme.bodySmall,
labelLarge: AppTheme.labelLarge,
labelMedium: AppTheme.labelMedium,
labelSmall: AppTheme.labelSmall,
);
/// Create the light theme text styles with adjusted colors
static TextTheme _getLightTextTheme() {
return TextTheme(
displayLarge: AppTheme.displayLarge.copyWith(color: AppTheme.black),
displayMedium: AppTheme.displayMedium.copyWith(color: AppTheme.black),
displaySmall: AppTheme.displaySmall.copyWith(color: AppTheme.black),
headlineLarge: AppTheme.headlineLarge.copyWith(color: AppTheme.black),
headlineMedium: AppTheme.headlineMedium.copyWith(color: AppTheme.black),
headlineSmall: AppTheme.headlineSmall.copyWith(color: AppTheme.black),
titleLarge: AppTheme.titleLarge.copyWith(color: AppTheme.black),
titleMedium: AppTheme.titleMedium.copyWith(color: AppTheme.black),
titleSmall: AppTheme.titleSmall.copyWith(color: AppTheme.black),
bodyLarge: AppTheme.bodyLarge.copyWith(color: AppTheme.black),
bodyMedium: AppTheme.bodyMedium.copyWith(color: AppTheme.black),
bodySmall: AppTheme.bodySmall.copyWith(color: AppTheme.black),
labelLarge: AppTheme.labelLarge.copyWith(color: AppTheme.black),
labelMedium: AppTheme.labelMedium.copyWith(color: AppTheme.black),
labelSmall: AppTheme.labelSmall.copyWith(color: AppTheme.black),
);
}
// Display styles
static TextStyle get displayLarge => _current.displayLarge!;
static TextStyle get displayMedium => _current.displayMedium!;
static TextStyle get displaySmall => _current.displaySmall!;
// Headline styles
static TextStyle get headlineLarge => _current.headlineLarge!;
static TextStyle get headlineMedium => _current.headlineMedium!;
static TextStyle get headlineSmall => _current.headlineSmall!;
// Title styles
static TextStyle get titleLarge => _current.titleLarge!;
static TextStyle get titleMedium => _current.titleMedium!;
static TextStyle get titleSmall => _current.titleSmall!;
// Body styles
static TextStyle get bodyLarge => _current.bodyLarge!;
static TextStyle get bodyMedium => _current.bodyMedium!;
static TextStyle get bodySmall => _current.bodySmall!;
// Label styles
static TextStyle get labelLarge => _current.labelLarge!;
static TextStyle get labelMedium => _current.labelMedium!;
static TextStyle get labelSmall => _current.labelSmall!;
// Utility styles - these adjust color based on current theme automatically
static TextStyle get buttonText => AppColors.themeMode == ThemeMode.light
? AppTheme.buttonText.copyWith(color: AppTheme.black)
: AppTheme.buttonText;
static TextStyle get caption => AppColors.themeMode == ThemeMode.light
? AppTheme.caption.copyWith(color: AppTheme.black.withAlpha(179))
: AppTheme.caption;
static TextStyle get overline => AppColors.themeMode == ThemeMode.light
? AppTheme.overline.copyWith(color: AppTheme.black)
: AppTheme.overline;
static TextStyle get error => AppColors.themeMode == ThemeMode.light
? AppTheme.error.copyWith(color: AppTheme.pinkDark.withRed(220))
: AppTheme.error;
static TextStyle get link => AppColors.themeMode == ThemeMode.light
? AppTheme.link.copyWith(color: AppTheme.blueLight)
: AppTheme.link;
}

View file

@ -0,0 +1,803 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// app_theme.dart
// Theme system
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
/// Main theme class with comprehensive style definitions
class AppTheme {
//----------------------------------------------------------------------------
// SPACING CONSTANTS
//----------------------------------------------------------------------------
/// Standard spacing unit for margins, padding, etc.
static const double spacing = 8.0;
static const double doubleSpacing = spacing * 2;
static const double tripleSpacing = spacing * 3;
/// Standard padding for containers
static const double padding = 16.0;
/// Standard padding for cards
static const double cardPadding = 16.0;
/// Standard spacing between cards
static const double cardSpacing = 24.0;
/// Standard padding for navigation elements
static const double navbarPadding = 8.0;
//----------------------------------------------------------------------------
// STANDARD EDGE INSETS
//----------------------------------------------------------------------------
/// Standard padding for cards
static const EdgeInsets standardCardPadding = EdgeInsets.all(cardPadding);
/// Standard margins for screens
static const EdgeInsets standardScreenMargins = EdgeInsets.all(padding);
/// Standard padding for navigation bar icons
static const EdgeInsets navigationBarPadding =
EdgeInsets.symmetric(horizontal: navbarPadding, vertical: 0);
///
/// EMOJI STYLES
///
static const String emojiStyle = "Noto Color Emoji";
//----------------------------------------------------------------------------
// BASE COLORS
//----------------------------------------------------------------------------
static const Color black = Color(0xFF050505);
static const Color white = Color(0xFFF9F9F9);
static const Color darkgrey = Color(0xFF151515);
static const Color grey = Color(0xFF909090);
//----------------------------------------------------------------------------
// NOTIFICATION TYPE COLORS
//----------------------------------------------------------------------------
static const oralMedColorDark = Color(0xFF8A7EFF);
static const injectionColorDark = Color(0xFFBF7AFF);
static const patchColorDark = Color(0xFF45AAF2);
static const topicalColorDark = Color(0xFFB8A9DB);
static const oralMedColorLight = Color(0xFF5B48DA);
static const injectionColorLight = Color(0xFF8854D0);
static const patchColorLight = Color(0xFF3867D6);
static const topicalColorLight = Color(0xFF9F86C0);
static const bloodworkColorDark = Color(0xFFFF5D7E);
static const doctorApptColorDark = Color(0xFFFF9F45);
static const surgeryColorDark = Color(0xFFFFD166);
static const bloodworkColorLight = Color(0xFFE63946);
static const doctorApptColorLight = Color(0xFFE67E22);
static const surgeryColorLight = Color(0xFFE6B800);
//
// MOOD COLORS
//
static const moodGreatDark = Color(0xFF00E0A0);
static const moodGoodDark = Color(0xFF74E878);
static const moodOkayDark = Color(0xFFFFD166);
static const moodMehDark = Color(0xFFFF9F45);
static const moodBadDark = Color(0xFFFF5D7E);
static const moodGreatLight = Color(0xFF00B280);
static const moodGoodLight = Color(0xFF5AC85A);
static const moodOkayLight = Color(0xFFE6B800);
static const moodMehLight = Color(0xFFE67E22);
static const moodBadLight = Color(0xFFE63946);
//----------------------------------------------------------------------------
// DARK THEME COLORS
//----------------------------------------------------------------------------
static const Color greyDark = Color(0xFF252525);
static const Color lightgreyDark = Color(0xFF9BA0B4);
static const Color blueDark = Color(0xFF00D0A0);
static const Color lightblueDark = Color(0xFF00F0B8);
static const Color darkblueDark = Color(0xFF00A080);
static const Color orangeDark = Color(0xFFFF6B6B);
static const Color pinkDark = Color(0xFFFF85A1);
static const Color lightpinkDark = Color(0xFFFFABC1);
static const Color greenDark = Color(0xFF00E0A0);
//----------------------------------------------------------------------------
// LIGHT THEME COLORS
//----------------------------------------------------------------------------
static const Color greyLight = Color(0xFFE3E5ED);
static const Color lightgreyLight = Color(0xFFF5F7FA);
static const Color blueLight = Color(0xFF00B890);
static const Color lightblueLight = Color(0xFF33D0A8);
static const Color darkblueLight = Color(0xFF009878);
static const Color orangeLight = Color(0xFFE65100);
static const Color pinkLight = Color(0xFFD81B60);
static const Color lightpinkLight = Color(0xFFEC407A);
static const Color greenLight = Color(0xFF00B890);
//----------------------------------------------------------------------------
// COLOR SCHEMES
//----------------------------------------------------------------------------
/// Dark mode color scheme
static final ColorScheme darkColorScheme = ColorScheme.dark(
primary: blueDark,
secondary: orangeDark,
tertiary: greenDark,
surface: darkgrey,
surfaceContainer: greyDark.withAlpha(220),
onPrimary: white,
onSecondary: white,
onTertiary: white,
onSurface: white,
onSurfaceVariant: lightgreyDark,
error: Color(0xFFFF5252),
errorContainer: Color(0xFF800020).withAlpha(160),
shadow: black.withAlpha(40),
outline: lightgreyDark.withAlpha(120),
inverseSurface: lightgreyLight,
onInverseSurface: black,
inversePrimary: darkblueLight,
);
/// Light mode color scheme
static final ColorScheme lightColorScheme = ColorScheme.light(
primary: blueLight,
secondary: orangeLight,
tertiary: greenLight,
surface: white,
surfaceContainer: greyLight,
onPrimary: white,
onSecondary: white,
onTertiary: white,
onSurface: Color(0xFF212121),
onSurfaceVariant: darkgrey.withAlpha(180),
error: Color(0xFFD32F2F),
errorContainer: Color(0xFFFFDAD6),
shadow: black.withAlpha(25),
outline: greyLight.withAlpha(180),
inverseSurface: darkgrey,
onInverseSurface: white,
inversePrimary: lightblueDark,
);
/// Default text field decoration used throughout the app
static const defaultTextFieldDecoration = InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
);
//----------------------------------------------------------------------------
// TEXT STYLES
//----------------------------------------------------------------------------
static final TextStyle displayLarge = TextStyle(
fontFamily: 'Nunito',
fontSize: 45,
fontWeight: FontWeight.w400,
letterSpacing: -0.25,
height: 1.12,
color: AppColors.onSurface,
);
static final TextStyle displayMedium = TextStyle(
fontFamily: 'Nunito',
fontSize: 32,
fontWeight: FontWeight.w400,
letterSpacing: 0,
height: 1.16,
color: AppColors.onSurface,
);
static final TextStyle displaySmall = TextStyle(
fontFamily: 'Nunito',
fontSize: 24,
fontWeight: FontWeight.w400,
letterSpacing: 0,
height: 1.22,
color: AppColors.onSurface,
);
// Headline Styles
static final TextStyle headlineLarge = TextStyle(
fontFamily: 'Nunito',
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: 0,
height: 1.25,
color: AppColors.onSurface,
);
static final TextStyle headlineMedium = TextStyle(
fontFamily: 'Nunito',
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: 0,
height: 1.29,
color: AppColors.onSurface,
);
static final TextStyle headlineSmall = TextStyle(
fontFamily: 'Nunito',
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.33,
color: AppColors.onSurface,
);
// Title Styles
static final TextStyle titleLarge = TextStyle(
fontFamily: 'Nunito',
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.27,
color: AppColors.onSurface,
);
static final TextStyle titleMedium = TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.15,
height: 1.5,
color: AppColors.onSurface,
);
static final TextStyle titleSmall = TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
height: 1.43,
color: AppColors.onSurface,
);
// Label Styles
static final TextStyle labelLarge = TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
height: 1.43,
color: AppColors.onSurface,
);
static final TextStyle labelMedium = TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
height: 0.83,
color: AppColors.onSurface,
);
static final TextStyle labelSmall = TextStyle(
fontFamily: 'Nunito',
fontSize: 10,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
height: 1,
color: AppColors.onSurface,
);
// Body Styles
static final TextStyle bodyLarge = TextStyle(
fontFamily: 'Nunito',
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
height: 1.5,
color: AppColors.onSurface,
);
static final TextStyle bodyMedium = TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.25,
height: 1.33,
color: AppColors.onSurface,
);
static final TextStyle bodySmall = TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.4,
height: 1.25,
color: AppColors.onSurface,
);
// Additional Utility Text Styles
static final TextStyle buttonText = TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.40,
height: 0.75,
color: AppColors.onSurface,
);
static final TextStyle caption = TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.4,
height: 1.33,
color: AppColors.onSurfaceVariant.withAlpha(180),
);
static final TextStyle overline = TextStyle(
fontFamily: 'Nunito',
fontSize: 10,
fontWeight: FontWeight.w400,
letterSpacing: 1.5,
height: 1.6,
textBaseline: TextBaseline.alphabetic,
color: AppColors.onSurface,
);
// Helper Styles
static final TextStyle error = TextStyle(
fontFamily: 'Nunito',
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.error.withRed(220),
letterSpacing: 0.4,
height: 1.33,
);
static final TextStyle link = TextStyle(
fontFamily: 'Nunito',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primary,
decoration: TextDecoration.underline,
letterSpacing: 0.25,
height: 1.43,
);
//----------------------------------------------------------------------------
// THEME DATA CONFIGURATION
//----------------------------------------------------------------------------
/// Get ThemeData based on specified theme mode
static ThemeData getTheme(ThemeMode mode) {
return mode == ThemeMode.light ? lightTheme : darkTheme;
}
//----------------------------------------------------------------------------
// DARK THEME
//----------------------------------------------------------------------------
/// Dark theme configuration
static final ThemeData darkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: darkColorScheme,
// Dark - Text Theme
textTheme: TextTheme(
displayLarge: displayLarge,
displayMedium: displayMedium,
displaySmall: displaySmall,
headlineLarge: headlineLarge,
headlineMedium: headlineMedium,
headlineSmall: headlineSmall,
titleLarge: titleLarge,
titleMedium: titleMedium,
titleSmall: titleSmall,
bodyLarge: bodyLarge,
bodyMedium: bodyMedium,
bodySmall: bodySmall,
labelLarge: labelLarge,
labelMedium: labelMedium,
labelSmall: labelSmall,
),
// Dark - AppBar Theme
appBarTheme: AppBarTheme(
toolbarHeight: 40,
backgroundColor: darkColorScheme.primary,
foregroundColor:
darkColorScheme.onPrimary, // Now using black on bright blue
elevation: 0,
centerTitle: true,
titleTextStyle: headlineSmall.copyWith(color: darkColorScheme.onPrimary),
),
// Dark - Card Theme
cardTheme: CardTheme(
elevation: 2,
color: darkColorScheme.surfaceContainer,
shadowColor: black.withAlpha(25),
),
// Dark - Floating Action Button Theme
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: darkColorScheme.secondary,
foregroundColor: darkColorScheme.onSecondary,
elevation: 4,
),
// Dark - Chip Theme
chipTheme: ChipThemeData(
backgroundColor: blueDark.withAlpha(25),
labelStyle: labelLarge.copyWith(color: blueDark),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
// Dark - Checkbox Theme
checkboxTheme: CheckboxThemeData(
materialTapTargetSize: MaterialTapTargetSize.padded,
splashRadius: 24, // for better touch feedback
visualDensity: VisualDensity.comfortable,
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return greenDark;
}
return black.withAlpha(40);
}),
// Smooth size animation on click
side: BorderSide(
width: 1,
color: WidgetStateColor.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return greenDark;
}
return black.withAlpha(40);
}),
),
),
// Dark - Dialog Theme
dialogTheme: DialogTheme(
elevation: 4,
backgroundColor: darkColorScheme.surface,
titleTextStyle: headlineSmall,
contentTextStyle: bodyMedium,
),
// Dark - Button Themes
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
textStyle: buttonText,
foregroundColor: blueDark,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
textStyle: buttonText,
backgroundColor: blueDark,
foregroundColor: black, // Better contrast
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
textStyle: buttonText,
foregroundColor: blueDark,
),
),
// Dark - Input Theme
inputDecorationTheme: InputDecorationTheme(
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
labelStyle: labelMedium,
hintStyle: bodyMedium.copyWith(color: white.withAlpha(128)),
errorStyle: error,
),
// Dark - Date picker theme
datePickerTheme: DatePickerThemeData(
backgroundColor: darkColorScheme.surface,
headerBackgroundColor: darkColorScheme.primary,
headerForegroundColor: darkColorScheme.onPrimary,
headerHeadlineStyle:
headlineSmall.copyWith(color: darkColorScheme.onPrimary),
headerHelpStyle: labelMedium.copyWith(color: darkColorScheme.onPrimary),
dayBackgroundColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return darkColorScheme.primary;
}
return Colors.transparent;
}),
dayForegroundColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return darkColorScheme.onPrimary;
} else if (states.contains(WidgetState.disabled)) {
return lightgreyDark;
}
return darkColorScheme.onSurface;
}),
dayStyle: bodyMedium,
todayBackgroundColor:
WidgetStateProperty.all(darkColorScheme.primary.withAlpha(25)),
todayForegroundColor: WidgetStateProperty.all(darkColorScheme.primary),
todayBorder: const BorderSide(color: white, width: 1.5),
yearBackgroundColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return darkColorScheme.primary;
}
return Colors.transparent;
}),
yearForegroundColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return darkColorScheme.onPrimary;
}
return darkColorScheme.onSurface;
}),
yearStyle: bodyMedium,
),
// Dark - Bottom Sheet Theme
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: white,
modalBackgroundColor: darkgrey,
modalBarrierColor: black.withAlpha(128),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
surfaceTintColor: darkColorScheme.primary,
),
// Dark - Navigation Bar Theme
navigationBarTheme: NavigationBarThemeData(
backgroundColor: darkgrey.withAlpha(220),
height: 56,
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(
color: blueDark,
size: 28,
);
}
return const IconThemeData(
color: white,
size: 28,
);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return labelSmall.copyWith(color: blueDark);
}
return labelSmall;
}),
indicatorColor: Colors.transparent,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
),
// Dark - Snackbar Theme
snackBarTheme: SnackBarThemeData(
backgroundColor: darkblueDark,
contentTextStyle: bodyMedium.copyWith(color: white),
actionTextColor: blueDark,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
// Dark - TabBar Theme
tabBarTheme: TabBarTheme(
labelStyle: labelLarge,
unselectedLabelStyle: labelLarge.copyWith(
color: white.withAlpha(200),
),
indicatorColor: blueDark,
dividerColor: Colors.transparent,
),
// Dark - Tooltip Theme
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: darkblueDark.withAlpha(230),
borderRadius: BorderRadius.circular(4),
),
textStyle: bodySmall.copyWith(color: white),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
);
//----------------------------------------------------------------------------
// LIGHT THEME
//----------------------------------------------------------------------------
/// Light theme configuration
static final ThemeData lightTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: lightColorScheme,
// Light - Text Theme
textTheme: TextTheme(
displayLarge: displayLarge.copyWith(color: black),
displayMedium: displayMedium.copyWith(color: black),
displaySmall: displaySmall.copyWith(color: black),
headlineLarge: headlineLarge.copyWith(color: black),
headlineMedium: headlineMedium.copyWith(color: black),
headlineSmall: headlineSmall.copyWith(color: black),
titleLarge: titleLarge.copyWith(color: black),
titleMedium: titleMedium.copyWith(color: black),
titleSmall: titleSmall.copyWith(color: black),
bodyLarge: bodyLarge.copyWith(color: black),
bodyMedium: bodyMedium.copyWith(color: black),
bodySmall: bodySmall.copyWith(color: black),
labelLarge: labelLarge.copyWith(color: black),
labelMedium: labelMedium.copyWith(color: black),
labelSmall: labelSmall.copyWith(color: black),
),
// Light - AppBar Theme
appBarTheme: AppBarTheme(
toolbarHeight: 36,
backgroundColor: lightColorScheme.primary,
foregroundColor: lightColorScheme.onPrimary,
elevation: 0,
centerTitle: true,
titleTextStyle: headlineSmall.copyWith(color: white),
),
// Light - Card Theme
cardTheme: CardTheme(
elevation: 2,
color: lightColorScheme.surfaceContainer,
shadowColor: black.withAlpha(25),
),
// Light - Floating Action Button Theme
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: lightColorScheme.secondary,
foregroundColor: lightColorScheme.onSecondary,
elevation: 4,
),
// Light - Chip Theme
chipTheme: ChipThemeData(
backgroundColor: blueLight.withAlpha(25),
labelStyle: labelLarge.copyWith(color: blueLight),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
// Light - Checkbox Theme
checkboxTheme: CheckboxThemeData(
materialTapTargetSize: MaterialTapTargetSize.padded,
splashRadius: 24,
visualDensity: VisualDensity.comfortable,
fillColor: WidgetStateProperty.resolveWith<Color>((states) {
if (states.contains(WidgetState.selected)) {
return greenLight;
}
return black.withAlpha(40);
}),
side: BorderSide(
width: 1,
color: WidgetStateColor.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return greenLight;
}
return black.withAlpha(40);
}),
),
),
// Light - Dialog Theme
dialogTheme: DialogTheme(
elevation: 4,
backgroundColor: lightColorScheme.surface,
titleTextStyle: headlineSmall.copyWith(color: black),
contentTextStyle: bodyMedium.copyWith(color: black),
),
// Light - Button Themes
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
textStyle: buttonText.copyWith(color: black),
foregroundColor: darkblueLight,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
textStyle: buttonText.copyWith(color: white),
backgroundColor: darkblueLight,
foregroundColor: white,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
textStyle: buttonText.copyWith(color: black),
foregroundColor: darkblueLight,
),
),
// Light - Input Theme
inputDecorationTheme: InputDecorationTheme(
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
labelStyle: labelMedium.copyWith(color: black),
hintStyle: bodyMedium.copyWith(color: black.withAlpha(128)),
errorStyle: error.copyWith(color: pinkLight.withRed(240)),
),
// Light - Bottom Sheet Theme
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: white,
modalBackgroundColor: white,
modalBarrierColor: black.withAlpha(128),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
surfaceTintColor: lightColorScheme.primary,
),
// Light - Navigation Bar Theme
navigationBarTheme: NavigationBarThemeData(
backgroundColor: greyLight.withAlpha(200),
height: 56,
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return IconThemeData(
color: darkblueLight,
size: 28,
);
}
return const IconThemeData(
color: black,
size: 28,
);
}),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return labelSmall.copyWith(color: darkblueLight);
}
return labelSmall;
}),
indicatorColor: Colors.transparent,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
),
// Light - Snackbar Theme
snackBarTheme: SnackBarThemeData(
backgroundColor: darkblueLight,
contentTextStyle: bodyMedium.copyWith(color: white),
actionTextColor: white,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
// Light - TabBar Theme
tabBarTheme: TabBarTheme(
labelStyle: labelLarge.copyWith(color: darkblueLight),
unselectedLabelStyle: labelLarge.copyWith(
color: black.withAlpha(179),
),
indicatorColor: darkblueLight,
dividerColor: Colors.transparent,
),
// Light - Tooltip Theme
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: darkblueLight.withAlpha(230),
borderRadius: BorderRadius.circular(4),
),
textStyle: bodySmall.copyWith(color: white),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
);
//----------------------------------------------------------------------------
// THEME HELPER METHODS
//----------------------------------------------------------------------------
/// Toggle between light and dark theme mode
static ThemeMode toggleThemeMode(ThemeMode current) {
ThemeMode newMode =
current == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
AppColors.setThemeMode(newMode);
return newMode;
}
}

View file

@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// theme_provider.dart
// Provider and utilities for theme management
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
/// Provider for the application theme mode (light/dark)
final themeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.dark);
/// Utility functions for theme management
class ThemeUtils {
/// Toggle between light and dark mode
static void toggleTheme(WidgetRef ref) {
final currentTheme = ref.read(themeProvider);
final newTheme =
currentTheme == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
// Update the theme provider
ref.read(themeProvider.notifier).state = newTheme;
// Update the static AppColors._themMode var
AppColors.setThemeMode(newTheme);
}
/// Set a specific theme
static void setTheme(WidgetRef ref, ThemeMode mode) {
// Update the theme provider
ref.read(themeProvider.notifier).state = mode;
// Update the static AppColors._themMode var
AppColors.setThemeMode(mode);
}
}

View file

@ -0,0 +1,143 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// autocomplete_text_field.dart
// A reusable autocomplete text field widget
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
/// A text field with autocomplete functionality for medical providers
class AutocompleteTextField extends StatefulWidget {
final TextEditingController controller;
final String labelText;
final String hintText;
final List<String> options;
final ValueChanged<String>? onSelected;
final bool isLoading;
const AutocompleteTextField({
super.key,
required this.controller,
required this.labelText,
required this.options,
this.hintText = '',
this.onSelected,
this.isLoading = false,
});
@override
State<AutocompleteTextField> createState() => _AutocompleteTextFieldState();
}
class _AutocompleteTextFieldState extends State<AutocompleteTextField> {
// Track whether the field is focused for showing loading state
bool _isFocused = false;
@override
Widget build(BuildContext context) {
return Focus(
onFocusChange: (hasFocus) {
setState(() {
_isFocused = hasFocus;
});
},
child: Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<String>.empty();
}
final query = textEditingValue.text.toLowerCase();
return widget.options
.where((option) => option.toLowerCase().contains(query));
},
displayStringForOption: (option) => option,
fieldViewBuilder: (
BuildContext context,
TextEditingController fieldController,
FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted,
) {
// Sync the internal controller with our external one
fieldController.text = widget.controller.text;
fieldController.selection = widget.controller.selection;
// Listen to changes in the field and update our controller
fieldController.addListener(() {
widget.controller.text = fieldController.text;
widget.controller.selection = fieldController.selection;
});
return TextFormField(
controller: fieldController,
focusNode: fieldFocusNode,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: widget.labelText,
hintText: widget.hintText,
// Show loading indicator when options are being loaded and field is focused
suffixIcon: (_isFocused && widget.isLoading)
? SizedBox(
height: 20,
width: 20,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
)
: null,
),
);
},
onSelected: (String selection) {
widget.controller.text = selection;
if (widget.onSelected != null) {
widget.onSelected!(selection);
}
},
optionsViewBuilder: (
BuildContext context,
AutocompleteOnSelected<String> onSelected,
Iterable<String> options,
) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4.0,
color: AppColors.surface,
child: ConstrainedBox(
constraints:
const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: const EdgeInsets.all(8.0),
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final option = options.elementAt(index);
return InkWell(
onTap: () {
onSelected(option);
},
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
option,
style: AppTextStyles.bodyMedium,
),
),
);
},
),
),
),
);
},
),
);
}
}

View file

@ -0,0 +1,166 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
// dialog_service.dart
import 'package:intl/intl.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
class DialogService {
/// Shows a dialog asking user if they want to save unsaved changes
/// Returns true if user chooses to save, false if they choose to discard
static Future<void> showSaveChangesDialog({
required BuildContext context,
required WidgetRef ref,
required Future<void> Function() onSave,
VoidCallback? onDiscard,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Unsaved Changes'),
content: const Text(
'You have unsaved changes. Would you like to save them?'),
actions: [
TextButton(
onPressed: () => NavigationService.goBackWithResult(context, false),
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
),
child: const Text('Discard'),
),
TextButton(
onPressed: () => NavigationService.goBackWithResult(context, true),
child: Text(
'Save',
style: AppTextStyles.titleMedium,
),
),
],
),
);
if (!context.mounted) return;
if (result == true) {
await onSave();
} else if (context.mounted) {
if (onDiscard != null) {
onDiscard();
} else {
NavigationService.goBack(context);
}
}
}
/// Shows a delete confirmation dialog
/// Returns true if user confirms deletion, false otherwise
static Future<void> showDeleteDialog({
required BuildContext context,
required WidgetRef ref,
required String itemName,
required Future<void> Function() onDelete,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Item'),
content: Text('Are you sure you want to delete $itemName?'),
actions: [
TextButton(
onPressed: () => NavigationService.goBackWithResult(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => NavigationService.goBackWithResult(context, true),
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
),
child: const Text('Delete'),
),
],
),
);
if (result == true && context.mounted) {
await onDelete();
}
}
/// Shows a dialog with bloodwork details and options to close or edit
static Future<void> showBloodworkDetailDialog({
required BuildContext context,
required String hormoneType,
required DateTime date,
required double value,
required String unit,
required dynamic record,
}) async {
await showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(hormoneType),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Date: ${DateFormat('MM/dd/yyyy').format(date)}'),
Text('Value: ${value.toStringAsFixed(1)} $unit'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(
'Close',
style: AppTextStyles.labelMedium.copyWith(
color: AppColors.primary,
backgroundColor: AppColors.primary.withAlpha(20),
),
),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
NavigationService.goToBloodworkAddEdit(
context,
bloodwork: record,
);
},
child: Text(
'Edit',
style: AppTextStyles.labelMedium.copyWith(
color: AppColors.primary,
backgroundColor: AppColors.primary.withAlpha(20),
),
),
),
],
),
);
}
/// Generic back button handler that checks for unsaved changes
static void handleBackWithUnsavedChanges({
required BuildContext context,
required WidgetRef ref,
required bool Function() checkForUnsavedChanges,
required Future<void> Function() saveFn,
}) async {
// Check if there are unsaved changes
bool hasUnsavedChanges = checkForUnsavedChanges();
if (hasUnsavedChanges) {
await showSaveChangesDialog(
context: context,
ref: ref,
onSave: saveFn,
);
} else {
// No unsaved changes, just go back
NavigationService.goBack(context);
}
}
}

View file

@ -0,0 +1,837 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
class DropdownWidgets {
//----------------------------------------------------------------------------
// DROPDOWN HELPER FUNCTIONS
//----------------------------------------------------------------------------
/// A wrapper for dropdown items with optional description text
static DropdownMenuItem<T> dropdownItemWithDescription<T>({
required T value,
required Widget child,
String? description,
}) {
if (description == null) {
// Regular dropdown item without description
return DropdownMenuItem<T>(
value: value,
child: child,
);
}
// Enhanced dropdown item with description
return DropdownMenuItem<T>(
value: value,
// Use a custom tag to mark this as having a description
// This will be detected in our custom dropdown
child: _DropdownItemWithDescription(
title: child,
description: description,
),
);
}
/// A custom dropdown that shows a semi-opaque background when showing the options
/// Supports items with optional descriptions
static Widget customDropdownButtonFormField<T>({
required T value,
required List<DropdownMenuItem<T>> items,
required ValueChanged<T?>? onChanged,
InputDecoration? decoration,
Widget? hint,
FormFieldValidator<T>? validator,
}) {
return Builder(
builder: (context) {
// Find the selected item and extract its display widget
Widget? displayWidget;
// Find the selected item's display widget
for (final item in items) {
if (item.value == value) {
// Check if this is an item with description
if (item.child is _DropdownItemWithDescription) {
final enhancedItem = item.child as _DropdownItemWithDescription;
displayWidget = enhancedItem.title;
} else {
displayWidget = item.child;
}
break;
}
}
return FormField<T>(
initialValue: value,
validator: validator,
builder: (state) {
return InkWell(
onTap: () async {
// Show a modal with our desired semi-opaque background
final result = await showDialog<T>(
context: context,
barrierDismissible: true,
barrierColor: Colors.transparent,
builder: (context) {
// Add a search controller if there are more than 8 items
final bool showSearch = items.length > 8;
final TextEditingController searchController =
TextEditingController();
final ValueNotifier<List<DropdownMenuItem<T>>>
filteredItems =
ValueNotifier<List<DropdownMenuItem<T>>>(items);
// Helper function to try to extract searchable text from a widget
String getSearchableText(Widget? widget) {
if (widget == null) return '';
if (widget is Text) {
return widget.data ?? '';
}
if (widget is RichText) {
// Simplified approach for RichText
return widget.text.toPlainText();
}
// For other types, we can't extract text reliably
return '';
}
// Function to filter items based on search text
void filterItems(String query) {
if (query.isEmpty) {
filteredItems.value = items;
return;
}
final String searchQuery = query.toLowerCase();
filteredItems.value = items.where((item) {
if (item.child is _DropdownItemWithDescription) {
final enhancedItem =
item.child as _DropdownItemWithDescription;
// Extract text from title and use description
final titleText =
getSearchableText(enhancedItem.title)
.toLowerCase();
final descText =
enhancedItem.description.toLowerCase();
return titleText.contains(searchQuery) ||
descText.contains(searchQuery);
} else {
// Try to extract text from the child widget
final itemText =
getSearchableText(item.child).toLowerCase();
return itemText.contains(searchQuery);
}
}).toList();
}
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Container(
color: AppTheme.black.withAlpha(200),
child: Dialog(
// Adjust alignment to top when search is visible
alignment: showSearch
? Alignment.topCenter
: Alignment.center,
insetPadding: EdgeInsets.only(
left: 40,
right: 40,
// Less padding at top when search is visible
top: showSearch ? 60 : 24,
bottom: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 500,
maxHeight:
MediaQuery.of(context).size.height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 16),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
'Select an option',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
TextButton(
onPressed: () {
Navigator.of(context)
.pop(); // Close dialog without selection
},
style: TextButton.styleFrom(
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize:
MaterialTapTargetSize
.shrinkWrap,
foregroundColor: AppColors.error,
backgroundColor: AppColors.error
.withAlpha(20)),
child: const Text('Cancel'),
),
]),
),
// Divider after header
Divider(
height: 1,
color: AppColors.primary.withAlpha(60)),
// Search bar has been moved outside the scrollable area
// List of items - wrapped in Expanded to avoid intrinsic sizing issues
// Search bar remains static while list scrolls
if (showSearch)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search),
contentPadding:
const EdgeInsets.symmetric(
vertical: 8, horizontal: 12),
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(8),
),
),
onChanged: filterItems,
),
),
Flexible(
child: ValueListenableBuilder<
List<DropdownMenuItem<T>>>(
valueListenable: filteredItems,
builder: (context, currentItems, _) {
if (currentItems.isEmpty &&
searchController.text.isNotEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
"No matching options found",
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.grey,
fontStyle: FontStyle.italic,
),
),
),
);
}
return ListView.separated(
shrinkWrap: true,
separatorBuilder: (context, index) =>
Divider(
height: 1,
color: AppTheme.grey.withAlpha(60),
indent: 16,
endIndent: 16,
),
itemCount: currentItems.length,
itemBuilder: (context, index) {
final item = currentItems[index];
bool isSelected = item.value == value;
// Check if this item has a description
if (item.child
is _DropdownItemWithDescription) {
// Extract the title and description
final enhancedItem = item.child
as _DropdownItemWithDescription;
// Return a list tile with both title and subtitle
return ListTile(
contentPadding:
const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8),
title: enhancedItem.title,
subtitle: Text(
enhancedItem.description,
style: TextStyle(
fontSize: 12,
color: isSelected
? AppColors.primary
: AppTheme.white
.withAlpha(120),
),
),
selected: isSelected,
tileColor: isSelected
? AppColors.primary
.withAlpha(20)
: null,
onTap: () {
Navigator.of(context)
.pop(item.value);
},
);
} else {
// Regular item without description
return ListTile(
contentPadding:
const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8),
title: item.child,
selected: isSelected,
tileColor: isSelected
? AppColors.primary
.withAlpha(20)
: null,
onTap: () {
Navigator.of(context)
.pop(item.value);
},
);
}
},
);
},
),
),
],
),
),
),
),
);
},
);
// If user selected a value, update the form field
if (result != null) {
state.didChange(result);
if (onChanged != null) {
onChanged(result);
}
}
},
child: InputDecorator(
decoration: (decoration ?? const InputDecoration()).copyWith(
errorText: state.hasError ? state.errorText : null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Display selected value (use the actual widget if available)
Expanded(
child: displayWidget ?? (hint ?? const Text('')),
),
// Dropdown icon
const Icon(Icons.arrow_drop_down),
],
),
),
);
},
);
},
);
}
/// A multi-select dropdown widget that allows selecting multiple options
/// Currently only used for day of week selector
static Widget multiSelectDropdown<T>({
required Set<T> selectedValues,
required List<DropdownMenuItem<T>> items,
required ValueChanged<Set<T>> onChanged,
InputDecoration? decoration,
String? placeholder,
FormFieldValidator<Set<T>>? validator,
bool Function(T)? canDeselect,
}) {
// Helper function to build the display widget
Widget _buildDisplay() {
if (selectedValues.isEmpty) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
placeholder ?? 'Select options...',
style: TextStyle(
color: AppTheme.grey,
fontStyle: FontStyle.italic,
),
),
const Icon(Icons.arrow_drop_down),
],
);
}
// Create a list of display texts for selected items
final List<String> displayTexts = [];
for (final value in selectedValues) {
// Find the corresponding dropdown item to get its display widget
final item = items.firstWhere(
(item) => item.value == value,
orElse: () => DropdownMenuItem<T>(
value: value,
child: Text(value.toString()),
),
);
// Extract the display text
String displayText = '';
if (item.child is _DropdownItemWithDescription) {
final enhancedItem = item.child as _DropdownItemWithDescription;
if (enhancedItem.title is Text) {
displayText = (enhancedItem.title as Text).data ?? value.toString();
}
} else if (item.child is Text) {
displayText = (item.child as Text).data ?? value.toString();
} else {
displayText = value.toString();
}
displayTexts.add(displayText);
}
// Join them with commas for display
final String displayValue = displayTexts.join(', ');
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
displayValue,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down),
],
);
}
return FormField<Set<T>>(
initialValue: selectedValues,
validator: validator,
builder: (state) {
return InkWell(
onTap: () async {
// Show a modal with our desired semi-opaque background
final result = await showDialog<Set<T>>(
context: state.context,
barrierDismissible: true,
barrierColor: Colors.transparent,
builder: (context) {
// Create a working copy of the selected values
final ValueNotifier<Set<T>> workingSet =
ValueNotifier<Set<T>>(Set<T>.from(selectedValues));
// Add a search controller if there are more than 8 items
final bool showSearch = items.length > 8;
final TextEditingController searchController =
TextEditingController();
final ValueNotifier<List<DropdownMenuItem<T>>> filteredItems =
ValueNotifier<List<DropdownMenuItem<T>>>(items);
// Helper function to try to extract searchable text from a widget
String getSearchableText(Widget? widget) {
if (widget == null) return '';
if (widget is Text) {
return widget.data ?? '';
}
if (widget is RichText) {
// Simplified approach for RichText
return widget.text.toPlainText();
}
// For other types, we can't extract text reliably
return '';
}
// Function to filter items based on search text
void filterItems(String query) {
if (query.isEmpty) {
filteredItems.value = items;
return;
}
final String searchQuery = query.toLowerCase();
filteredItems.value = items.where((item) {
if (item.child is _DropdownItemWithDescription) {
final enhancedItem =
item.child as _DropdownItemWithDescription;
// Extract text from title and use description
final titleText =
getSearchableText(enhancedItem.title).toLowerCase();
final descText = enhancedItem.description.toLowerCase();
return titleText.contains(searchQuery) ||
descText.contains(searchQuery);
} else {
// Try to extract text from the child widget
final itemText =
getSearchableText(item.child).toLowerCase();
return itemText.contains(searchQuery);
}
}).toList();
}
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Container(
color: AppTheme.black.withAlpha(200),
child: Dialog(
// Adjust alignment to top when search is visible
alignment:
showSearch ? Alignment.topCenter : Alignment.center,
insetPadding: EdgeInsets.only(
left: 40,
right: 40,
// Less padding at top when search is visible
top: showSearch ? 60 : 24,
bottom: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 500,
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header with Save/Cancel buttons
Container(
padding: const EdgeInsets.symmetric(
vertical: 12, horizontal: 16),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
'Select options',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
),
Row(
children: [
TextButton(
onPressed: () {
Navigator.of(context)
.pop(); // Close dialog without selection
},
style: TextButton.styleFrom(
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize:
MaterialTapTargetSize
.shrinkWrap,
foregroundColor: AppColors.error,
backgroundColor: AppColors.error
.withAlpha(20)),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
TextButton(
onPressed: () {
Navigator.of(context)
.pop(workingSet.value);
},
style: TextButton.styleFrom(
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4),
minimumSize: const Size(0, 0),
tapTargetSize:
MaterialTapTargetSize
.shrinkWrap,
foregroundColor:
AppColors.primary,
backgroundColor: AppColors.primary
.withAlpha(20)),
child: const Text('Apply'),
),
],
),
]),
),
// Divider after header
Divider(
height: 1,
color: AppColors.primary.withAlpha(60)),
// Search bar if needed
if (showSearch)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search),
contentPadding: const EdgeInsets.symmetric(
vertical: 8, horizontal: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: filterItems,
),
),
// List of items - wrapped in Expanded to avoid intrinsic sizing issues
Flexible(
child: ValueListenableBuilder<
List<DropdownMenuItem<T>>>(
valueListenable: filteredItems,
builder: (context, currentItems, _) {
if (currentItems.isEmpty &&
searchController.text.isNotEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
"No matching options found",
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.grey,
fontStyle: FontStyle.italic,
),
),
),
);
}
return ValueListenableBuilder<Set<T>>(
valueListenable: workingSet,
builder: (context, currentSelection, _) {
return ListView.separated(
shrinkWrap: true,
separatorBuilder: (context, index) =>
Divider(
height: 1,
color: AppTheme.grey.withAlpha(60),
indent: 16,
endIndent: 16,
),
itemCount: currentItems.length,
itemBuilder: (context, index) {
final item = currentItems[index];
final bool isSelected =
currentSelection
.contains(item.value);
// Check if this item can be deselected
bool canToggle = true;
if (isSelected &&
canDeselect != null) {
canToggle =
canDeselect(item.value as T);
}
// Function to toggle selection
void toggleSelection() {
if (isSelected) {
if (canToggle) {
workingSet.value = Set<T>.from(
currentSelection)
..remove(item.value as T);
}
} else {
workingSet.value =
Set<T>.from(currentSelection)
..add(item.value as T);
}
}
// Check if this item has a description
if (item.child
is _DropdownItemWithDescription) {
// Extract the title and description
final enhancedItem = item.child
as _DropdownItemWithDescription;
// Return a list tile with both title and subtitle
return ListTile(
contentPadding:
const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8),
title: enhancedItem.title,
subtitle: Text(
enhancedItem.description,
style: TextStyle(
fontSize: 12,
color: isSelected
? AppColors.primary
: AppTheme.white
.withAlpha(120),
),
),
selected: isSelected,
tileColor: isSelected
? AppColors.primary
.withAlpha(20)
: null,
onTap: canToggle
? toggleSelection
: null,
// Add a checkbox for multi-select
leading: Checkbox(
value: isSelected,
onChanged: canToggle
? (_) => toggleSelection()
: null,
activeColor: AppColors.primary,
),
);
} else {
// Regular item without description
return ListTile(
contentPadding:
const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8),
title: item.child,
selected: isSelected,
tileColor: isSelected
? AppColors.primary
.withAlpha(20)
: null,
onTap: canToggle
? toggleSelection
: null,
// Add a checkbox for multi-select
leading: Checkbox(
value: isSelected,
onChanged: canToggle
? (_) => toggleSelection()
: null,
activeColor: AppColors.primary,
),
);
}
},
);
},
);
},
),
),
],
),
),
),
),
);
},
);
// If user applied selections, update the form field
if (result != null) {
state.didChange(result);
onChanged(result);
}
},
child: InputDecorator(
decoration: (decoration ?? const InputDecoration()).copyWith(
errorText: state.hasError ? state.errorText : null,
),
child: _buildDisplay(),
),
);
},
);
}
}
//----------------------------------------------------------------------------
// DROPDOWN COMPONENT CLASSES
//----------------------------------------------------------------------------
/// A widget that combines a title with a description
/// Used internally for enhanced dropdown items
class _DropdownItemWithDescription extends StatelessWidget {
final Widget title;
final String description;
const _DropdownItemWithDescription({
required this.title,
required this.description,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
Text(
description,
style: const TextStyle(
fontSize: 12,
color: AppTheme.grey,
),
),
],
);
}
}
/// A widget that combines a title with a description
/// Used internally for enhanced dropdown items
class DropdownItemWithDescription extends StatelessWidget {
final Widget title;
final String description;
const DropdownItemWithDescription({
super.key,
required this.title,
required this.description,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title,
Text(
description,
style: const TextStyle(
fontSize: 12,
color: AppTheme.grey,
),
),
],
);
}
}

View file

@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// shared_widgets.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
/// SharedWidgets provides consistent UI components across the app
class SharedWidgets {
//----------------------------------------------------------------------------
// SPACING UTILITIES
//----------------------------------------------------------------------------
/// Creates a vertical spacer with customizable height
static Widget verticalSpace([double height = AppTheme.spacing]) {
return SizedBox(height: height);
}
/// Creates a horizontal spacer with customizable width
static Widget horizontalSpace([double width = AppTheme.spacing]) {
return SizedBox(width: width);
}
//----------------------------------------------------------------------------
// TEXT AND INFORMATION DISPLAY
//----------------------------------------------------------------------------
/// Builds a section header with standardized styling
static Widget buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title.toUpperCase(),
style: AppTextStyles.labelMedium,
),
);
}
/// Helper to build consistent info rows
static Widget buildInfoRow(String label, String value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: AppTextStyles.bodyMedium,
),
Text(
value,
style: AppTextStyles.bodyMedium,
),
],
);
}
//----------------------------------------------------------------------------
// CARD COMPONENTS
//----------------------------------------------------------------------------
/// Creates a standardized card with primary-colored title bar and content
static Widget basicCard({
required BuildContext context,
required String title,
required List<Widget> children,
Color? color,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 16.0),
clipBehavior: Clip.antiAlias, // This ensures the bar fills the card width
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title bar with primary color background
Container(
width: double.infinity,
color: color ?? AppColors.primary.withAlpha(160),
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Text(
title,
style: AppTextStyles.titleMedium.copyWith(
color: AppColors.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
// Card content
Padding(
padding: AppTheme.standardCardPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
],
),
);
}
}

View file

@ -0,0 +1,161 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
/// A class to manage timeframe selection across the app
class TimeframeSelector {
/// Standard timeframe options available throughout the app
static const List<String> standardTimeframes = [
'3 Months',
'6 Months',
'1 Year',
'5 Years',
'All Time'
];
/// Returns a filter icon button for AppBar actions
static Widget dropdownButton({
required BuildContext context,
required String selectedTimeframe,
required Function(String) onTimeframeSelected,
List<String>? timeframes,
}) {
final options = timeframes ?? standardTimeframes;
return PopupMenuButton<String>(
icon: Icon(AppIcons.getIcon('filter_list')),
tooltip: 'Filter time range',
onSelected: onTimeframeSelected,
itemBuilder: (BuildContext context) {
return options.map((String range) {
return PopupMenuItem<String>(
value: range,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(range),
if (selectedTimeframe == range)
Icon(AppIcons.getIcon('checkmark'), size: 18)
],
),
);
}).toList();
},
);
}
/// Returns a card with timeframe chip selectors
static Widget chipSelector({
required BuildContext context,
required String selectedTimeframe,
required Function(String) onTimeframeSelected,
List<String>? timeframes,
String title = 'Time Range',
}) {
final options = timeframes ?? standardTimeframes;
return SharedWidgets.basicCard(
context: context,
title: title,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: options.map((timeFrame) {
final isSelected = selectedTimeframe == timeFrame;
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
label: Text(timeFrame),
selected: isSelected,
onSelected: (selected) {
if (selected) {
onTimeframeSelected(timeFrame);
}
},
backgroundColor: AppColors.surface,
selectedColor: AppColors.primary.withAlpha(100),
labelStyle: AppTextStyles.labelMedium.copyWith(
color: isSelected ? AppColors.primary : null,
fontWeight: isSelected ? FontWeight.bold : null,
),
),
);
}).toList(),
),
),
],
);
}
/// Utility function to filter a list of items by timeframe
/// T must have a 'date' property of type DateTime
static List<T> filterByTimeframe<T>({
required List<T> items,
required String timeframe,
required DateTime Function(T) getDate,
}) {
if (timeframe == 'All Time') {
return items;
}
final now = DateTime.now();
DateTime cutoffDate;
switch (timeframe) {
case '3 Months':
cutoffDate = DateTime(now.year, now.month - 3, now.day);
break;
case '6 Months':
cutoffDate = DateTime(now.year, now.month - 6, now.day);
break;
case '1 Year':
cutoffDate = DateTime(now.year - 1, now.month, now.day);
break;
case '5 Years':
cutoffDate = DateTime(now.year - 5, now.month, now.day);
break;
default:
return items;
}
return items.where((item) => getDate(item).isAfter(cutoffDate)).toList();
}
/// Returns a DateTimeRange for the specified timeframe
static DateTimeRange? getDateRangeForTimeframe(String timeframe) {
final now = DateTime.now();
final today =
DateTime(now.year, now.month, now.day); // Normalize to start of day
DateTime startDate;
switch (timeframe) {
case '3 Months':
startDate = DateTime(now.year, now.month - 3, now.day);
break;
case '6 Months':
startDate = DateTime(now.year, now.month - 6, now.day);
break;
case '1 Year':
startDate = DateTime(now.year - 1, now.month, now.day);
break;
case '5 Years':
startDate = DateTime(now.year - 5, now.month, now.day);
break;
case 'All Time':
// For "All Time", we'll use a date far in the past
startDate = DateTime(2000, 1, 1);
break;
default:
// Return null for unrecognized timeframes
return null;
}
return DateTimeRange(start: startDate, end: today);
}
}

View file

@ -0,0 +1,328 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// date_time_formatter.dart
// A utility class for formatting dates, times, and related display elements
//
import 'package:flutter/material.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/core/constants/date_constants.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
class DateTimeFormatter {
//----------------------------------------------------------------------------
// DATE FORMATTING FUNCTIONS
//----------------------------------------------------------------------------
static String getDaysAgo(DateTime? date) {
if (date == null) return 'N/A';
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today';
} else if (difference.inDays == 1) {
return 'Yesterday';
} else {
return '${difference.inDays} days ago';
}
}
/// Formats a date as "Month Day, Year" with special handling for today,
/// tomorrow and yesterday.
///
/// Examples:
/// - Today - Jan 1, 2025
/// - Tomorrow - Jan 2, 2025
/// - Jan 15, 2025
static String formatDateMMMDDYYYY(DateTime date) {
final now = DateTime.now();
/// Helper function to create the base date string
String formatDateStringMMMDDYYYY() {
return '${DateConstants.months[date.month - 1]} ${date.day}, ${date.year}';
}
if (date.year == now.year &&
date.month == now.month &&
date.day == now.day) {
return 'Today - ${formatDateStringMMMDDYYYY()}';
} else if (date.year == now.year &&
date.month == now.month &&
date.day == now.day + 1) {
return 'Tomorrow - ${formatDateStringMMMDDYYYY()}';
} else if (date.year == now.year &&
date.month == now.month &&
date.day == now.day - 1) {
return 'Yesterday - ${formatDateStringMMMDDYYYY()}';
}
return formatDateStringMMMDDYYYY();
}
/// Formats a date in MM/DD/YYYY format
///
/// Example: 2/26/2025
static String formatDateDDMMYY(DateTime date) {
return '${date.month}/${date.day}/${date.year}';
}
/// Formats a set of days of the week into a human-readable string
///
/// If all 7 days are included, returns "Everyday"
/// Otherwise, returns a comma-separated list of day names
static String formatDaysOfWeek(Set<String> days) {
if (days.length == 7 && DateConstants.orderedDays.every(days.contains)) {
return 'Everyday';
}
final sortedDays = days.toList()
..sort((a, b) => DateConstants.orderedDays
.indexOf(a)
.compareTo(DateConstants.orderedDays.indexOf(b)));
return sortedDays.map((day) => DateConstants.dayNames[day]).join(', ');
}
//----------------------------------------------------------------------------
// MEDICATION-SPECIFIC FORMATTING
//----------------------------------------------------------------------------
/// Returns a string describing the frequency of a medication
/// Accounts for medication type and frequency and weekly vs. biweekly
static String formatMedicationFrequencyDosage(Medication medication) {
// Handle as-needed medications first
if (medication.asNeeded) {
switch (medication.medicationType) {
case MedicationType.oral:
return 'Take ${medication.dosage} as needed';
case MedicationType.injection:
return 'Inject ${medication.dosage} as needed';
case MedicationType.topical:
return 'Apply ${medication.dosage} as needed';
case MedicationType.patch:
return 'Change ${medication.name} patch, ${medication.dosage}, as needed';
}
}
// Format frequency text (once/twice/three times)
String frequencyText = medication.frequency == 1
? 'once'
: medication.frequency == 2
? 'twice'
: '${medication.frequency} times';
// Handle different medication types
switch (medication.medicationType) {
case MedicationType.oral:
if (medication.daysOfWeek.isEmpty ||
medication.daysOfWeek.length == 7) {
return 'Take ${medication.dosage} $frequencyText daily, everyday';
} else {
return 'Take ${medication.dosage} $frequencyText daily, on ${medication.daysOfWeek.join(', ')}';
}
case MedicationType.injection:
// Add this condition for biweekly injections
if (medication.injectionDetails?.frequency ==
InjectionFrequency.biweekly) {
return 'Inject ${medication.dosage} every 2 weeks on ${medication.daysOfWeek.join(', ')}';
}
return 'Inject ${medication.dosage} every week on ${medication.daysOfWeek.join(', ')}';
case MedicationType.topical:
if (medication.daysOfWeek.isEmpty ||
medication.daysOfWeek.length == 7) {
return 'Apply ${medication.dosage} $frequencyText daily, everyday';
} else {
return 'Apply ${medication.dosage} $frequencyText daily, on ${medication.daysOfWeek.join(', ')}';
}
case MedicationType.patch:
if (medication.daysOfWeek.isEmpty ||
medication.daysOfWeek.length == 7) {
return 'Change ${medication.name} patch, ${medication.dosage}, $frequencyText daily, everyday';
} else {
return 'Change ${medication.name} patch, ${medication.dosage}, $frequencyText on ${medication.daysOfWeek.join(', ')}';
}
}
}
/// Returns a string describing the frequency of a medication
static String formatMedicationFrequency(Medication medication) {
// Handle as-needed medications
if (medication.asNeeded) {
return 'As Needed';
}
if (medication.medicationType == MedicationType.injection) {
if (medication.injectionDetails?.frequency ==
InjectionFrequency.biweekly) {
return 'every 2 weeks';
}
return 'every week';
} else if (medication.medicationType == MedicationType.patch) {
String frequencyText = medication.frequency == 1
? 'once'
: medication.frequency == 2
? 'twice'
: '${medication.frequency} times';
return 'change $frequencyText a day';
}
String frequencyText = medication.frequency == 1
? 'once'
: medication.frequency == 2
? 'twice'
: '${medication.frequency} times';
return '$frequencyText a day';
}
//----------------------------------------------------------------------------
// TIME PARSING AND FORMATTING
//----------------------------------------------------------------------------
/// Parses a time string in various formats (like "8:30 AM") to a TimeOfDay object
///
/// Handles both 12-hour and 24-hour formats
///
/// @param timeStr The time string to parse
/// @return A TimeOfDay representation of the input string
static TimeOfDay parseTimeString(String timeStr) {
final isPM = timeStr.toLowerCase().contains('pm');
final cleanTime =
timeStr.toLowerCase().replaceAll(RegExp(r'[ap]m'), '').trim();
final parts = cleanTime.split(':');
if (parts.length != 2) return const TimeOfDay(hour: 0, minute: 0);
var hour = int.tryParse(parts[0]) ?? 0;
final minute = int.tryParse(parts[1]) ?? 0;
if (isPM && hour != 12) hour += 12;
if (!isPM && hour == 12) hour = 0;
return TimeOfDay(
hour: hour.clamp(0, 23),
minute: minute.clamp(0, 59),
);
}
/// Formats a TimeOfDay object to a 12-hour AM/PM time string
///
/// @param time The TimeOfDay object to format
/// @return A formatted string like "8:30 AM"
static String formatTimeToAMPM(TimeOfDay time) {
final int hour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod;
final String minute = time.minute.toString().padLeft(2, '0');
final String period = time.period == DayPeriod.am ? 'AM' : 'PM';
return '$hour:$minute $period';
}
/// Compares two time strings for sorting
///
/// @param a First time string (e.g., "8:30 AM")
/// @param b Second time string (e.g., "2:45 PM")
/// @return A negative value if a is earlier than b,
/// positive if a is later than b,
/// zero if they are the same time
static int compareTimeSlots(String a, String b) {
final timeA = parseTimeString(a);
final timeB = parseTimeString(b);
return timeA.hour * 60 + timeA.minute - (timeB.hour * 60 + timeB.minute);
}
//----------------------------------------------------------------------------
// TIME ICON SELECTION
//----------------------------------------------------------------------------
/// Returns an appropriate icon based on the time of day
///
/// Time ranges:
/// - 5:00-8:59 AM: twilight icon (dawn)
/// - 9:00 AM-4:59 PM: sun icon (daytime)
/// - 5:00-8:59 PM: twilight icon (dusk)
/// - 9:00 PM-4:59 AM: moon icon (noon)
///
/// @param timeOfDay A TimeOfDay object
/// @return An IconData object for the corresponding time period
static IconData getTimeIconFromTimeOfDay(TimeOfDay timeOfDay) {
final hour = timeOfDay.hour;
if (hour >= 5 && hour < 9) {
return AppIcons.getFilled('twilight');
} else if (hour >= 9 && hour < 17) {
return AppIcons.getFilled('sun');
} else if (hour >= 17 && hour < 21) {
return AppIcons.getFilled('twilight');
} else {
return AppIcons.getFilled('night');
}
}
/// Gets an icon appropriate for the given time string
///
/// @param timeSlot A string representation of time (e.g., "8:30 AM")
/// @return An IconData object for the corresponding time period
static IconData getTimeIcon(String timeSlot) {
final timeOfDay = parseTimeString(timeSlot);
return getTimeIconFromTimeOfDay(timeOfDay);
}
/// Normalizes a DateTime by removing the time component
///
/// Returns a new DateTime with only year, month, and day preserved
static DateTime normalizeDate(DateTime date) {
return DateTime(date.year, date.month, date.day);
}
/// Checks if two dates are the same day (ignoring time)
///
/// Returns true if both dates have the same year, month, and day
static bool isSameDay(DateTime date1, DateTime date2) {
return date1.year == date2.year &&
date1.month == date2.month &&
date1.day == date2.day;
}
/// Efficiently generates a date range between two dates
///
/// Returns an Iterable of DateTime objects from start to end inclusive
static Iterable<DateTime> dateRange(DateTime start, DateTime end) sync* {
var current = normalizeDate(start);
final endDate = normalizeDate(end);
while (!current.isAfter(endDate)) {
yield current;
current = current.add(const Duration(days: 1));
}
}
/// Creates a cached date range for the visible calendar period
///
/// Returns dates for the current month plus padding weeks, optimizing
/// for calendar view performance
static Iterable<DateTime> visibleCalendarRange(DateTime focusedMonth) {
// Get first day of the month
final firstDay = DateTime(focusedMonth.year, focusedMonth.month, 1);
// Get first day of calendar grid (may be in previous month)
// Calendar usually shows from Sunday before the 1st to complete the grid
final int firstWeekday = firstDay.weekday % 7;
final startDate = firstDay.subtract(Duration(days: firstWeekday));
// Get last day of month
final lastDay = DateTime(focusedMonth.year, focusedMonth.month + 1, 0);
// Get last day of calendar grid (may be in next month)
// Calendar usually shows 6 weeks, so add days to make a complete grid
final int lastWeekday = lastDay.weekday % 7;
final endDate = lastDay.add(Duration(days: 6 - lastWeekday));
return dateRange(startDate, endDate);
}
}

View file

@ -0,0 +1,94 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
class GetIconsColors {
///-------------------------------
/// MEDICATION TYPES
///-------------------------------
/// Get appropriate icon based on medication type
static IconData getMedicationIcon(MedicationType medicationType) {
switch (medicationType) {
case MedicationType.oral:
return AppIcons.getOutlined('medication');
case MedicationType.injection:
return AppIcons.getOutlined('vaccine');
case MedicationType.topical:
return AppIcons.getOutlined('topical');
case MedicationType.patch:
return AppIcons.getOutlined('patch');
}
}
/// Get appropriate color based on medication type
static Color getMedicationColor(MedicationType medicationType) {
switch (medicationType) {
case MedicationType.oral:
return AppColors.oralMedication;
case MedicationType.injection:
return AppColors.injection;
case MedicationType.topical:
return AppColors.topical;
case MedicationType.patch:
return AppColors.patch;
}
}
/// Get appropriate Icon and color based on medication type
static Icon getMedicationIconWithColor(MedicationType medicationType) {
return Icon(getMedicationIcon(medicationType),
color: getMedicationColor(medicationType));
}
static CircleAvatar getMedicationIconCirlce(MedicationType medicationType) {
return CircleAvatar(
backgroundColor: getMedicationColor(medicationType).withAlpha(40),
child: getMedicationIconWithColor(medicationType),
);
}
///-------------------------------
/// APPOINTMENT TYPES
///-------------------------------
/// Get an icon for an appointment type
static IconData getAppointmentIcon(AppointmentType appointmentType) {
switch (appointmentType) {
case AppointmentType.bloodwork:
return AppIcons.getOutlined('bloodwork');
case AppointmentType.appointment:
return AppIcons.getOutlined('medical_services');
case AppointmentType.surgery:
return AppIcons.getOutlined('medical_info');
}
}
static Color getAppointmentColor(AppointmentType appointmentType) {
switch (appointmentType) {
case AppointmentType.bloodwork:
return AppColors.bloodwork;
case AppointmentType.appointment:
return AppColors.doctorAppointment;
case AppointmentType.surgery:
return AppColors.surgery;
}
}
/// Get appropriate Icon and color based on appointment type
static Icon getAppointmentIconWithColor(AppointmentType appointmentType) {
return Icon(getAppointmentIcon(appointmentType),
color: getAppointmentColor(appointmentType));
}
static CircleAvatar getAppointmentIconCirlce(
AppointmentType appointmentType) {
return CircleAvatar(
backgroundColor: getAppointmentColor(appointmentType).withAlpha(40),
child: getAppointmentIconWithColor(appointmentType),
);
}
}

View file

@ -0,0 +1,212 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
class GetLabels {
///-------------------------------
/// MEDICATION TYPES
///-------------------------------
/// Get a text description of the medication type and subtype
static String getMedicationTypeText(Medication medication) {
switch (medication.medicationType) {
case MedicationType.oral:
String subtypeText;
switch (medication.oralSubtype) {
case OralSubtype.tablets:
subtypeText = 'Tablets';
break;
case OralSubtype.capsules:
subtypeText = 'Capsules';
break;
case OralSubtype.drops:
subtypeText = 'Drops';
break;
case null:
subtypeText = 'Oral';
break;
}
return 'Oral${subtypeText.isNotEmpty ? ' - $subtypeText' : ''}';
case MedicationType.injection:
return 'Injection';
case MedicationType.topical:
String subtypeText;
switch (medication.topicalSubtype) {
case TopicalSubtype.gel:
subtypeText = 'Gel';
break;
case TopicalSubtype.cream:
subtypeText = 'Cream';
break;
case TopicalSubtype.spray:
subtypeText = 'Spray';
break;
case null:
subtypeText = 'Topical';
break;
}
return 'Topical${subtypeText.isNotEmpty ? ' - $subtypeText' : ''}';
case MedicationType.patch:
return 'Patch';
}
}
/// Get a text description of the injection subtype
static String getInjectionSubtypeText(InjectionSubtype injectionSubtype) {
switch (injectionSubtype) {
case InjectionSubtype.intravenous:
return 'Intravenous (IV)';
case InjectionSubtype.intramuscular:
return 'Intramuscular (IM)';
case InjectionSubtype.subcutaneous:
return 'Subcutaneous (SC)';
}
}
// Convert body area enum to user-friendly text
static String getBodyAreaText(InjectionBodyArea bodyArea) {
switch (bodyArea) {
case InjectionBodyArea.abdomen:
return 'Abdomen';
case InjectionBodyArea.thigh:
return 'Thigh';
}
}
/// Format dosage text based on medication type
static String formatDosageText(Medication medication) {
switch (medication.medicationType) {
case MedicationType.oral:
return medication.dosage;
case MedicationType.injection:
String subtypeText = '';
switch (medication.injectionDetails?.subtype) {
case InjectionSubtype.intravenous:
subtypeText = 'IV';
break;
case InjectionSubtype.intramuscular:
subtypeText = 'IM';
break;
case InjectionSubtype.subcutaneous:
subtypeText = 'SC';
break;
case null:
subtypeText = '';
break;
}
return '${medication.dosage}${subtypeText.isNotEmpty ? ' ($subtypeText)' : ''}';
case MedicationType.topical:
String subtypeText = '';
switch (medication.topicalSubtype) {
case TopicalSubtype.gel:
subtypeText = 'Gel';
break;
case TopicalSubtype.cream:
subtypeText = 'Cream';
break;
case TopicalSubtype.spray:
subtypeText = 'Spray';
break;
case null:
subtypeText = '';
break;
}
return '${medication.dosage}${subtypeText.isNotEmpty ? ' ($subtypeText)' : ''}';
case MedicationType.patch:
return medication.dosage;
}
}
/// Get a short text description for the medication type badge
static String getTypeBadgeText(Medication medication) {
switch (medication.medicationType) {
case MedicationType.oral:
switch (medication.oralSubtype) {
case OralSubtype.tablets:
return 'Tablets';
case OralSubtype.capsules:
return 'Capsules';
case OralSubtype.drops:
return 'Drops';
case null:
return 'Oral';
}
case MedicationType.injection:
switch (medication.injectionDetails?.subtype) {
case InjectionSubtype.intravenous:
return 'IV';
case InjectionSubtype.intramuscular:
return 'IM';
case InjectionSubtype.subcutaneous:
return 'SC';
case null:
return 'Injection';
}
case MedicationType.topical:
switch (medication.topicalSubtype) {
case TopicalSubtype.gel:
return 'Gel';
case TopicalSubtype.cream:
return 'Cream';
case TopicalSubtype.spray:
return 'Spray';
case null:
return 'Topical';
}
case MedicationType.patch:
return 'Patch';
}
}
/// Get text for completed state based on medication type
static String getCompletedText(MedicationType medicationType) {
switch (medicationType) {
case MedicationType.oral:
return 'Taken';
case MedicationType.injection:
return 'Injected';
case MedicationType.topical:
return 'Applied';
case MedicationType.patch:
return 'Changed';
}
}
/// Get text for pending state based on medication type
static String getPendingText(MedicationType medicationType) {
switch (medicationType) {
case MedicationType.oral:
return 'Not taken';
case MedicationType.injection:
return 'Not injected';
case MedicationType.topical:
return 'Not applied';
case MedicationType.patch:
return 'Not changed';
}
}
///-------------------------------
/// APPOINTMENT TYPES
///-------------------------------
///
/// Get a text description for an appointment type
static String getAppointmentTypeText(AppointmentType appointmentType) {
switch (appointmentType) {
case AppointmentType.bloodwork:
return 'Bloodwork';
case AppointmentType.appointment:
return 'Doctor Visit';
case AppointmentType.surgery:
return 'Surgery';
}
}
}

View file

@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
/// Extension to add firstWhereOrNull method to List
extension FirstWhereOrNullExtension<T> on List<T> {
T? firstWhereOrNull(bool Function(T) test) {
for (final element in this) {
if (test(element)) return element;
}
return null;
}
}

View file

@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
// Extension to add capitalize method to String
extension StringExtension on String {
String capitalize() {
return "${this[0].toUpperCase()}${substring(1)}";
}
}

View file

@ -0,0 +1,269 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// bloodwork.dart
// Model for bloodwork lab results
//
import 'package:uuid/uuid.dart';
/// Custom exception for bloodwork-related errors
class BloodworkException implements Exception {
final String message;
BloodworkException(this.message);
@override
String toString() => 'BloodworkException: $message';
}
/// Types of appointments supported by the bloodwork tracker
enum AppointmentType { bloodwork, appointment, surgery }
/// Represents a single hormone reading with name, value and unit
class HormoneReading {
final String name;
final double value;
final String unit;
final DateTime? date;
final double? minValue;
final double? maxValue;
HormoneReading({
required this.name,
required this.value,
required this.unit,
this.date,
this.minValue,
this.maxValue,
});
/// Convert to JSON format for database storage
Map<String, dynamic> toJson() {
return {
'name': name,
'value': value,
'unit': unit,
'date': date?.toIso8601String(),
'minValue': minValue,
'maxValue': maxValue,
};
}
factory HormoneReading.fromJson(Map<String, dynamic> json) {
return HormoneReading(
name: json['name'] as String,
value: (json['value'] as num).toDouble(),
unit: json['unit'] as String,
date:
json['date'] != null ? DateTime.parse(json['date'] as String) : null,
minValue: json['minValue'] != null
? (json['minValue'] as num).toDouble()
: null,
maxValue: json['maxValue'] != null
? (json['maxValue'] as num).toDouble()
: null,
);
}
/// Create a copy with updated fields
HormoneReading copyWith({
String? name,
double? value,
String? unit,
DateTime? date,
double? minValue,
double? maxValue,
}) {
return HormoneReading(
name: name ?? this.name,
value: value ?? this.value,
unit: unit ?? this.unit,
date: date ?? this.date,
minValue: minValue ?? this.minValue,
maxValue: maxValue ?? this.maxValue,
);
}
}
/// Predefined hormone types with their default units
class HormoneTypes {
static const Map<String, String> defaultUnits = {
// Sex Hormones
'Estradiol': 'pg/mL',
'Free Estradiol': 'pg/mL',
'Estrone': 'pg/mL',
'Estriol': 'pg/mL',
'Testosterone': 'ng/dL',
'Free Testosterone': 'ng/dL',
'Bioavailable Testosterone': 'ng/dL',
'DHT': 'ng/dL',
'Progesterone': 'ng/mL',
'SHBG': 'nmol/L',
'Free Androgen Index': 'ratio',
'Androstenedione': 'ng/dL',
'DHEA': 'μg/dL',
'DHEA-S': 'μg/dL',
'17-OH Progesterone': 'ng/mL',
// Pituitary Hormones
'FSH': 'mIU/mL',
'LH': 'mIU/mL',
'Prolactin': 'ng/mL',
'Growth Hormone': 'ng/mL',
'IGF-1': 'ng/mL',
// Thyroid Hormones
'TSH': 'μIU/mL',
'Free T3': 'pg/mL',
'Total T3': 'ng/dL',
'Free T4': 'ng/dL',
'Total T4': 'μg/dL',
'Reverse T3': 'ng/dL',
'Thyroid Peroxidase Antibodies': 'IU/mL',
'Thyroglobulin Antibodies': 'IU/mL',
'Thyroglobulin': 'ng/mL',
// Adrenal Hormones
'Cortisol': 'μg/dL',
'Cortisone': 'μg/dL',
'Aldosterone': 'ng/dL',
'Epinephrine': 'pg/mL',
'Norepinephrine': 'pg/mL',
// Metabolic Markers
'Insulin': 'μIU/mL',
'C-Peptide': 'ng/mL',
'Leptin': 'ng/mL',
'Adiponectin': 'μg/mL',
// Vitamin D and Calcium Regulation
'Vitamin D (25-OH)': 'ng/mL',
'Vitamin D (1,25-OH)': 'pg/mL',
'Parathyroid Hormone': 'pg/mL',
'Calcitonin': 'pg/mL',
// Other Hormones
'Melatonin': 'pg/mL',
'AMH': 'ng/mL',
'Inhibin B': 'pg/mL',
'Activin': 'pg/mL',
'Oxytocin': 'pg/mL',
'Vasopressin': 'pg/mL',
};
/// Get the default unit for a hormone type
static String getDefaultUnit(String hormoneName) {
return defaultUnits[hormoneName] ?? '';
}
/// Get list of available hormone types
static List<String> getHormoneTypes() {
return defaultUnits.keys.toList();
}
}
/// Primary model for bloodwork lab test data
class Bloodwork {
final String id;
final DateTime date;
final AppointmentType appointmentType;
final List<HormoneReading> hormoneReadings;
final String? location;
final String? doctor;
final String? notes;
/// Constructor with validation
Bloodwork({
String? id,
required this.date,
this.appointmentType = AppointmentType.bloodwork,
List<HormoneReading>? hormoneReadings,
this.location,
this.doctor,
this.notes,
}) : id = id ?? const Uuid().v4(),
hormoneReadings = hormoneReadings ?? [] {
_validate();
}
/// Validates bloodwork fields
void _validate() {
// Validate hormone readings
for (final reading in hormoneReadings) {
if (reading.value < 0) {
throw BloodworkException('${reading.name} level cannot be negative');
}
}
}
/// Convert to JSON format for database storage
Map<String, dynamic> toJson() {
return {
'id': id,
'date': date.toIso8601String(),
'appointmentType': appointmentType.toString(),
'hormoneReadings':
hormoneReadings.map((reading) => reading.toJson()).toList(),
'location': location?.trim(),
'doctor': doctor?.trim(),
'notes': notes?.trim(),
};
}
/// Create a Bloodwork instance from JSON (database record)
factory Bloodwork.fromJson(Map<String, dynamic> json) {
try {
// Parse appointment type from string
AppointmentType parsedType;
try {
parsedType = AppointmentType.values.firstWhere(
(e) => e.toString() == json['appointmentType'],
orElse: () => AppointmentType.bloodwork);
} catch (_) {
// For backward compatibility with old records without appointmentType
parsedType = AppointmentType.bloodwork;
}
// Handle the transition between old and new formats
List<HormoneReading> readings = [];
// First try to parse new format with hormoneReadings list
if (json['hormoneReadings'] != null) {
readings = (json['hormoneReadings'] as List)
.map((reading) => HormoneReading.fromJson(reading))
.toList();
}
return Bloodwork(
id: json['id'] as String,
date: DateTime.parse(json['date']),
appointmentType: parsedType,
hormoneReadings: readings,
location: json['location'] as String?,
doctor: json['doctor'] as String?,
notes: json['notes'] as String?,
);
} catch (e) {
throw BloodworkException('Invalid bloodwork data: $e');
}
}
/// Create a copy of this bloodwork with updated fields
Bloodwork copyWith({
DateTime? date,
AppointmentType? appointmentType,
List<HormoneReading>? hormoneReadings,
String? location,
String? doctor,
String? notes,
}) {
return Bloodwork(
id: id,
date: date ?? this.date,
appointmentType: appointmentType ?? this.appointmentType,
hormoneReadings: hormoneReadings ?? this.hormoneReadings,
location: location ?? this.location,
doctor: doctor ?? this.doctor,
notes: notes ?? this.notes,
);
}
}

View file

@ -0,0 +1,335 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// bloodwork_state.dart
// State management for bloodwork using Riverpod
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
/// State class to handle loading and error states for bloodwork data
class BloodworkState {
final List<Bloodwork> bloodworkRecords;
final bool isLoading;
final String? error;
const BloodworkState({
this.bloodworkRecords = const [],
this.isLoading = false,
this.error,
});
/// Create a new state object with updated fields
BloodworkState copyWith({
List<Bloodwork>? bloodworkRecords,
bool? isLoading,
String? error,
}) {
return BloodworkState(
bloodworkRecords: bloodworkRecords ?? this.bloodworkRecords,
isLoading: isLoading ?? this.isLoading,
error: error, // Pass null to clear error
);
}
}
/// Notifier class to handle bloodwork state changes
class BloodworkNotifier extends StateNotifier<BloodworkState> {
final DatabaseService _databaseService;
BloodworkNotifier({
required DatabaseService databaseService,
}) : _databaseService = databaseService,
super(const BloodworkState()) {
// Load bloodwork when initialized
loadBloodwork();
}
/// Load bloodwork from the database
Future<void> loadBloodwork() async {
try {
state = state.copyWith(isLoading: true, error: null);
final records = await _databaseService.getAllBloodwork();
state = state.copyWith(
bloodworkRecords: records,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to load bloodwork: $e',
);
}
}
/// Add a new bloodwork record to the database
Future<void> addBloodwork(Bloodwork bloodwork) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Save to database
await _databaseService.insertBloodwork(bloodwork);
// Update state immediately with new record
state = state.copyWith(
bloodworkRecords: [...state.bloodworkRecords, bloodwork],
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to add bloodwork: $e',
);
}
}
/// Update an existing bloodwork record in the database
Future<void> updateBloodwork(Bloodwork bloodwork) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Update in database
await _databaseService.updateBloodwork(bloodwork);
// Update state immediately
state = state.copyWith(
bloodworkRecords: state.bloodworkRecords
.map((record) => record.id == bloodwork.id ? bloodwork : record)
.toList(),
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to update bloodwork: $e',
);
}
}
/// Delete a bloodwork record
Future<void> deleteBloodwork(String id) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Delete from database
await _databaseService.deleteBloodwork(id);
// Update state immediately by filtering out the deleted record
state = state.copyWith(
bloodworkRecords:
state.bloodworkRecords.where((record) => record.id != id).toList(),
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to delete bloodwork: $e',
);
}
}
/// Helper method to find bloodwork by ID
Bloodwork? getBloodworkById(String id) {
try {
return state.bloodworkRecords.firstWhere((record) => record.id == id);
} catch (e) {
return null;
}
}
}
//----------------------------------------------------------------------------
// PROVIDER DEFINITIONS
//----------------------------------------------------------------------------
/// Main state notifier provider for bloodwork
final bloodworkStateProvider =
StateNotifierProvider<BloodworkNotifier, BloodworkState>((ref) {
final databaseService = ref.watch(databaseServiceProvider);
return BloodworkNotifier(
databaseService: databaseService,
);
});
//----------------------------------------------------------------------------
// CONVENIENCE PROVIDERS
//----------------------------------------------------------------------------
/// Provider for accessing the list of bloodwork records
final bloodworkRecordsProvider = Provider<List<Bloodwork>>((ref) {
return ref.watch(bloodworkStateProvider).bloodworkRecords;
});
/// Provider for checking if bloodwork data is loading
final bloodworkLoadingProvider = Provider<bool>((ref) {
return ref.watch(bloodworkStateProvider).isLoading;
});
/// Provider for accessing bloodwork loading errors
final bloodworkErrorProvider = Provider<String?>((ref) {
return ref.watch(bloodworkStateProvider).error;
});
/// Provider for all medical records sorted by date (most recent first)
final sortedBloodworkProvider = Provider<List<Bloodwork>>((ref) {
final records = ref.watch(bloodworkStateProvider).bloodworkRecords;
// Sort by date (most recent first)
return [...records]..sort((a, b) => b.date.compareTo(a.date));
});
/// Provider for bloodwork-type records only (for graph display)
final bloodworkTypeRecordsProvider = Provider<List<Bloodwork>>((ref) {
final records = ref.watch(bloodworkStateProvider).bloodworkRecords;
// Filter only records that are bloodwork type for hormone graphs
final bloodworkRecords = records
.where((record) => record.appointmentType == AppointmentType.bloodwork)
.toList();
// Sort by date (most recent first)
return [...bloodworkRecords]..sort((a, b) => b.date.compareTo(a.date));
});
/// Provider for getting all bloodwork dates for calendar display
final bloodworkDatesProvider = Provider<Set<DateTime>>((ref) {
final records = ref.watch(bloodworkStateProvider).bloodworkRecords;
return records.map((record) {
final date = record.date;
return DateTime(date.year, date.month, date.day);
}).toSet();
});
/// Provider that groups and sorts bloodwork records into upcoming, today, and past sections
final groupedBloodworkProvider = Provider<Map<String, List<Bloodwork>>>((ref) {
final records = ref.watch(bloodworkStateProvider).bloodworkRecords;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
// Initialize the result map with empty lists
final result = {
'upcoming': <Bloodwork>[],
'today': <Bloodwork>[],
'past': <Bloodwork>[],
};
// Categorize each record
for (final record in records) {
final recordDate =
DateTime(record.date.year, record.date.month, record.date.day);
if (recordDate.isAfter(today)) {
result['upcoming']!.add(record);
} else if (recordDate.isAtSameMomentAs(today)) {
result['today']!.add(record);
} else {
result['past']!.add(record);
}
}
// Sort upcoming appointments (earliest first)
result['upcoming']!.sort((a, b) => a.date.compareTo(b.date));
// Sort today's appointments (earliest first)
result['today']!.sort((a, b) => a.date.compareTo(b.date));
// Sort past appointments (latest first)
result['past']!.sort((a, b) => b.date.compareTo(a.date));
return result;
});
/// Provider for getting different colors for different appointment types in calendar
final appointmentTypeColorsProvider =
Provider<Map<AppointmentType, Color>>((ref) {
return {
AppointmentType.bloodwork: Colors.red,
AppointmentType.appointment: Colors.blue,
AppointmentType.surgery: Colors.purple,
};
});
/// Provider for filtered bloodwork records by appointment type
final filteredBloodworkByTypeProvider =
Provider.family<List<Bloodwork>, AppointmentType>((ref, type) {
final records = ref.watch(bloodworkStateProvider).bloodworkRecords;
return records.where((record) => record.appointmentType == type).toList();
});
/// Provider that extracts all unique hormone types from records
final hormoneTypesProvider = Provider<List<String>>((ref) {
final records = ref.watch(bloodworkTypeRecordsProvider);
final Set<String> hormoneTypes = {};
// Extract from readings
for (final record in records) {
for (final reading in record.hormoneReadings) {
hormoneTypes.add(reading.name);
}
}
// Sort alphabetically
final sortedTypes = hormoneTypes.toList()..sort();
return sortedTypes;
});
/// Provider for getting all records for a specific hormone type
final hormoneRecordsProvider =
Provider.family<List<MapEntry<DateTime, double>>, String>(
(ref, hormoneName) {
final records = ref.watch(bloodworkTypeRecordsProvider);
final List<MapEntry<DateTime, double>> readings = [];
for (final record in records) {
// First check in hormone readings
for (final reading in record.hormoneReadings) {
if (reading.name == hormoneName) {
readings.add(MapEntry(record.date, reading.value));
}
}
}
// Sort by date (oldest first for charts)
readings.sort((a, b) => a.key.compareTo(b.key));
return readings;
});
/// Provider to get the most recent value for a specific hormone
final latestHormoneValueProvider =
Provider.family<double?, String>((ref, hormoneName) {
final readings = ref.watch(hormoneRecordsProvider(hormoneName));
if (readings.isEmpty) return null;
// Sort by date descending (most recent first)
final sortedReadings = [...readings];
sortedReadings.sort((a, b) => b.key.compareTo(a.key));
return sortedReadings.first.value;
});
/// Provider to get the unit for a specific hormone type
final hormoneUnitProvider = Provider.family<String, String>((ref, hormoneName) {
final records = ref.watch(bloodworkTypeRecordsProvider);
// First try to find it in the data
for (final record in records) {
for (final reading in record.hormoneReadings) {
if (reading.name == hormoneName) {
return reading.unit;
}
}
}
// Fall back to default units
switch (hormoneName) {
case 'Estradiol':
return 'pg/mL';
case 'Testosterone':
return 'ng/dL';
default:
return HormoneTypes.getDefaultUnit(hormoneName);
}
});

View file

@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_location_doctor.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/services/database/medical_providers_repository.dart';
import 'package:nokken/src/core/ui/widgets/autocomplete_text_field.dart';
/// Location and doctor fields with autocomplete functionality
class LocationDoctorFields extends ConsumerWidget {
final TextEditingController locationController;
final TextEditingController doctorController;
const LocationDoctorFields({
super.key,
required this.locationController,
required this.doctorController,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the providers for location and doctor suggestions
final locationsAsync = ref.watch(locationsProvider);
final doctorsAsync = ref.watch(doctorsProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Location field
locationsAsync.when(
data: (locations) {
return AutocompleteTextField(
controller: locationController,
labelText: 'Location',
hintText: 'Enter appointment location',
options: locations,
);
},
error: (_, __) {
// On error, fall back to standard text field
return TextFormField(
controller: locationController,
decoration: InputDecoration(
labelText: 'Location',
hintText: 'Enter appointment location',
),
);
},
loading: () {
// While loading, show autocomplete with loading state
return AutocompleteTextField(
controller: locationController,
labelText: 'Location',
hintText: 'Enter appointment location',
options: const [],
isLoading: true,
);
},
),
SharedWidgets.verticalSpace(),
// Doctor field
doctorsAsync.when(
data: (doctors) {
return AutocompleteTextField(
controller: doctorController,
labelText: 'Healthcare Provider',
hintText: 'Enter doctor or provider name',
options: doctors,
);
},
error: (_, __) {
// On error, fall back to standard text field
return TextFormField(
controller: doctorController,
decoration: InputDecoration(
labelText: 'Healthcare Provider',
hintText: 'Enter doctor or provider name',
),
);
},
loading: () {
// While loading, show autocomplete with loading state
return AutocompleteTextField(
controller: doctorController,
labelText: 'Healthcare Provider',
hintText: 'Enter doctor or provider name',
options: const [],
isLoading: true,
);
},
),
],
);
}
}

View file

@ -0,0 +1,355 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// blood_level_list_screen.dart
// Screen that displays each hormone level with a mini graph
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/features/bloodwork_tracker/providers/bloodwork_state.dart';
import 'package:nokken/src/features/bloodwork_tracker/screens/hormone_search_bar.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
/// Screen that shows an overview of all hormone levels
class BloodLevelListScreen extends ConsumerStatefulWidget {
const BloodLevelListScreen({super.key});
@override
ConsumerState<BloodLevelListScreen> createState() =>
_BloodLevelListScreenState();
}
class _BloodLevelListScreenState extends ConsumerState<BloodLevelListScreen> {
// Search query state
String _searchQuery = '';
// Filter hormones based on search query
List<String> _filterHormones(List<String> hormones, String query) {
if (query.isEmpty) return hormones;
return hormones
.where((hormone) => hormone.toLowerCase().contains(query.toLowerCase()))
.toList();
}
@override
Widget build(BuildContext context) {
// Get only bloodwork type records for hormone graphs
final bloodworkRecords = ref.watch(bloodworkTypeRecordsProvider);
final isLoading = ref.watch(bloodworkLoadingProvider);
final error = ref.watch(bloodworkErrorProvider);
// Extract all unique hormone types from the data
final Set<String> hormoneTypes = {};
for (final record in bloodworkRecords) {
for (final reading in record.hormoneReadings) {
hormoneTypes.add(reading.name);
}
}
// Sort hormone types alphabetically
final sortedHormoneTypes = hormoneTypes.toList()..sort();
// Apply search filter
final filteredHormoneTypes =
_filterHormones(sortedHormoneTypes, _searchQuery);
return Scaffold(
appBar: AppBar(
title: const Text('Hormone Levels'),
),
body: error != null
? Center(
child: Text(
'Error: $error',
style: TextStyle(color: AppColors.error),
),
)
: isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
// Search bar
HormoneSearchBar(
hintText: 'Search hormones...',
onSearch: (query) {
setState(() {
_searchQuery = query;
});
},
),
// Results
Expanded(
child: filteredHormoneTypes.isEmpty
? _buildEmptyState(_searchQuery.isNotEmpty)
: SafeArea(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredHormoneTypes.length,
itemBuilder: (context, index) {
final hormoneType =
filteredHormoneTypes[index];
return _HormoneLevelTile(
hormoneName: hormoneType,
bloodworkRecords: bloodworkRecords,
);
},
),
),
),
],
),
);
}
/// Builds the empty state view when no data exists
Widget _buildEmptyState(bool isSearching) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSearching ? Icons.search_off : AppIcons.getOutlined('bloodwork'),
size: 64,
color: AppColors.secondary,
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
Text(
isSearching
? 'No matching hormone data found'
: 'No hormone data available',
textAlign: TextAlign.center,
),
SharedWidgets.horizontalSpace(),
Text(
isSearching
? 'Try a different search term'
: 'Add bloodwork with hormone levels\nto see them displayed here',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
),
),
],
),
);
}
}
/// A tile that displays information about a hormone level
class _HormoneLevelTile extends StatelessWidget {
final String hormoneName;
final List<Bloodwork> bloodworkRecords;
const _HormoneLevelTile({
required this.hormoneName,
required this.bloodworkRecords,
});
@override
Widget build(BuildContext context) {
// Get readings for this hormone
final List<MapEntry<DateTime, double>> readings = _getHormoneReadings();
// Sort readings by date (oldest to newest for graph)
readings.sort((a, b) => a.key.compareTo(b.key));
// No data available
if (readings.isEmpty) {
return const SizedBox.shrink();
}
// Get last reading and unit
final lastReading = readings.last;
String unit = _getHormoneUnit();
// Calculate trends (if we have at least 2 points)
final trendInfo = _calculateTrend(readings);
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: InkWell(
onTap: () => NavigationService.goToBloodworkGraphWithHormone(
context,
hormoneName,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row with name and last value
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Hormone name
Flexible(
child: Text(
hormoneName,
style: AppTextStyles.titleLarge,
overflow: TextOverflow.ellipsis,
),
),
// Last value with trend indicator
Row(
children: [
if (trendInfo.showTrend)
Icon(
trendInfo.isIncreasing
? AppIcons.getIcon('arrow_up')
: AppIcons.getIcon('arrow_down'),
color: trendInfo.isIncreasing
? Colors.red
: Colors.green,
size: 16,
),
SharedWidgets.horizontalSpace(4),
Text(
'${lastReading.value.toStringAsFixed(1)} $unit',
style: AppTextStyles.titleMedium,
),
],
),
],
),
SharedWidgets.horizontalSpace(),
// Date of last reading
Text(
'Last recorded: ${DateFormat('MMM d, yyyy').format(lastReading.key)}',
style: AppTextStyles.bodySmall,
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
// Mini chart
SizedBox(
height: 60,
child: _buildMiniChart(readings),
),
],
),
),
),
);
}
/// Get all readings for this hormone
List<MapEntry<DateTime, double>> _getHormoneReadings() {
final readings = <MapEntry<DateTime, double>>[];
for (final record in bloodworkRecords) {
// Check in hormone readings list
for (final reading in record.hormoneReadings) {
if (reading.name == hormoneName) {
readings.add(MapEntry(record.date, reading.value));
}
}
}
return readings;
}
/// Get the unit for this hormone type
String _getHormoneUnit() {
// First try to find it in the data
for (final record in bloodworkRecords) {
for (final reading in record.hormoneReadings) {
if (reading.name == hormoneName) {
return reading.unit;
}
}
}
// Fall back to default units
switch (hormoneName) {
case 'Estradiol':
return 'pg/mL';
case 'Testosterone':
return 'ng/dL';
default:
return HormoneTypes.getDefaultUnit(hormoneName);
}
}
/// Calculate trend information
({bool showTrend, bool isIncreasing, double percentChange}) _calculateTrend(
List<MapEntry<DateTime, double>> readings) {
if (readings.length < 2) {
return (showTrend: false, isIncreasing: false, percentChange: 0);
}
final lastValue = readings.last.value;
final previousValue = readings[readings.length - 2].value;
if (previousValue == 0) {
return (showTrend: false, isIncreasing: false, percentChange: 0);
}
final percentChange = ((lastValue - previousValue) / previousValue) * 100;
final isIncreasing = lastValue > previousValue;
return (
showTrend: true,
isIncreasing: isIncreasing,
percentChange: percentChange.abs()
);
}
/// Build a mini line chart for the hormone levels
Widget _buildMiniChart(List<MapEntry<DateTime, double>> readings) {
// Extract values for min/max Y-axis
final values = readings.map((e) => e.value).toList();
final minY = values.reduce((a, b) => a < b ? a : b) * 0.9;
final maxY = values.reduce((a, b) => a > b ? a : b) * 1.1;
// Handle the case with a single reading
List<FlSpot> spots;
if (readings.length == 1) {
// Create a horizontal line for a single data point
double value = readings[0].value;
spots = [FlSpot(0, value), FlSpot(1, value)];
} else {
// Normal case with multiple readings
spots = List.generate(readings.length, (i) {
return FlSpot(i.toDouble(), readings[i].value);
});
}
return LineChart(
LineChartData(
gridData: FlGridData(show: false),
titlesData: FlTitlesData(show: false),
borderData: FlBorderData(show: false),
lineTouchData: LineTouchData(enabled: false),
minX: 0,
maxX: readings.length == 1 ? 1.0 : readings.length - 1.0,
minY: minY,
maxY: maxY,
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
dotData: FlDotData(show: false),
color: AppColors.primary,
belowBarData: BarAreaData(
show: true,
color: AppColors.primary.withAlpha(40),
),
),
],
),
);
}
}

View file

@ -0,0 +1,725 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// bloodwork_graph_screen.dart
// Screen that displays hormone level graphs over time
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/features/bloodwork_tracker/providers/bloodwork_state.dart';
import 'package:nokken/src/features/bloodwork_tracker/screens/hormone_search_bar.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/timeframe_selector.dart';
import 'package:nokken/src/core/ui/widgets/dialog_service.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
class BloodworkGraphScreen extends ConsumerStatefulWidget {
final String? selectedHormone;
const BloodworkGraphScreen({
super.key,
this.selectedHormone,
});
@override
ConsumerState<BloodworkGraphScreen> createState() =>
_BloodworkGraphScreenState();
}
class _BloodworkGraphScreenState extends ConsumerState<BloodworkGraphScreen>
with TickerProviderStateMixin {
// Changed from SingleTickerProviderStateMixin
late TabController _tabController;
// Time range filter
String _selectedTimeFrame = 'All Time';
// Search filter
String _searchQuery = '';
// Available hormone types
late List<String> _hormoneTypes;
late List<String> _filteredHormoneTypes;
late String _selectedHormone;
// Scroll controller for tab bar
final ScrollController _tabScrollController = ScrollController();
// Recommended ranges (female reference ranges)
// todo: should be own file
final Map<String, ({double min, double max, String unit})>
_recommendedRanges = {
'Estradiol': (min: 30, max: 400, unit: 'pg/mL'),
'Testosterone': (min: 15, max: 70, unit: 'ng/dL'),
'Progesterone': (min: 0.1, max: 25, unit: 'ng/mL'),
'FSH': (min: 4, max: 13, unit: 'mIU/mL'),
'LH': (min: 1, max: 20, unit: 'mIU/mL'),
'Prolactin': (min: 4, max: 30, unit: 'ng/mL'),
'DHEA-S': (min: 35, max: 430, unit: 'μg/dL'),
'Cortisol': (min: 5, max: 25, unit: 'μg/dL'),
'TSH': (min: 0.4, max: 4.0, unit: 'μIU/mL'),
'Free T4': (min: 0.8, max: 1.8, unit: 'ng/dL'),
};
@override
void initState() {
super.initState();
// Initialize with the selected hormone or default to first available
_selectedHormone = widget.selectedHormone ?? '';
// Extract hormone types from data
final bloodworkRecords = ref.read(bloodworkTypeRecordsProvider);
_hormoneTypes = _extractHormoneTypes(bloodworkRecords);
_filteredHormoneTypes = _hormoneTypes; // Initialize filtered list
// If no selected hormone or selected hormone not in the list, use first one
if (_selectedHormone.isEmpty || !_hormoneTypes.contains(_selectedHormone)) {
_selectedHormone = _hormoneTypes.isNotEmpty ? _hormoneTypes[0] : '';
}
// Initialize tab controller with the number of available hormone types
_tabController = TabController(
length: _hormoneTypes.length,
vsync: this,
initialIndex: _selectedHormone.isNotEmpty
? _hormoneTypes.indexOf(_selectedHormone)
: 0,
);
// Listen to tab changes
_tabController.addListener(_onTabChanged);
}
// Tab change listener as a separate method so we can remove it when needed
void _onTabChanged() {
if (_tabController.indexIsChanging) return;
if (_filteredHormoneTypes.isNotEmpty) {
setState(() {
_selectedHormone = _filteredHormoneTypes[_tabController.index];
});
}
}
/// Extract all available hormone types from records
List<String> _extractHormoneTypes(List<Bloodwork> records) {
// Set to store unique hormone types
final Set<String> hormoneSet = {};
// Extract from hormone readings
for (final record in records) {
for (final reading in record.hormoneReadings) {
hormoneSet.add(reading.name);
}
}
// Sort alphabetically
final hormoneList = hormoneSet.toList()..sort();
return hormoneList;
}
/// Filter hormone types based on search query
void _filterHormoneTypes(String query) {
setState(() {
_searchQuery = query.toLowerCase();
if (_searchQuery.isEmpty) {
_filteredHormoneTypes = _hormoneTypes;
} else {
_filteredHormoneTypes = _hormoneTypes
.where((hormone) => hormone.toLowerCase().contains(_searchQuery))
.toList();
}
// Update selected hormone if needed
if (_filteredHormoneTypes.isNotEmpty) {
if (!_filteredHormoneTypes.contains(_selectedHormone)) {
_selectedHormone = _filteredHormoneTypes[0];
}
} else {
_selectedHormone = '';
}
// Update tab controller
_updateTabController();
});
}
/// Update tab controller when filtered list changes
void _updateTabController() {
// Remove listener before disposing
_tabController.removeListener(_onTabChanged);
// Find the appropriate index for the selected hormone
final int newIndex = _selectedHormone.isNotEmpty &&
_filteredHormoneTypes.contains(_selectedHormone)
? _filteredHormoneTypes.indexOf(_selectedHormone)
: 0;
// Dispose the old controller before creating a new one
_tabController.dispose();
// Create a new controller
_tabController = TabController(
length: _filteredHormoneTypes.length,
vsync: this,
initialIndex: _filteredHormoneTypes.isEmpty ? 0 : newIndex,
);
// Reattach the listener
_tabController.addListener(_onTabChanged);
}
@override
void dispose() {
_tabController.dispose();
_tabScrollController.dispose();
super.dispose();
}
List<Bloodwork> _getFilteredData(List<Bloodwork> allRecords) {
if (_selectedTimeFrame == 'All Time') {
return allRecords;
}
final now = DateTime.now();
DateTime cutoffDate;
switch (_selectedTimeFrame) {
case '3 Months':
cutoffDate = DateTime(now.year, now.month - 3, now.day);
break;
case '6 Months':
cutoffDate = DateTime(now.year, now.month - 6, now.day);
break;
case '1 Year':
cutoffDate = DateTime(now.year - 1, now.month, now.day);
break;
default:
return allRecords;
}
return allRecords
.where((record) => record.date.isAfter(cutoffDate))
.toList();
}
@override
Widget build(BuildContext context) {
// Use the bloodwork-type only provider for hormone graphs
final bloodworkRecords = ref.watch(bloodworkTypeRecordsProvider);
final isLoading = ref.watch(bloodworkLoadingProvider);
final error = ref.watch(bloodworkErrorProvider);
// Check if we need to update the hormone types and tabs
final newHormoneTypes = _extractHormoneTypes(bloodworkRecords);
if (newHormoneTypes.length != _hormoneTypes.length ||
!newHormoneTypes.every((e) => _hormoneTypes.contains(e))) {
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_hormoneTypes = newHormoneTypes;
// Apply current search filter
_filterHormoneTypes(_searchQuery);
});
});
}
// Get filtered data based on time range
final filteredRecords = _getFilteredData(bloodworkRecords);
// Sort the records chronologically (oldest first) for chart display
final chronologicalRecords = [...filteredRecords]
..sort((a, b) => a.date.compareTo(b.date));
return Scaffold(
appBar: AppBar(
title: Text(
_selectedHormone.isNotEmpty ? _selectedHormone : 'Blood Levels'),
bottom: PreferredSize(
preferredSize:
const Size.fromHeight(96), // Increased height for search bar
child: Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: HormoneSearchBar(
hintText: 'Search hormones...',
onSearch: _filterHormoneTypes,
inAppBar: true,
),
),
// Tabs
Container(
alignment: Alignment.centerLeft,
child: TabBar(
controller: _tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
tabs: _filteredHormoneTypes
.map((type) => Tab(text: type))
.toList(),
),
),
],
),
),
actions: [
// Timeframe filter dropdown
TimeframeSelector.dropdownButton(
context: context,
selectedTimeframe: _selectedTimeFrame,
onTimeframeSelected: (value) {
setState(() {
_selectedTimeFrame = value;
});
},
),
],
),
body: error != null
? Center(
child: Text(
'Error: $error',
style: TextStyle(color: AppColors.error),
),
)
: isLoading
? const Center(child: CircularProgressIndicator())
: _selectedHormone.isEmpty ||
chronologicalRecords.isEmpty ||
_filteredHormoneTypes.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
AppIcons.getOutlined('bloodwork'),
size: 64,
color: AppColors.bloodwork,
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
const Text(
'No bloodwork data available for the selected time range',
textAlign: TextAlign.center,
),
SharedWidgets.verticalSpace(),
const Text(
'Only lab appointments with hormone levels are shown in graphs',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
),
),
],
),
)
: TabBarView(
controller: _tabController,
children: _filteredHormoneTypes
.map(
(hormoneType) => _buildChartContainer(
chronologicalRecords,
hormoneType,
_getHormoneColor(hormoneType),
(record) => _getHormoneValue(record, hormoneType),
_getHormoneUnit(
chronologicalRecords, hormoneType),
),
)
.toList(),
),
);
}
// Generate colors for hormones using hashes
Color _getHormoneColor(String hormoneType) {
final hash = hormoneType.hashCode;
return Color.fromARGB(
255,
(hash & 0xFF),
((hash >> 8) & 0xFF),
((hash >> 16) & 0xFF),
);
}
/// Get the hormone value from a record
double? _getHormoneValue(Bloodwork record, String hormoneType) {
// First check in hormone readings
for (final reading in record.hormoneReadings) {
if (reading.name == hormoneType) {
return reading.value;
}
}
return null;
}
/// Get the unit for this hormone type
String _getHormoneUnit(List<Bloodwork> records, String hormoneType) {
// First try to find it in the data
for (final record in records) {
for (final reading in record.hormoneReadings) {
if (reading.name == hormoneType) {
return reading.unit;
}
}
}
// Try to get from recommended ranges
if (_recommendedRanges.containsKey(hormoneType)) {
return _recommendedRanges[hormoneType]!.unit;
}
// Fall back to default units
return HormoneTypes.getDefaultUnit(hormoneType);
}
Widget _buildChartContainer(
List<Bloodwork> records,
String title,
Color lineColor,
double? Function(Bloodwork) valueGetter,
String unit,
) {
// Filter out records with null values for this hormone
final validRecords =
records.where((record) => valueGetter(record) != null).toList();
if (validRecords.isEmpty) {
return Center(
child: Text('No data available for $title'),
);
}
return Padding(
padding: AppTheme.standardCardPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$title ($unit)',
style: AppTextStyles.titleMedium,
),
SharedWidgets.verticalSpace(),
Text(
'Time range: $_selectedTimeFrame',
style: AppTextStyles.bodySmall,
),
SharedWidgets.verticalSpace(AppTheme.tripleSpacing),
Expanded(
child: _buildLineChart(
validRecords, lineColor, valueGetter, unit, title),
),
],
),
);
}
Widget _buildLineChart(
List<Bloodwork> records,
Color lineColor,
double? Function(Bloodwork) valueGetter,
String unit,
String hormoneType,
) {
// Get all values to calculate min and max for Y axis
final values = records
.map((record) => valueGetter(record))
.where((value) => value != null)
.map((value) => value!)
.toList();
if (values.isEmpty) {
return const Center(child: Text('No data available'));
}
// Calculate min/max Y values
double minY =
(values.reduce((a, b) => a < b ? a : b) * 0.8).floorToDouble();
double maxY = (values.reduce((a, b) => a > b ? a : b) * 1.2).ceilToDouble();
// Adjust based on recommended ranges if available
if (_recommendedRanges.containsKey(hormoneType)) {
final range = _recommendedRanges[hormoneType]!;
// Ensure the range is visible on the chart
minY = minY < range.min ? minY : (range.min * 0.8).floorToDouble();
maxY = maxY > range.max ? maxY : (range.max * 1.2).ceilToDouble();
}
return LineChart(
LineChartData(
gridData: const FlGridData(
show: true,
drawVerticalLine: true,
drawHorizontalLine: true,
),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
final int index = value.toInt();
// Only show titles for actual data points
if (index >= 0 && index < records.length) {
final date = records[index].date;
// For single data point, always show the date
if (records.length == 1) {
final dateFormat = DateFormat('MM/dd/yy');
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
dateFormat.format(date),
style: AppTextStyles.labelSmall,
),
);
}
// For multiple data points, ensure even distribution
if (records.length < 8) {
// For small datasets, show all dates
final dateFormat = _dateFormatSpansMultipleYears(records)
? DateFormat('MM/dd/yy')
: DateFormat('MM/dd');
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
dateFormat.format(date),
style: AppTextStyles.labelSmall,
),
);
} else {
// For larger datasets, show dates at fixed intervals
// Ensure first and last point always show, plus some evenly spaced ones
int interval = (records.length / 5).ceil();
if (index == 0 ||
index == records.length - 1 ||
index % interval == 0) {
final dateFormat = _dateFormatSpansMultipleYears(records)
? DateFormat('MM/dd/yy')
: DateFormat('MM/dd');
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
dateFormat.format(date),
style: AppTextStyles.labelSmall,
),
);
}
}
}
return const SizedBox();
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget: (value, meta) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Text(
value.toStringAsFixed(1),
style: AppTextStyles.labelSmall,
),
);
},
),
),
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: AppColors.outline.withAlpha(150)),
),
minX: 0,
maxX: records.length - 1.0,
minY: minY,
maxY: maxY,
lineBarsData: [
LineChartBarData(
spots: _createSpots(records, valueGetter),
isCurved: true,
color: lineColor,
barWidth: 3,
isStrokeCapRound: true,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: lineColor.withAlpha(40),
),
),
// Add recommended range overlay if available
if (_recommendedRanges.containsKey(hormoneType))
..._createRecommendedRangeOverlay(records, hormoneType),
],
lineTouchData: LineTouchData(
enabled: true,
// Disable built-in hover behavior
handleBuiltInTouches: false,
touchTooltipData: LineTouchTooltipData(
// Make tooltip invisible since we'll show a custom dialog
tooltipBgColor: Colors.transparent,
tooltipPadding: EdgeInsets.zero,
tooltipMargin: 0,
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
// Return null for all spots to prevent the default tooltip
return touchedBarSpots.map((_) => null).toList();
},
),
// Handle tap events to show a custom dialog
touchCallback:
(FlTouchEvent event, LineTouchResponse? touchResponse) {
// Only respond to tap events
if (event is! FlTapUpEvent ||
touchResponse == null ||
touchResponse.lineBarSpots == null ||
touchResponse.lineBarSpots!.isEmpty) {
return;
}
final spot = touchResponse.lineBarSpots!.first;
if (spot.barIndex == 0) {
// Only respond to main data line
final index = spot.x.toInt();
if (index >= 0 && index < records.length) {
final record = records[index];
final value = valueGetter(record)!;
DialogService.showBloodworkDetailDialog(
context: context,
hormoneType: hormoneType,
date: record.date,
value: value,
unit: unit,
record: record,
);
}
}
},
// Make touch area larger for easier tapping
touchSpotThreshold: 20,
),
),
);
}
/// Create spots for the line chart
List<FlSpot> _createSpots(
List<Bloodwork> records,
double? Function(Bloodwork) valueGetter,
) {
final spots = <FlSpot>[];
for (int i = 0; i < records.length; i++) {
final value = valueGetter(records[i]);
if (value != null) {
spots.add(FlSpot(i.toDouble(), value));
}
}
// Create a horizontal line if there's only one data point
if (spots.length == 1) {
// Add a second point with the same Y value but at X position 1
final spot = spots.first;
spots.add(FlSpot(1.0, spot.y));
// Set the first point to X position 0
spots[0] = FlSpot(0.0, spot.y);
}
return spots;
}
/// Create overlays for recommended ranges
List<LineChartBarData> _createRecommendedRangeOverlay(
List<Bloodwork> records, String hormoneType) {
if (!_recommendedRanges.containsKey(hormoneType)) {
return [];
}
final range = _recommendedRanges[hormoneType]!;
final minY = range.min;
final maxY = range.max;
return [
// Minimum line
LineChartBarData(
spots: [
FlSpot(0, minY),
FlSpot(records.length - 1, minY),
],
isCurved: false,
color: AppTheme.grey.withAlpha(160),
barWidth: 1,
isStrokeCapRound: false,
dotData: const FlDotData(show: false),
dashArray: [5, 5], // Dashed line
// Disable touch interaction for reference lines
show: true,
),
// Maximum line
LineChartBarData(
spots: [
FlSpot(0, maxY),
FlSpot(records.length - 1, maxY),
],
isCurved: false,
color: AppTheme.grey.withAlpha(160),
barWidth: 1,
isStrokeCapRound: false,
dotData: const FlDotData(show: false),
dashArray: [5, 5], // Dashed line
// Disable touch interaction for reference lines
show: true,
),
// Filled area between min and max
LineChartBarData(
spots: [
FlSpot(0, minY),
FlSpot(records.length - 1, minY),
],
isCurved: false,
color: Colors.transparent,
barWidth: 0,
isStrokeCapRound: false,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: false,
),
aboveBarData: BarAreaData(
show: true,
color: Colors.green.withAlpha(20),
cutOffY: maxY,
applyCutOffY: true,
),
// Disable touch interaction for reference lines
show: true,
),
];
}
/// Check if the dataset spans multiple years
bool _dateFormatSpansMultipleYears(List<Bloodwork> records) {
if (records.length < 2) return false;
final firstYear = records.first.date.year;
return records.any((record) => record.date.year != firstYear);
}
}

View file

@ -0,0 +1,435 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// bloodwork_list_screen.dart
// Screen that displays user's bloodwork records in a list
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:nokken/src/core/utils/get_icons_colors.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/features/bloodwork_tracker/providers/bloodwork_state.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
import 'package:nokken/src/core/utils/get_labels.dart';
/// This widget adds a sticky header decorator for each section
class SectionWithStickyHeader extends StatelessWidget {
final String title;
final List<Bloodwork> records;
const SectionWithStickyHeader({
super.key,
required this.title,
required this.records,
});
@override
Widget build(BuildContext context) {
// Return a SliverToBoxAdapter instead of SizedBox.shrink for empty records
if (records.isEmpty) {
return SliverToBoxAdapter(child: const SizedBox());
}
// Determine icon and color based on section title
IconData sectionIcon;
Color sectionColor;
switch (title) {
case 'Today':
sectionIcon = AppIcons.getIcon('today');
sectionColor = AppTheme.greenDark;
break;
case 'Upcoming':
sectionIcon = AppIcons.getIcon('event');
sectionColor = Colors.blue;
break;
case 'Past':
default:
sectionIcon = AppIcons.getIcon('history');
sectionColor = AppTheme.grey;
break;
}
return SliverStickyHeader(
header: Container(
color: AppColors.surface,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
// Icon based on section
Icon(
sectionIcon,
size: 20,
color: sectionColor,
),
SharedWidgets.horizontalSpace(),
// Section title
Text(
title,
style: AppTextStyles.titleMedium.copyWith(
color: sectionColor,
fontWeight: FontWeight.bold,
),
),
// Count badge
SharedWidgets.horizontalSpace(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: sectionColor.withAlpha(20),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${records.length}',
style: TextStyle(
fontSize: 12,
color: sectionColor,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => BloodworkListTile(bloodwork: records[index]),
childCount: records.length,
),
),
);
}
}
/// Main bloodwork list screen with sections
class BloodworkListScreen extends ConsumerWidget {
const BloodworkListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch for changes to bloodwork data using the grouped provider
final groupedRecords = ref.watch(groupedBloodworkProvider);
final isLoading = ref.watch(bloodworkLoadingProvider);
final error = ref.watch(bloodworkErrorProvider);
// Check if there are any records at all
final bool hasRecords = groupedRecords['upcoming']!.isNotEmpty ||
groupedRecords['today']!.isNotEmpty ||
groupedRecords['past']!.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('Appointments'),
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: Icon(AppIcons.getIcon('analytics')),
onPressed: () => NavigationService.goToBloodLevelList(context),
tooltip: 'View Hormone Levels',
),
IconButton(
onPressed: () => NavigationService.goToBloodworkAddEdit(context),
icon: Icon(AppIcons.getIcon('add')),
color: AppColors.onPrimary,
),
],
),
body: RefreshIndicator(
onRefresh: () =>
ref.read(bloodworkStateProvider.notifier).loadBloodwork(),
child: Column(
children: [
// Error Display - shown only when there's an error
if (error != null)
Container(
color: AppColors.errorContainer,
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
AppIcons.getIcon('error'),
color: AppColors.error,
),
SharedWidgets.horizontalSpace(),
Expanded(
child: Text(
error,
style: TextStyle(
color: AppColors.error,
),
),
),
],
),
),
// Main Content Area
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: !hasRecords
? _buildEmptyState(context)
: CustomScrollView(
slivers: [
// Today section with sticky header
SectionWithStickyHeader(
title: 'Today',
records: groupedRecords['today']!,
),
// Upcoming section with sticky header
SectionWithStickyHeader(
title: 'Upcoming',
records: groupedRecords['upcoming']!,
),
// Past section with sticky header
SectionWithStickyHeader(
title: 'Past',
records: groupedRecords['past']!,
),
],
),
),
],
),
),
);
}
/// Builds the empty state view when no records exist
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
AppIcons.getOutlined('event_note'),
size: 64,
color: AppColors.secondary,
),
SharedWidgets.verticalSpace(),
Text(
'No appointments yet',
style: AppTheme.titleLarge,
),
SharedWidgets.verticalSpace(),
ElevatedButton(
onPressed: () => NavigationService.goToBloodworkAddEdit(context),
child: Text(
'Add Appointment',
style: AppTextStyles.titleMedium,
),
),
],
),
);
}
}
/// List tile for displaying a bloodwork record in the list
class BloodworkListTile extends StatelessWidget {
final Bloodwork bloodwork;
const BloodworkListTile({
super.key,
required this.bloodwork,
});
/// Check if date is in the future
bool _isDateInFuture() {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final recordDate =
DateTime(bloodwork.date.year, bloodwork.date.month, bloodwork.date.day);
return recordDate.isAfter(today);
}
@override
Widget build(BuildContext context) {
final isFutureDate = _isDateInFuture();
// Format the time for display
final timeOfDay = TimeOfDay.fromDateTime(bloodwork.date);
final timeStr = DateTimeFormatter.formatTimeToAMPM(timeOfDay);
final timeIcon = DateTimeFormatter.getTimeIcon(timeStr);
final appointmentTypeColor =
GetIconsColors.getAppointmentColor(bloodwork.appointmentType);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
contentPadding: AppTheme.standardCardPadding,
title: Row(
children: [
Flexible(
// Added Flexible to prevent overflow
child: Text(
DateTimeFormatter.formatDateMMMDDYYYY(bloodwork.date),
style: AppTextStyles.titleMedium,
overflow:
TextOverflow.ellipsis, // Added ellipsis for long dates
),
),
if (isFutureDate) ...[
SharedWidgets.horizontalSpace(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.info.withAlpha(20),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.info.withAlpha(40)),
),
child: Text(
'Scheduled',
style: TextStyle(
fontSize: 12,
color: AppColors.info,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SharedWidgets.verticalSpace(),
// Display appointment type
Row(
children: [
GetIconsColors.getAppointmentIconCirlce(
bloodwork.appointmentType),
SharedWidgets.horizontalSpace(6),
Flexible(
// Added Flexible to prevent overflow
child: Text(
GetLabels.getAppointmentTypeText(bloodwork.appointmentType),
style: AppTextStyles.bodyMedium.copyWith(
color: appointmentTypeColor,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis, // Added ellipsis
),
),
],
),
SharedWidgets.verticalSpace(4),
// Display appointment time with icon
Row(
children: [
Icon(
timeIcon,
size: 16,
color: appointmentTypeColor,
),
SharedWidgets.verticalSpace(6),
Flexible(
// Added Flexible to prevent overflow
child: Text(
timeStr,
style: AppTextStyles.bodyMedium.copyWith(
color: appointmentTypeColor,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis, // Added ellipsis
),
),
],
),
SharedWidgets.verticalSpace(4),
// Display location if available
if (bloodwork.location?.isNotEmpty == true) ...[
Row(
children: [
Icon(
AppIcons.getOutlined('location'),
size: 16,
color: AppTheme.grey,
),
SharedWidgets.verticalSpace(6),
Flexible(
child: Text(
bloodwork.location!,
style: AppTextStyles.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
SharedWidgets.verticalSpace(2),
],
// Display doctor if available
if (bloodwork.doctor?.isNotEmpty == true) ...[
Row(
children: [
Icon(
AppIcons.getIcon('profile'),
size: 16,
color: AppTheme.grey,
),
SharedWidgets.verticalSpace(6),
Flexible(
child: Text(
bloodwork.doctor!,
style: AppTextStyles.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
SharedWidgets.verticalSpace(2),
],
// If future date, show scheduled message
if (isFutureDate)
Text(
'Scheduled',
style: AppTextStyles.bodyMedium,
)
// Otherwise show hormone levels if bloodwork type
else if (bloodwork.appointmentType ==
AppointmentType.bloodwork) ...[
// Display hormone readings if available
if (bloodwork.hormoneReadings.isNotEmpty)
...bloodwork.hormoneReadings.take(2).map((reading) => Text(
'${reading.name}: ${reading.value.toStringAsFixed(1)} ${reading.unit}',
overflow: TextOverflow.ellipsis)), // Added ellipsis
// Show count if there are more readings
if (bloodwork.hormoneReadings.length > 2)
Text('...and ${bloodwork.hormoneReadings.length - 2} more',
style: AppTextStyles.bodySmall),
],
// Display notes if any
if (bloodwork.notes?.isNotEmpty == true) ...[
SharedWidgets.verticalSpace(),
Text(
'Notes: ${bloodwork.notes}',
style: AppTextStyles.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
onTap: () => NavigationService.goToBloodworkAddEdit(
context,
bloodwork: bloodwork,
),
trailing: Icon(AppIcons.getIcon('chevron_right')),
),
);
}
}

View file

@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// hormone_search_bar.dart
// Reusable search bar for hormone filtering
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
class HormoneSearchBar extends StatefulWidget {
final String hintText;
final Function(String) onSearch;
final bool autofocus;
final bool inAppBar;
const HormoneSearchBar({
super.key,
this.hintText = 'Search hormones...',
required this.onSearch,
this.autofocus = false,
this.inAppBar = false,
});
@override
State<HormoneSearchBar> createState() => _HormoneSearchBarState();
}
class _HormoneSearchBarState extends State<HormoneSearchBar> {
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: widget.inAppBar
? EdgeInsets.zero
: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: TextField(
controller: _controller,
autofocus: widget.autofocus,
decoration: InputDecoration(
hintText: widget.hintText,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
prefixIcon: Icon(Icons.search, color: AppColors.primary),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, color: AppColors.primary),
onPressed: () {
_controller.clear();
widget.onSearch('');
},
)
: null,
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(widget.inAppBar ? 20 : 20),
borderSide: BorderSide(color: AppColors.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(widget.inAppBar ? 20 : 20),
borderSide: BorderSide(color: AppColors.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(widget.inAppBar ? 20 : 20),
borderSide: BorderSide(color: AppColors.primary, width: 2),
),
),
style: AppTextStyles.bodyMedium,
onChanged: widget.onSearch,
),
);
}
}

View file

@ -0,0 +1,516 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication.dart
// Core model for medication data
//
import 'package:uuid/uuid.dart';
import 'package:nokken/src/core/constants/date_constants.dart';
import 'package:nokken/src/core/services/error/validation_service.dart';
/// Types of medications supported
enum MedicationType { oral, injection, topical, patch }
/// Subtypes for oral medications
enum OralSubtype { tablets, capsules, drops }
/// Subtypes for topical medications
enum TopicalSubtype { gel, cream, spray }
/// Subtypes for injection medications
enum InjectionSubtype { intravenous, intramuscular, subcutaneous }
/// Frequency options for injectable medications
enum InjectionFrequency { weekly, biweekly }
/// Types of body areas for injection sites
enum InjectionBodyArea { abdomen, thigh }
/// Tracks an individual injection site
class InjectionSite {
final int siteNumber;
final InjectionBodyArea bodyArea;
InjectionSite({
required this.siteNumber,
required this.bodyArea,
});
/// Convert to JSON for storage
Map<String, dynamic> toJson() => {
'siteNumber': siteNumber,
'bodyArea': bodyArea.toString(),
};
/// Create from JSON
factory InjectionSite.fromJson(Map<String, dynamic> json) {
return InjectionSite(
siteNumber: json['siteNumber'] as int,
bodyArea: InjectionBodyArea.values.firstWhere(
(e) => e.toString() == json['bodyArea'],
orElse: () => InjectionBodyArea.abdomen,
),
);
}
}
/// Manages rotation of injection sites
class InjectionSiteRotation {
final List<InjectionSite> sites;
final int currentSiteIndex; // Index in the sites list
InjectionSiteRotation({
required this.sites,
this.currentSiteIndex = 0,
});
/// Get the next site in the rotation
InjectionSite get nextSite {
if (sites.isEmpty) {
// Default to first abdominal site if no sites are defined
return InjectionSite(siteNumber: 1, bodyArea: InjectionBodyArea.abdomen);
}
return sites[currentSiteIndex];
}
/// Advance to the next site in the rotation
InjectionSiteRotation advance() {
if (sites.isEmpty) return this;
final nextIndex = (currentSiteIndex + 1) % sites.length;
return InjectionSiteRotation(
sites: sites,
currentSiteIndex: nextIndex,
);
}
/// Convert to JSON for storage
Map<String, dynamic> toJson() => {
'sites': sites.map((site) => site.toJson()).toList(),
'currentSiteIndex': currentSiteIndex,
};
/// Create from JSON
factory InjectionSiteRotation.fromJson(Map<String, dynamic> json) {
final sitesList = (json['sites'] as List?)
?.map((siteJson) =>
InjectionSite.fromJson(siteJson as Map<String, dynamic>))
.toList() ??
[];
return InjectionSiteRotation(
sites: sitesList,
currentSiteIndex: json['currentSiteIndex'] as int? ?? 0,
);
}
}
/// Details specific to injectable medications
class InjectionDetails {
final String drawingNeedleType;
final int drawingNeedleCount;
final int drawingNeedleRefills;
final String injectingNeedleType;
final int injectingNeedleCount;
final int injectingNeedleRefills;
final String syringeType;
final int syringeCount;
final int syringeRefills;
final String injectionSiteNotes;
final InjectionFrequency frequency;
final InjectionSubtype subtype;
final InjectionSiteRotation? siteRotation; // New field for site rotation
InjectionDetails({
required this.drawingNeedleType,
required this.drawingNeedleCount,
required this.drawingNeedleRefills,
required this.injectingNeedleType,
required this.injectingNeedleCount,
required this.injectingNeedleRefills,
required this.syringeType,
required this.syringeCount,
required this.syringeRefills,
required this.injectionSiteNotes,
required this.frequency,
required this.subtype,
this.siteRotation, // Optional parameter for site rotation
});
/// Convert to JSON for database storage
Map<String, dynamic> toJson() {
final json = {
'drawingNeedleType': drawingNeedleType,
'drawingNeedleCount': drawingNeedleCount,
'drawingNeedleRefills': drawingNeedleRefills,
'injectingNeedleType': injectingNeedleType,
'injectingNeedleCount': injectingNeedleCount,
'injectingNeedleRefills': injectingNeedleRefills,
'syringeType': syringeType,
'syringeCount': syringeCount,
'syringeRefills': syringeRefills,
'injectionSiteNotes': injectionSiteNotes,
'frequency': frequency.toString(),
'subtype': subtype.toString(),
};
// Add site rotation if available
if (siteRotation != null) {
json['siteRotation'] = siteRotation!.toJson();
}
return json;
}
/// Create InjectionDetails from JSON (database record)
factory InjectionDetails.fromJson(Map<String, dynamic> json) {
// Parse site rotation if available
InjectionSiteRotation? siteRotation;
if (json['siteRotation'] != null) {
try {
siteRotation = InjectionSiteRotation.fromJson(
json['siteRotation'] as Map<String, dynamic>);
} catch (e) {
// Handle parsing error
}
}
return InjectionDetails(
drawingNeedleType: json['drawingNeedleType'],
drawingNeedleCount: json['drawingNeedleCount'],
drawingNeedleRefills: json['drawingNeedleRefills'],
injectingNeedleType: json['injectingNeedleType'],
injectingNeedleCount: json['injectingNeedleCount'],
injectingNeedleRefills: json['injectingNeedleRefills'],
syringeType: json['syringeType'] ?? '',
syringeCount: json['syringeCount'] ?? 0,
syringeRefills: json['syringeRefills'] ?? 0,
injectionSiteNotes: json['injectionSiteNotes'],
frequency: InjectionFrequency.values.firstWhere(
(e) => e.toString() == json['frequency'],
orElse: () => InjectionFrequency.weekly),
subtype: InjectionSubtype.values.firstWhere(
(e) => e.toString() == json['subtype'],
orElse: () => InjectionSubtype.intramuscular),
siteRotation: siteRotation,
);
}
/// Create a copy with updated fields
InjectionDetails copyWith({
String? drawingNeedleType,
int? drawingNeedleCount,
int? drawingNeedleRefills,
String? injectingNeedleType,
int? injectingNeedleCount,
int? injectingNeedleRefills,
String? syringeType,
int? syringeCount,
int? syringeRefills,
String? injectionSiteNotes,
InjectionFrequency? frequency,
InjectionSubtype? subtype,
InjectionSiteRotation? siteRotation,
}) {
return InjectionDetails(
drawingNeedleType: drawingNeedleType ?? this.drawingNeedleType,
drawingNeedleCount: drawingNeedleCount ?? this.drawingNeedleCount,
drawingNeedleRefills: drawingNeedleRefills ?? this.drawingNeedleRefills,
injectingNeedleType: injectingNeedleType ?? this.injectingNeedleType,
injectingNeedleCount: injectingNeedleCount ?? this.injectingNeedleCount,
injectingNeedleRefills:
injectingNeedleRefills ?? this.injectingNeedleRefills,
syringeType: syringeType ?? this.syringeType,
syringeCount: syringeCount ?? this.syringeCount,
syringeRefills: syringeRefills ?? this.syringeRefills,
injectionSiteNotes: injectionSiteNotes ?? this.injectionSiteNotes,
frequency: frequency ?? this.frequency,
subtype: subtype ?? this.subtype,
siteRotation: siteRotation ?? this.siteRotation,
);
}
}
/// Custom exception for medication-related errors
class MedicationException implements Exception {
final String message;
MedicationException(this.message);
@override
String toString() => 'MedicationException: $message';
}
/// Primary model for medication data
class Medication {
final String id;
final String name;
final String dosage;
final DateTime startDate;
final int frequency; // Times per day
final List<DateTime> timeOfDay; // Specific times for each dose
final Set<String> daysOfWeek;
final int currentQuantity;
final int refillThreshold;
final String? notes;
final MedicationType medicationType;
final InjectionDetails?
injectionDetails; // null for non-injection medications
final OralSubtype? oralSubtype; // null for non-oral medications
final TopicalSubtype? topicalSubtype; // null for non-topical medications
final String? doctor; // Optional doctor name
final String? pharmacy; // Optional pharmacy name
final bool asNeeded; // Flag for take-as-needed medications
/// Constructor with validation
Medication({
String? id,
required this.name,
required this.dosage,
required this.startDate,
required this.frequency,
required this.timeOfDay,
required this.daysOfWeek,
required this.currentQuantity,
required this.refillThreshold,
required this.medicationType,
this.injectionDetails,
this.oralSubtype,
this.topicalSubtype,
this.notes,
this.doctor,
this.pharmacy,
this.asNeeded = false, // Default to scheduled (not as-needed)
}) : id = id ?? const Uuid().v4() {
_validate();
}
/// Validates medication fields
void _validate() {
final nameResult = ValidationService.validateMedicationName(name);
if (nameResult.hasError) {
throw MedicationException(nameResult.message!);
}
final dosageResult = ValidationService.validateMedicationDosage(dosage);
if (dosageResult.hasError) {
throw MedicationException(dosageResult.message!);
}
// Skip frequency and time validation for as-needed medications
if (!asNeeded) {
final frequencyResult = ValidationService.validateFrequency(frequency);
if (frequencyResult.hasError) {
throw MedicationException(frequencyResult.message!);
}
final timeResult =
ValidationService.validateTimeOfDay(timeOfDay, frequency);
if (timeResult.hasError) {
throw MedicationException(timeResult.message!);
}
final daysResult = ValidationService.validateDaysOfWeek(daysOfWeek);
if (daysResult.hasError) {
throw MedicationException(daysResult.message!);
}
}
final quantityResult = ValidationService.validateQuantity(currentQuantity);
if (quantityResult.hasError) {
throw MedicationException(quantityResult.message!);
}
final injectionResult = ValidationService.validateInjectionDetails(
medicationType, injectionDetails, frequency);
if (injectionResult.hasError) {
throw MedicationException(injectionResult.message!);
}
// Validate subtypes match medication type
if (medicationType == MedicationType.oral && oralSubtype == null) {
throw MedicationException('Oral subtype required for oral medications');
}
if (medicationType == MedicationType.topical && topicalSubtype == null) {
throw MedicationException(
'Topical subtype required for topical medications');
}
if (medicationType != MedicationType.oral && oralSubtype != null) {
throw MedicationException(
'Oral subtype should only be set for oral medications');
}
if (medicationType != MedicationType.topical && topicalSubtype != null) {
throw MedicationException(
'Topical subtype should only be set for topical medications');
}
}
/// Convert to JSON format for database storage
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name.trim(),
'dosage': dosage.trim(),
'startDate': startDate.toIso8601String(),
'frequency': frequency,
'timeOfDay': timeOfDay.map((t) => t.toIso8601String()).toList(),
'daysOfWeek': daysOfWeek.toList(),
'currentQuantity': currentQuantity,
'refillThreshold': refillThreshold,
'notes': notes?.trim(),
'medicationType': medicationType.toString(),
'doctor': doctor?.trim(),
'pharmacy': pharmacy?.trim(),
'oralSubtype': oralSubtype?.toString(),
'topicalSubtype': topicalSubtype?.toString(),
'asNeeded': asNeeded, // Add asNeeded field to JSON
if (injectionDetails != null)
'injectionDetails': injectionDetails!.toJson(),
};
}
/// Create a Medication instance from JSON (database record)
factory Medication.fromJson(Map<String, dynamic> json) {
try {
// Handle medication type
final medicationType = MedicationType.values.firstWhere(
(e) => e.toString() == json['medicationType'],
orElse: () => MedicationType.oral,
);
// Handle oral subtype if present
OralSubtype? oralSubtype;
if (json['oralSubtype'] != null) {
try {
oralSubtype = OralSubtype.values.firstWhere(
(e) => e.toString() == json['oralSubtype'],
);
} catch (_) {
// If parsing fails, default to tablets
oralSubtype = OralSubtype.tablets;
}
}
// Handle topical subtype if present
TopicalSubtype? topicalSubtype;
if (json['topicalSubtype'] != null) {
try {
topicalSubtype = TopicalSubtype.values.firstWhere(
(e) => e.toString() == json['topicalSubtype'],
);
} catch (_) {
// If parsing fails, default to gel
topicalSubtype = TopicalSubtype.gel;
}
}
return Medication(
id: json['id'] as String,
name: json['name'] as String,
dosage: json['dosage'] as String,
startDate: DateTime.parse(json['startDate']),
frequency: json['frequency'] as int,
timeOfDay: (json['timeOfDay'] as List)
.map((t) => DateTime.parse(t as String))
.toList(),
daysOfWeek: Set<String>.from(json['daysOfWeek'] as List),
currentQuantity: json['currentQuantity'] as int,
refillThreshold: json['refillThreshold'] as int,
notes: json['notes'] as String?,
medicationType: medicationType,
doctor: json['doctor'] as String?,
pharmacy: json['pharmacy'] as String?,
oralSubtype: oralSubtype,
topicalSubtype: topicalSubtype,
asNeeded: json['asNeeded'] as bool? ??
false, // Add asNeeded field from JSON with default
injectionDetails: json['injectionDetails'] != null
? InjectionDetails.fromJson(json['injectionDetails'])
: null,
);
} catch (e) {
throw MedicationException('Invalid medication data: $e');
}
}
/// Create a copy of this medication with updated fields
Medication copyWith({
String? name,
String? dosage,
int? frequency,
DateTime? startDate,
List<DateTime>? timeOfDay,
Set<String>? daysOfWeek,
int? currentQuantity,
int? refillThreshold,
String? notes,
MedicationType? medicationType,
InjectionDetails? injectionDetails,
OralSubtype? oralSubtype,
TopicalSubtype? topicalSubtype,
String? doctor,
String? pharmacy,
bool? asNeeded, // Add asNeeded to copyWith
}) {
return Medication(
id: id,
name: name ?? this.name,
dosage: dosage ?? this.dosage,
startDate: startDate ?? this.startDate,
frequency: frequency ?? this.frequency,
timeOfDay: timeOfDay ?? this.timeOfDay,
daysOfWeek: daysOfWeek ?? this.daysOfWeek,
currentQuantity: currentQuantity ?? this.currentQuantity,
refillThreshold: refillThreshold ?? this.refillThreshold,
notes: notes ?? this.notes,
medicationType: medicationType ?? this.medicationType,
injectionDetails: injectionDetails ?? this.injectionDetails,
oralSubtype: oralSubtype ?? this.oralSubtype,
topicalSubtype: topicalSubtype ?? this.topicalSubtype,
doctor: doctor ?? this.doctor,
pharmacy: pharmacy ?? this.pharmacy,
asNeeded: asNeeded ?? this.asNeeded, // Add asNeeded parameter
);
}
/// Check if this medication is due on the specified date
bool isDueOnDate(DateTime date) {
// As-needed medications are not scheduled for specific dates
if (asNeeded) {
return false;
}
// Normalize dates to remove time component
final normalizedDate = DateTime(date.year, date.month, date.day);
final startDate =
DateTime(this.startDate.year, this.startDate.month, this.startDate.day);
// Basic date validation - not scheduled before start date
if (normalizedDate.isBefore(startDate)) {
return false;
}
// Check day of week
final dayAbbr = DateConstants.dayMap[date.weekday] ?? '';
if (!daysOfWeek.contains(dayAbbr)) {
return false;
}
// Handle biweekly injections
if (medicationType == MedicationType.injection &&
injectionDetails?.frequency == InjectionFrequency.biweekly) {
// Calculate weeks since start
final daysSince = normalizedDate.difference(startDate).inDays;
final weeksSince = daysSince ~/ 7;
// Is this an "on" week or "off" week?
return weeksSince % 2 == 0;
}
return true;
}
/// Check if medication needs to be refilled based on current quantity and threshold
bool needsRefill() => currentQuantity < refillThreshold;
}

View file

@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication_dose.dart
// Model representing a specific dose of medication on a specific date and time
//
class MedicationDose {
final String medicationId;
final DateTime date;
final String timeSlot;
/// Create a medication dose with normalized date (no time component)
MedicationDose(
{required this.medicationId,
required DateTime date,
required this.timeSlot})
: date = DateTime(date.year, date.month, date.day);
/// Override equality for proper comparison in Sets and Maps
@override
bool operator ==(Object other) =>
other is MedicationDose &&
other.medicationId == medicationId &&
other.date.year == date.year &&
other.date.month == date.month &&
other.date.day == date.day &&
other.timeSlot == timeSlot;
@override
int get hashCode => Object.hash(
medicationId, DateTime(date.year, date.month, date.day), timeSlot);
/// Convert to string key format used in database
String toKey() => '$medicationId-${date.toIso8601String()}-$timeSlot';
/// Get a unique key that includes an instance index
String toKeyWithIndex(int index) =>
'$medicationId-${date.toIso8601String()}-$timeSlot-$index';
/// Create a dose object from a string key
static MedicationDose fromKey(String key) {
// Check if this is a key with an index
final hasIndex = key.split('-').length > 3;
if (hasIndex) {
// Handle keys with indexes by removing the index part
final lastDashIndex = key.lastIndexOf('-');
return fromKey(key.substring(0, lastDashIndex));
}
// Handle medication IDs that may contain hyphens
final dateAndTimeStart = key.lastIndexOf('-', key.lastIndexOf('-') - 1);
final id = key.substring(0, dateAndTimeStart);
final remaining = key.substring(dateAndTimeStart + 1);
final remainingParts = remaining.split('-');
final dateStr = remainingParts[0];
final timeSlot = remainingParts[1];
return MedicationDose(
medicationId: id, date: DateTime.parse(dateStr), timeSlot: timeSlot);
}
/// Extract the index from a key with index
static int? getIndexFromKey(String key) {
final parts = key.split('-');
if (parts.length <= 3) {
return null;
}
return int.tryParse(parts.last);
}
}

View file

@ -0,0 +1,190 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// as_needed_medication_provider.dart
// Providers for convenient access to as-needed medications
//
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/utils/list_extensions.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/medication_tracker/models/medication_dose.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_providers.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
/// Provider for medications marked as "take as needed"
final asNeededMedicationsProvider = Provider<List<Medication>>((ref) {
final allMedications = ref.watch(medicationsProvider);
final result = allMedications.where((med) => med.asNeeded).toList();
print("Debug - Found ${result.length} as-needed medications");
return result;
});
/// Provider for checking if an individual medication is configured for as-needed use
final isAsNeededMedicationProvider =
Provider.family<bool, String>((ref, medicationId) {
final allMedications = ref.watch(medicationsProvider);
final medication = allMedications.firstWhereOrNull(
(med) => med.id == medicationId,
);
return medication?.asNeeded ?? false;
});
/// Model for as-needed doses taken on a specific day
class AsNeededDose {
final Medication medication;
final DateTime date;
final String timeSlot;
final String key; // Original key from database for deletion
AsNeededDose({
required this.medication,
required this.date,
required this.timeSlot,
required this.key,
});
}
/// Provider for as-needed doses taken on a specific date
final asNeededDosesForDateProvider =
Provider.family<List<AsNeededDose>, DateTime>((ref, date) {
final takenMedications = ref.watch(medicationTakenProvider);
final allMedications = ref.watch(medicationsProvider);
final asNeededMedications =
allMedications.where((med) => med.asNeeded).toList();
print(
"DEBUG: Found ${asNeededMedications.length} medications marked as as-needed");
print("DEBUG: Taken medication keys: ${takenMedications.length} keys");
if (takenMedications.isNotEmpty) {
print("DEBUG: Sample key: ${takenMedications.first}");
}
final List<AsNeededDose> asNeededDoses = [];
// Extract as-needed doses from taken medications
for (final key in takenMedications) {
print("DEBUG: Processing key: $key");
try {
// Skip keys that don't look like our format
if (!key.contains('-')) {
print("DEBUG: Key doesn't contain hyphen: $key");
continue;
}
// Find the ISO date part (contains 'T' character)
final isoDateRegex = RegExp(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}');
final match = isoDateRegex.firstMatch(key);
if (match == null) {
print("DEBUG: No ISO date found in key: $key");
continue;
}
// Get the matched date string and its position
final dateStr = match.group(0)!;
final dateStartIndex = match.start;
// Everything before the date is the medication ID
final medicationId =
key.substring(0, dateStartIndex - 1); // -1 to remove the hyphen
print("DEBUG: Extracted medicationId: $medicationId");
// Find the timeSlot (after the date, before the timestamp)
final afterDateStr = key.substring(dateStartIndex + dateStr.length + 1);
final timeSlotEndIndex = afterDateStr.lastIndexOf('-');
if (timeSlotEndIndex == -1) {
print("DEBUG: No timestamp found after timeSlot: $afterDateStr");
continue;
}
final timeSlot = afterDateStr.substring(0, timeSlotEndIndex);
print("DEBUG: Extracted timeSlot: $timeSlot");
// Find the medication
final medication = asNeededMedications.firstWhereOrNull(
(med) => med.id == medicationId,
);
if (medication == null) {
print("DEBUG: No matching medication found for ID: $medicationId");
continue;
}
print("DEBUG: Found medication: ${medication.name}");
// Parse the date from the ISO string
final doseDate = DateTime.parse(dateStr);
print("DEBUG: Comparing dates - dose: $doseDate, selected: $date");
// Compare just the date part
if (doseDate.year == date.year &&
doseDate.month == date.month &&
doseDate.day == date.day) {
print("DEBUG: Date match found! Adding to list");
// Create an as-needed dose entry
asNeededDoses.add(AsNeededDose(
medication: medication,
date: doseDate,
timeSlot: timeSlot,
key: key, // Store the original key for deletion
));
} else {
print("DEBUG: Date mismatch - dose: $doseDate, selected: $date");
}
} catch (e) {
// Skip this entry if parsing fails
print("DEBUG: Error parsing key $key: $e");
continue;
}
}
print("DEBUG: Final asNeededDoses count: ${asNeededDoses.length}");
// Sort the as-needed doses by time
asNeededDoses.sort(
(a, b) => DateTimeFormatter.compareTimeSlots(a.timeSlot, b.timeSlot));
return asNeededDoses;
});
/// Function to parse an as-needed dose key to get the medication dose
MedicationDose? parseDoseFromKey(String key) {
try {
// Use the same regex approach as in the provider for consistency
final isoDateRegex = RegExp(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}');
final match = isoDateRegex.firstMatch(key);
if (match == null) return null;
// Get the matched date string and its position
final dateStr = match.group(0)!;
final dateStartIndex = match.start;
// Everything before the date is the medication ID
final medicationId =
key.substring(0, dateStartIndex - 1); // -1 to remove the hyphen
// Find the timeSlot (after the date, before the timestamp)
final afterDateStr = key.substring(dateStartIndex + dateStr.length + 1);
final timeSlotEndIndex = afterDateStr.lastIndexOf('-');
if (timeSlotEndIndex == -1) return null;
final timeSlot = afterDateStr.substring(0, timeSlotEndIndex);
// Parse date
final date = DateTime.parse(dateStr);
// Create a dose object
return MedicationDose(
medicationId: medicationId,
date: date,
timeSlot: timeSlot,
);
} catch (e) {
return null;
}
}

View file

@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication_providers.dart
// Export file for all providers
//
export 'medication_state.dart';
export 'medication_taken_provider.dart';

View file

@ -0,0 +1,269 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication_state.dart
// State management for medications using Riverpod
//
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
import 'package:nokken/src/core/services/notifications/notification_service.dart';
/// State class to handle loading and error states for medication data
class MedicationState {
final List<Medication> medications;
final bool isLoading;
final String? error;
const MedicationState({
this.medications = const [],
this.isLoading = false,
this.error,
});
/// Create a new state object with updated fields
MedicationState copyWith({
List<Medication>? medications,
bool? isLoading,
String? error,
}) {
return MedicationState(
medications: medications ?? this.medications,
isLoading: isLoading ?? this.isLoading,
error: error, // Pass null to clear error
);
}
}
/// Notifier class to handle medication state changes
/// Manages interactions with database and notification services
class MedicationNotifier extends StateNotifier<MedicationState> {
final DatabaseService _databaseService;
final NotificationService _notificationService;
MedicationNotifier({
required DatabaseService databaseService,
required NotificationService notificationService,
}) : _databaseService = databaseService,
_notificationService = notificationService,
super(const MedicationState()) {
// Load medications when initialized
loadMedications();
}
/// Load medications from the database
Future<void> loadMedications() async {
try {
state = state.copyWith(isLoading: true, error: null);
final medications = await _databaseService.getAllMedications();
state = state.copyWith(
medications: medications,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to load medications: $e',
);
}
}
/// Add a new medication to the database and schedule reminders
Future<void> addMedication(Medication medication) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Save to database
await _databaseService.insertMedication(medication);
// Schedule notifications
await _notificationService.scheduleMedicationReminders(medication);
// Update state immediately with new medication
state = state.copyWith(
medications: [...state.medications, medication],
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to add medication: $e',
);
}
}
/// Update an existing medication in the database and reschedule reminders
Future<void> updateMedication(Medication medication) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Update in database
await _databaseService.updateMedication(medication);
// Reschedule notifications
await _notificationService.scheduleMedicationReminders(medication);
// Update state immediately with new medication
state = state.copyWith(
medications: state.medications
.map((med) => med.id == medication.id ? medication : med)
.toList(),
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to update medication: $e',
);
}
}
/// Update the quantity of a medication (when taken or reverting a taken status)
Future<void> updateMedicationQuantity(
Medication medication, bool taken) async {
try {
// Calculate new quantity (decrement if taken, increment if untaken)
final updatedMed = medication.copyWith(
currentQuantity: medication.currentQuantity + (taken ? -1 : 1),
);
await _databaseService.updateMedication(updatedMed);
state = state.copyWith(
medications: state.medications.map((med) {
return med.id == medication.id ? updatedMed : med;
}).toList(),
);
} catch (e) {
state = state.copyWith(error: 'Failed to update medication quantity');
}
}
/// Delete a medication and cancel its reminders
Future<void> deleteMedication(String id) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Cancel notifications first
await _notificationService.cancelMedicationReminders(id);
// Delete from database
await _databaseService.deleteMedication(id);
// Update state immediately by filtering out the deleted medication
state = state.copyWith(
medications: state.medications.where((med) => med.id != id).toList(),
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to delete medication: $e',
);
}
}
/// Helper method to find medication by ID
Medication? getMedicationById(String id) {
try {
return state.medications.firstWhere((med) => med.id == id);
} catch (e) {
return null;
}
}
}
//----------------------------------------------------------------------------
// PROVIDER DEFINITIONS
//----------------------------------------------------------------------------
/// Provider for database service
final databaseServiceProvider = Provider<DatabaseService>((ref) {
return DatabaseService();
});
/// Provider for notification service
final notificationServiceProvider = Provider<NotificationService>((ref) {
return NotificationService();
});
/// Main state notifier provider for medications
final medicationStateProvider =
StateNotifierProvider<MedicationNotifier, MedicationState>((ref) {
final databaseService = ref.watch(databaseServiceProvider);
final notificationService = ref.watch(notificationServiceProvider);
return MedicationNotifier(
databaseService: databaseService,
notificationService: notificationService,
);
});
//----------------------------------------------------------------------------
// CONVENIENCE PROVIDERS
//----------------------------------------------------------------------------
/// Provider for accessing the list of medications
final medicationsProvider = Provider<List<Medication>>((ref) {
return ref.watch(medicationStateProvider).medications;
});
/// Provider for checking if medications are loading
final medicationsLoadingProvider = Provider<bool>((ref) {
return ref.watch(medicationStateProvider).isLoading;
});
/// Provider for accessing medication loading errors
final medicationsErrorProvider = Provider<String?>((ref) {
return ref.watch(medicationStateProvider).error;
});
/// Provider for medications that need to be refilled
final medicationsByNeedRefillProvider = Provider<List<Medication>>((ref) {
return ref
.watch(medicationStateProvider)
.medications
.where((med) => med.needsRefill())
.toList();
});
/// Provider for sorted medications (by name)
final sortedMedicationsProvider = Provider<List<Medication>>((ref) {
final medications = ref.watch(medicationStateProvider).medications;
return [...medications]..sort((a, b) => a.name.compareTo(b.name));
});
/// Provider that groups medications by type (oral vs injection)
/// Provider that groups medications by type (all types)
final groupedMedicationTypeProvider =
Provider<Map<String, List<Medication>>>((ref) {
final medications = ref.watch(sortedMedicationsProvider);
// Initialize the result map with empty lists for all types
final result = {
'oral': <Medication>[],
'injection': <Medication>[],
'topical': <Medication>[],
'patch': <Medication>[],
};
// Categorize each medication by type
for (final medication in medications) {
switch (medication.medicationType) {
case MedicationType.oral:
result['oral']!.add(medication);
break;
case MedicationType.injection:
result['injection']!.add(medication);
break;
case MedicationType.topical:
result['topical']!.add(medication);
break;
case MedicationType.patch:
result['patch']!.add(medication);
break;
}
}
return result;
});

View file

@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication_taken_provider.dart
// Provider to manage taken medications with database persistence
//
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/medication_tracker/models/medication_dose.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/features/scheduler/services/medication_schedule_service.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
/// Notifier for tracking which medications have been taken
class MedicationTakenNotifier extends StateNotifier<Set<String>> {
final DatabaseService _databaseService;
MedicationTakenNotifier({required DatabaseService databaseService})
: _databaseService = databaseService,
super({});
/// Load taken medications for a specific date
Future<void> loadTakenMedicationsForDate(DateTime date) async {
try {
final takenMeds = await _databaseService.getTakenMedicationsForDate(date);
state = takenMeds;
} catch (e) {
// Handle error - perhaps log it, but continue with empty set
state = {};
}
}
/// Set a medication as taken or not taken
///
/// @param dose The medication dose being taken or untaken
/// @param taken Whether the medication is being marked as taken (true) or untaken (false)
/// @param customKey An optional custom key to use for the database.
/// Used for handling multiple doses of the same medication at the same time slot
Future<void> setMedicationTaken(MedicationDose dose, bool taken,
{String? customKey}) async {
// Use the provided custom key or generate a standard one
final key = customKey ?? dose.toKey();
try {
// Update database using the dose details but saving with the custom key if provided
await _databaseService.setMedicationTakenWithCustomKey(
dose.medicationId, dose.date, dose.timeSlot, taken, customKey);
// Update state
if (taken) {
state = {...state, key};
} else {
state = {...state}..remove(key);
}
} catch (e) {
// If database update fails, don't update state
// Could add error handling here
}
}
/// Clear expired data (optional maintenance function)
Future<void> clearOldData(int olderThanDays) async {
final cutoffDate = DateTime.now().subtract(Duration(days: olderThanDays));
try {
await _databaseService.deleteTakenMedicationsOlderThan(cutoffDate);
// No need to update state since we don't track old data in memory
} catch (e) {
// Handle error
}
}
}
//----------------------------------------------------------------------------
// PROVIDER DEFINITIONS
//----------------------------------------------------------------------------
/// Provider for tracking which medications have been taken
final medicationTakenProvider =
StateNotifierProvider<MedicationTakenNotifier, Set<String>>((ref) {
final databaseService = ref.watch(databaseServiceProvider);
return MedicationTakenNotifier(databaseService: databaseService);
});
/// Provider to get medications scheduled for a specific date
final medicationsForDateProvider =
Provider.family<List<Medication>, DateTime>((ref, date) {
final allMedications = ref.watch(medicationsProvider);
return MedicationScheduleService.getMedicationsForDate(allMedications, date);
});
/// Provider to get medication doses scheduled for a specific date
final dosesForDateProvider =
Provider.family<List<MedicationDose>, DateTime>((ref, date) {
final medications = ref.watch(medicationsForDateProvider(date));
return MedicationScheduleService.getDosesForDate(medications, date);
});
/// Provider to check if a specific medication dose is taken
final isDoseTakenProvider = Provider.family<bool, MedicationDose>((ref, dose) {
final takenMedications = ref.watch(medicationTakenProvider);
return takenMedications.contains(dose.toKey());
});
/// Provider that groups medications by time slot for a specific date
final medicationsByTimeSlotProvider =
Provider.family<Map<String, List<Medication>>, DateTime>((ref, date) {
final medications = ref.watch(medicationsForDateProvider(date));
return MedicationScheduleService.groupMedicationsByTimeSlot(medications);
});

View file

@ -0,0 +1,732 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_medication_screen.dart
// Screen that handles both adding new medications and editing existing ones
// Uses a form with multiple sections for different medication properties
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/core/ui/widgets/dialog_service.dart';
import 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_widgets.dart';
class AddEditMedicationScreen extends ConsumerStatefulWidget {
final Medication? medication;
const AddEditMedicationScreen({
super.key,
this.medication,
});
@override
ConsumerState<AddEditMedicationScreen> createState() =>
_AddEditMedicationScreenState();
}
class _AddEditMedicationScreenState
extends ConsumerState<AddEditMedicationScreen> {
final _formKey = GlobalKey<FormState>();
// Controllers that need disposal
late final TextEditingController _nameController;
late final TextEditingController _dosageController;
late final TextEditingController _notesController;
late final TextEditingController _doctorController;
late final TextEditingController _pharmacyController;
late bool _asNeeded;
// State variables
late MedicationType _medicationType;
late OralSubtype? _oralSubtype;
late TopicalSubtype? _topicalSubtype;
late DateTime _startDate;
late int _frequency;
late List<TimeOfDay> _times;
late Set<String> _selectedDays;
late int _currentQuantity;
late int _refillThreshold;
late InjectionDetails? _injectionDetails;
bool _isLoading = false;
@override
void initState() {
super.initState();
_initializeFields();
}
/// Initialize all form fields with either existing medication data or defaults
void _initializeFields() {
// Initialize controllers
_nameController =
TextEditingController(text: widget.medication?.name ?? '');
_dosageController =
TextEditingController(text: widget.medication?.dosage ?? '');
_notesController =
TextEditingController(text: widget.medication?.notes ?? '');
_doctorController =
TextEditingController(text: widget.medication?.doctor ?? '');
_pharmacyController =
TextEditingController(text: widget.medication?.pharmacy ?? '');
// Initialize medication type and related fields
_medicationType = widget.medication?.medicationType ?? MedicationType.oral;
_oralSubtype = widget.medication?.oralSubtype ?? OralSubtype.tablets;
_topicalSubtype = widget.medication?.topicalSubtype ?? TopicalSubtype.gel;
_injectionDetails = _initializeInjectionDetails();
// Initialize schedule related fields
_startDate = widget.medication?.startDate ?? DateTime.now();
_frequency = widget.medication?.frequency ?? 1;
_times = _initializeTimes();
_selectedDays = widget.medication?.daysOfWeek ??
<String>{'Su', 'M', 'T', 'W', 'Th', 'F', 'Sa'};
// Initialize as-needed flag
_asNeeded = widget.medication?.asNeeded ?? false;
// Initialize inventory related fields
_currentQuantity = widget.medication?.currentQuantity ?? 0;
_refillThreshold = widget.medication?.refillThreshold ?? 0;
}
/// Initialize time slots from existing medication or default to current time
List<TimeOfDay> _initializeTimes() {
if (widget.medication != null) {
return widget.medication!.timeOfDay
.map((dt) => TimeOfDay.fromDateTime(dt))
.toList();
}
return [TimeOfDay.now()];
}
/// Initialize injection details if applicable
InjectionDetails? _initializeInjectionDetails() {
if (_medicationType == MedicationType.injection) {
return widget.medication?.injectionDetails ??
InjectionDetails(
drawingNeedleType: '',
drawingNeedleCount: 0,
drawingNeedleRefills: 0,
injectingNeedleType: '',
injectingNeedleCount: 0,
injectingNeedleRefills: 0,
syringeType: '',
syringeCount: 0,
syringeRefills: 0,
injectionSiteNotes: '',
frequency: InjectionFrequency.weekly,
subtype: InjectionSubtype.intramuscular,
);
}
return null;
}
void _handleAsNeededToggle(bool value) {
setState(() {
_asNeeded = value;
// If switching to scheduled mode, ensure we have enough time slots
if (!value && (_times.isEmpty || _times.length != _frequency)) {
// Initialize with correct number of time slots
_times = List.generate(
_frequency,
(_) => TimeOfDay.now(),
);
}
});
}
/// Handle medication type change (oral/injection/topical/patch)
void _handleTypeChange(MedicationType type) {
setState(() {
_medicationType = type;
// Reset subtypes based on selected type
if (type == MedicationType.oral) {
_oralSubtype = OralSubtype.tablets;
_topicalSubtype = null;
_injectionDetails = null;
_frequency = 1;
_selectedDays = {'Su', 'M', 'T', 'W', 'Th', 'F', 'Sa'};
} else if (type == MedicationType.injection) {
_oralSubtype = null;
_topicalSubtype = null;
_frequency = 1;
_selectedDays = {'Su'};
_injectionDetails ??= _initializeInjectionDetails();
} else if (type == MedicationType.topical) {
_oralSubtype = null;
_topicalSubtype = TopicalSubtype.gel;
_injectionDetails = null;
_frequency = 1;
_selectedDays = {'Su', 'M', 'T', 'W', 'Th', 'F', 'Sa'};
} else if (type == MedicationType.patch) {
_oralSubtype = null;
_topicalSubtype = null;
_injectionDetails = null;
_frequency = 1;
_selectedDays = {'Su', 'M', 'T', 'W', 'Th', 'F', 'Sa'};
}
});
}
// Clean up controllers to prevent memory leaks
@override
void dispose() {
_nameController.dispose();
_dosageController.dispose();
_notesController.dispose();
_doctorController.dispose();
_pharmacyController.dispose();
super.dispose();
}
/// Update injection details when fields change
void _updateInjectionDetails({
String? drawingNeedleType,
int? drawingNeedleCount,
int? drawingNeedleRefills,
String? injectingNeedleType,
int? injectingNeedleCount,
int? injectingNeedleRefills,
String? syringeType,
int? syringeCount,
int? syringeRefills,
String? injectionSiteNotes,
InjectionFrequency? frequency,
InjectionSubtype? subtype,
InjectionSiteRotation? siteRotation, // Added parameter
}) {
if (_injectionDetails == null) return;
setState(() {
_injectionDetails = InjectionDetails(
drawingNeedleType:
drawingNeedleType ?? _injectionDetails!.drawingNeedleType,
drawingNeedleCount:
drawingNeedleCount ?? _injectionDetails!.drawingNeedleCount,
drawingNeedleRefills:
drawingNeedleRefills ?? _injectionDetails!.drawingNeedleRefills,
injectingNeedleType:
injectingNeedleType ?? _injectionDetails!.injectingNeedleType,
injectingNeedleCount:
injectingNeedleCount ?? _injectionDetails!.injectingNeedleCount,
injectingNeedleRefills:
injectingNeedleRefills ?? _injectionDetails!.injectingNeedleRefills,
syringeType: syringeType ?? _injectionDetails!.syringeType,
syringeCount: syringeCount ?? _injectionDetails!.syringeCount,
syringeRefills: syringeRefills ?? _injectionDetails!.syringeRefills,
injectionSiteNotes:
injectionSiteNotes ?? _injectionDetails!.injectionSiteNotes,
frequency: frequency ?? _injectionDetails!.frequency,
subtype: subtype ?? _injectionDetails!.subtype,
siteRotation:
siteRotation ?? _injectionDetails!.siteRotation, // Added field
);
});
// Ensure frequency is 1 when biweekly is selected
if (frequency == InjectionFrequency.biweekly && _frequency != 1) {
_frequency = 1;
_times = _adjustTimesList(_times, 1);
}
}
/// Handle frequency change (times per day)
void _handleFrequencyChange(int newFrequency) {
setState(() {
_frequency = newFrequency;
// Always ensure _times list has exactly newFrequency entries
_times = _adjustTimesList(_times, newFrequency);
// If we're in as-needed mode, no need to show multiple time slots
if (_asNeeded && newFrequency > 1) {
_frequency = 1;
_times = _times.sublist(0, 1);
}
});
}
/// Adjust the time slots list when frequency changes
List<TimeOfDay> _adjustTimesList(
List<TimeOfDay> currentTimes, int newFrequency) {
if (newFrequency > currentTimes.length) {
// Add new time slots if frequency increases
return [
...currentTimes,
...List.generate(
newFrequency - currentTimes.length,
(_) => TimeOfDay.now(),
)
];
}
// Remove excess time slots if frequency decreases
return currentTimes.sublist(0, newFrequency);
}
/// Handle changes to individual time slots
void _handleTimeChange(int index, TimeOfDay newTime) {
setState(() {
// Make sure index is valid
if (index >= 0 && index < _times.length) {
_times[index] = newTime;
}
});
}
/// Handle changes to the selected days of the week
void _handleDaysChange(Set<String> newDays) {
setState(() {
_selectedDays = newDays;
});
}
/// Save or update medication data
Future<void> _saveMedication() async {
// Validate form before proceeding
if (!_formKey.currentState!.validate()) return;
// Show loading indicator
setState(() => _isLoading = true);
try {
// Initialize timeOfDay list based on medication type
List<DateTime> timeOfDay = [];
if (_asNeeded) {
// For as-needed medications, ensure we have at least one default time
final now = DateTime.now();
timeOfDay = [
DateTime(now.year, now.month, now.day, now.hour, now.minute)
];
} else {
// For scheduled medications, convert all _times entries to DateTime objects
timeOfDay = _times.map((time) {
final now = DateTime.now();
return DateTime(
now.year,
now.month,
now.day,
time.hour,
time.minute,
);
}).toList();
// Double-check that we have the correct number of time entries
if (timeOfDay.length != _frequency) {
throw MedicationException('Number of times must match frequency');
}
}
// Create medication object from form data
final medication = Medication(
id: widget.medication?.id, // null for new, existing id for updates
name: _nameController.text.trim(),
dosage: _dosageController.text.trim(),
startDate: _startDate,
frequency: _frequency,
timeOfDay: timeOfDay,
daysOfWeek: _selectedDays,
currentQuantity: _currentQuantity,
refillThreshold: _refillThreshold,
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
medicationType: _medicationType,
oralSubtype:
_medicationType == MedicationType.oral ? _oralSubtype : null,
topicalSubtype:
_medicationType == MedicationType.topical ? _topicalSubtype : null,
doctor: _doctorController.text.trim().isNotEmpty
? _doctorController.text.trim()
: null,
pharmacy: _pharmacyController.text.trim().isNotEmpty
? _pharmacyController.text.trim()
: null,
injectionDetails: _injectionDetails,
asNeeded: _asNeeded,
);
// If medication is null, we're adding new
// Otherwise, we're updating existing
if (widget.medication == null) {
await ref
.read(medicationStateProvider.notifier)
.addMedication(medication);
} else {
await ref
.read(medicationStateProvider.notifier)
.updateMedication(medication);
}
// Return to details screen
if (mounted) {
NavigationService.goToMedicationDetails(context,
medication: medication);
}
} catch (e) {
// Show error in snackbar if something goes wrong
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $e'),
backgroundColor: AppColors.error,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// Show different title based on add/edit mode
title: Text(
widget.medication == null ? 'Add Medication' : 'Edit Medication'),
leading: IconButton(
icon: Icon(AppIcons.getIcon('arrow_back')),
onPressed: () => _handleBackButton(context, ref),
),
actions: [
// Show loading indicator or save button
_isLoading
? const Center(
child: Padding(
padding: AppTheme.standardCardPadding,
child: CircularProgressIndicator(),
),
)
: TextButton(
onPressed: _saveMedication,
child: Text(
'Save',
style: AppTextStyles.titleMedium,
),
),
],
),
// Main form layout using ListView for scrolling
body: Form(
key: _formKey,
child: ListView(
padding: AppTheme.standardCardPadding,
children: [
// Basic information section (name, dosage, inventory)
BasicInfoSection(
nameController: _nameController,
dosageController: _dosageController,
currentQuantity: _currentQuantity,
refillThreshold: _refillThreshold,
onQuantityChanged: (value) =>
setState(() => _currentQuantity = value),
onThresholdChanged: (value) =>
setState(() => _refillThreshold = value),
),
// Medication type section
MedicationTypeSection(
medicationType: _medicationType,
onTypeChanged: _handleTypeChange,
),
// Oral subtype section (only shown for oral medications)
if (_medicationType == MedicationType.oral)
OralSubtypeSection(
subtype: _oralSubtype,
onSubtypeChanged: (value) =>
setState(() => _oralSubtype = value),
),
// Topical subtype section (only shown for topical medications)
if (_medicationType == MedicationType.topical)
TopicalSubtypeSection(
subtype: _topicalSubtype,
onSubtypeChanged: (value) =>
setState(() => _topicalSubtype = value),
),
// Injection details section (only shown for injectable medications)
if (_medicationType == MedicationType.injection)
InjectionDetailsSection(
injectionDetails: _injectionDetails!,
onDetailsChanged: _updateInjectionDetails,
),
// Timing section
TimingSection(
selectedStartDate: _startDate,
onStartDateChanged: (date) => setState(() => _startDate = date),
frequency: _frequency,
times: _times,
selectedDays: _selectedDays,
onFrequencyChanged: _handleFrequencyChange,
onTimeChanged: _handleTimeChange,
onDaysChanged: _handleDaysChange,
isEveryTwoWeeks: _medicationType == MedicationType.injection &&
_injectionDetails?.frequency == InjectionFrequency.biweekly,
medicationType: _medicationType,
asNeeded: _asNeeded,
onAsNeededChanged: _handleAsNeededToggle,
),
// Medical providers section
MedicalProvidersSection(
doctorController: _doctorController,
pharmacyController: _pharmacyController,
),
// Notes section
NotesSection(controller: _notesController),
Row(
children: [
// Delete button on left (only if medication exists)
if (widget.medication != null)
SizedBox(
height: 36,
child: ElevatedButton.icon(
icon: Icon(
AppIcons.getIcon('delete'),
size: 18,
color: AppColors.error,
),
label: Text('Delete'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error.withAlpha(40),
foregroundColor: AppColors.error,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 0),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => _showDeleteDialog(context, ref),
),
),
// Push save button to the right
const Spacer(),
// Save button on right
SizedBox(
height: 36,
child: ElevatedButton.icon(
icon: Icon(
AppIcons.getIcon('save'),
size: 18,
color: AppColors.primary,
),
label: Text('Save'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary.withAlpha(40),
foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 0),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () => _saveMedication(),
),
),
],
),
],
),
),
);
}
bool _checkForUnsavedChanges() {
// For new entries, if any field has content, there are unsaved changes
if (widget.medication == null) {
// Check if any required field has been filled
if (_nameController.text.isNotEmpty ||
_dosageController.text.isNotEmpty ||
_notesController.text.isNotEmpty ||
_doctorController.text.isNotEmpty ||
_pharmacyController.text.isNotEmpty) {
return true;
}
// Check if any other field has been modified from default values
if (_medicationType != MedicationType.oral ||
_oralSubtype != OralSubtype.tablets ||
_currentQuantity != 0 ||
_refillThreshold != 0) {
return true;
}
// Check if date was modified from the default (today)
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final selectedDate =
DateTime(_startDate.year, _startDate.month, _startDate.day);
if (selectedDate != today) {
return true;
}
return false;
}
// For existing entries, compare with original values
else {
// Compare basic text fields
if (_nameController.text != widget.medication!.name ||
_dosageController.text != widget.medication!.dosage ||
_notesController.text != (widget.medication!.notes ?? '') ||
_doctorController.text != (widget.medication!.doctor ?? '') ||
_pharmacyController.text != (widget.medication!.pharmacy ?? '')) {
return true;
}
// Compare medication type and related fields
if (_medicationType != widget.medication!.medicationType) {
return true;
}
// Compare type-specific fields
if (_medicationType == MedicationType.oral &&
_oralSubtype != widget.medication!.oralSubtype) {
return true;
}
if (_medicationType == MedicationType.topical &&
_topicalSubtype != widget.medication!.topicalSubtype) {
return true;
}
// Compare injection details if applicable
if (_medicationType == MedicationType.injection) {
final origDetails = widget.medication!.injectionDetails;
final currDetails = _injectionDetails;
if ((origDetails == null && currDetails != null) ||
(origDetails != null && currDetails == null)) {
return true;
}
if (origDetails != null && currDetails != null) {
if (origDetails.frequency != currDetails.frequency ||
origDetails.subtype != currDetails.subtype ||
origDetails.drawingNeedleType != currDetails.drawingNeedleType ||
origDetails.drawingNeedleCount !=
currDetails.drawingNeedleCount ||
origDetails.drawingNeedleRefills !=
currDetails.drawingNeedleRefills ||
origDetails.injectingNeedleType !=
currDetails.injectingNeedleType ||
origDetails.injectingNeedleCount !=
currDetails.injectingNeedleCount ||
origDetails.injectingNeedleRefills !=
currDetails.injectingNeedleRefills ||
origDetails.syringeType != currDetails.syringeType ||
origDetails.syringeCount != currDetails.syringeCount ||
origDetails.syringeRefills != currDetails.syringeRefills ||
origDetails.injectionSiteNotes !=
currDetails.injectionSiteNotes) {
return true;
}
// Check site rotation if exists
if ((origDetails.siteRotation == null &&
currDetails.siteRotation != null) ||
(origDetails.siteRotation != null &&
currDetails.siteRotation == null)) {
return true;
}
}
}
// Compare date
final originalDate = widget.medication!.startDate;
final originalDay =
DateTime(originalDate.year, originalDate.month, originalDate.day);
final currentDay =
DateTime(_startDate.year, _startDate.month, _startDate.day);
if (originalDay != currentDay) {
return true;
}
// Compare frequency
if (_frequency != widget.medication!.frequency) {
return true;
}
// Compare times of day
if (_times.length != widget.medication!.timeOfDay.length) {
return true;
}
for (int i = 0; i < _times.length; i++) {
final currTime = _times[i];
final origTime =
TimeOfDay.fromDateTime(widget.medication!.timeOfDay[i]);
if (currTime.hour != origTime.hour ||
currTime.minute != origTime.minute) {
return true;
}
}
// Compare days of week
if (_selectedDays.length != widget.medication!.daysOfWeek.length) {
return true;
}
for (final day in _selectedDays) {
if (!widget.medication!.daysOfWeek.contains(day)) {
return true;
}
}
// Compare inventory values
if (_currentQuantity != widget.medication!.currentQuantity ||
_refillThreshold != widget.medication!.refillThreshold) {
return true;
}
return false; // No changes detected
}
}
void _handleBackButton(BuildContext context, WidgetRef ref) {
DialogService.handleBackWithUnsavedChanges(
context: context,
ref: ref,
checkForUnsavedChanges: _checkForUnsavedChanges,
saveFn: _saveMedication,
);
}
/// Show confirmation dialog before deleting medication
Future<void> _showDeleteDialog(BuildContext context, WidgetRef ref) async {
await DialogService.showDeleteDialog(
context: context,
ref: ref,
itemName: _nameController.text.trim(),
onDelete: () async {
await ref
.read(medicationStateProvider.notifier)
.deleteMedication(widget.medication!.id);
if (context.mounted) {
NavigationService.goHome(context);
}
},
);
}
}

View file

@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_basic_info.dart
//
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/widgets/autocomplete_text_field.dart';
import 'package:nokken/src/core/services/error/validation_service.dart';
import 'package:nokken/src/core/services/database/drugs_repository.dart';
/// Basic Information Section with name, dosage, and inventory fields
class BasicInfoSection extends ConsumerWidget {
final TextEditingController nameController;
final TextEditingController dosageController;
final int currentQuantity;
final int refillThreshold;
final ValueChanged<int> onQuantityChanged;
final ValueChanged<int> onThresholdChanged;
const BasicInfoSection({
super.key,
required this.nameController,
required this.dosageController,
required this.currentQuantity,
required this.refillThreshold,
required this.onQuantityChanged,
required this.onThresholdChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the provider for drug name suggestions
final drugNamesAsync = ref.watch(drugNamesProvider);
return SharedWidgets.basicCard(
context: context,
title: 'Basic Information',
children: [
// Medication name field with autocomplete
drugNamesAsync.when(
data: (drugNames) {
print('Rendering autocomplete with ${drugNames.length} options');
// Only use autocomplete if we have options (we should if the file is loaded correctly)
if (drugNames.isNotEmpty) {
return AutocompleteTextField(
controller: nameController,
labelText: 'Medication Name',
options: drugNames,
hintText: 'Start typing medication name',
onSelected: (value) {
print('Selected medication: $value');
},
);
} else {
// If no options available, fall back to regular text field
return TextFormField(
controller: nameController,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Medication Name',
),
validator: ValidationService.nameValidator,
);
}
},
error: (error, stackTrace) {
// Log the error for debugging
print('Error loading drug names: $error');
print('Stack trace: $stackTrace');
// On error, fall back to standard text field
return TextFormField(
controller: nameController,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Medication Name',
),
validator: ValidationService.nameValidator,
);
},
loading: () {
// While loading, show autocomplete with loading state
return AutocompleteTextField(
controller: nameController,
labelText: 'Medication Name',
options: const [],
hintText: 'Loading medications...',
isLoading: true,
);
},
),
SharedWidgets.verticalSpace(),
// Dosage field
TextFormField(
controller: dosageController,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Dosage',
hintText: 'e.g., 50mg',
),
validator: ValidationService.dosageValidator,
),
SharedWidgets.verticalSpace(),
// Inventory fields
Row(
children: [
// Current quantity field
Expanded(
child: TextFormField(
initialValue: currentQuantity.toString(),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Quantity',
),
onChanged: (value) {
final newValue = int.tryParse(value) ?? currentQuantity;
if (newValue >= 0) {
onQuantityChanged(newValue);
}
},
),
),
SharedWidgets.horizontalSpace(),
// Refill threshold field
Expanded(
child: TextFormField(
initialValue: refillThreshold.toString(),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Refill At',
),
onChanged: (value) {
final newValue = int.tryParse(value) ?? refillThreshold;
if (newValue >= 0) {
onThresholdChanged(newValue);
}
},
),
),
],
),
],
);
}
}

View file

@ -0,0 +1,393 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_injection_details.dart
//
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/dropdown_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
/// Injection Details Section - Only shown for injection medications
class InjectionDetailsSection extends StatelessWidget {
final InjectionDetails injectionDetails;
final Function({
String? drawingNeedleType,
int? drawingNeedleCount,
int? drawingNeedleRefills,
String? injectingNeedleType,
int? injectingNeedleCount,
int? injectingNeedleRefills,
String? syringeType,
int? syringeCount,
int? syringeRefills,
String? injectionSiteNotes,
InjectionFrequency? frequency,
InjectionSubtype? subtype,
InjectionSiteRotation? siteRotation,
}) onDetailsChanged;
const InjectionDetailsSection({
super.key,
required this.injectionDetails,
required this.onDetailsChanged,
});
@override
Widget build(BuildContext context) {
return SharedWidgets.basicCard(
context: context,
title: 'Injection Details',
children: [
// Injection subtype dropdown
DropdownWidgets.customDropdownButtonFormField<InjectionSubtype>(
value: injectionDetails.subtype,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(),
items: InjectionSubtype.values.map((subtype) {
String displayName;
IconData iconData;
String description; // Add description variable
switch (subtype) {
case InjectionSubtype.intravenous:
displayName = 'Intravenous (IV)';
iconData = Icons.water_drop_outlined;
description = 'delivered directly into a vein';
break;
case InjectionSubtype.intramuscular:
displayName = 'Intramuscular (IM)';
iconData = Icons.fitness_center_outlined;
description = 'delivered into muscle tissue';
break;
case InjectionSubtype.subcutaneous:
displayName = 'Subcutaneous (SC)';
iconData = Icons.layers_outlined;
description = 'delivered into the fatty layer beneath the skin';
break;
}
return DropdownWidgets.dropdownItemWithDescription<
InjectionSubtype>(
value: subtype,
description: description,
child: Row(
children: [
Icon(iconData, size: 20, color: AppColors.injection),
SharedWidgets.horizontalSpace(12),
Text(displayName),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
onDetailsChanged(subtype: value);
}
},
),
SharedWidgets.verticalSpace(),
// Frequency Dropdown
DropdownWidgets.customDropdownButtonFormField<InjectionFrequency>(
value: injectionDetails.frequency,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(),
items: InjectionFrequency.values.map((freq) {
String displayText;
IconData iconData;
switch (freq) {
case InjectionFrequency.weekly:
displayText = 'Weekly';
iconData = Icons.calendar_view_week_outlined;
break;
case InjectionFrequency.biweekly:
displayText = 'Every 2 Weeks';
iconData = Icons.calendar_month_outlined;
break;
}
return DropdownMenuItem(
value: freq,
child: Row(
children: [
Icon(iconData, size: 20, color: AppColors.injection),
SharedWidgets.horizontalSpace(12),
Text(displayText),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
onDetailsChanged(frequency: value);
}
},
),
SharedWidgets.verticalSpace(AppTheme.tripleSpacing),
// Injection Site Rotation Section
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton.icon(
icon: Icon(AppIcons.getIcon('edit')),
label: const Text('Manage Sites'),
onPressed: () {
NavigationService.goToInjectionSiteTracker(
context,
initialRotation: injectionDetails.siteRotation,
onSave: (rotation) {
onDetailsChanged(siteRotation: rotation);
},
);
},
),
],
),
SharedWidgets.verticalSpace(),
_buildNextSitePreview(context),
],
),
SharedWidgets.verticalSpace(AppTheme.tripleSpacing),
_buildSuppliesSection(
context,
title: 'Syringes',
typeValue: injectionDetails.syringeType,
countValue: injectionDetails.syringeCount,
refillsValue: injectionDetails.syringeRefills,
onTypeChanged: (value) => onDetailsChanged(syringeType: value),
onCountChanged: (value) => onDetailsChanged(syringeCount: value),
onRefillsChanged: (value) => onDetailsChanged(syringeRefills: value),
typeHint: 'e.g., 3mL Luer Lock',
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
// Drawing Needles Section
_buildSuppliesSection(
context,
title: 'Drawing Needles',
typeValue: injectionDetails.drawingNeedleType,
countValue: injectionDetails.drawingNeedleCount,
refillsValue: injectionDetails.drawingNeedleRefills,
onTypeChanged: (value) => onDetailsChanged(drawingNeedleType: value),
onCountChanged: (value) =>
onDetailsChanged(drawingNeedleCount: value),
onRefillsChanged: (value) =>
onDetailsChanged(drawingNeedleRefills: value),
typeHint: 'e.g., 18G 1-inch',
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
// Injecting Needles Section
_buildSuppliesSection(
context,
title: 'Injecting Needles',
typeValue: injectionDetails.injectingNeedleType,
countValue: injectionDetails.injectingNeedleCount,
refillsValue: injectionDetails.injectingNeedleRefills,
onTypeChanged: (value) =>
onDetailsChanged(injectingNeedleType: value),
onCountChanged: (value) =>
onDetailsChanged(injectingNeedleCount: value),
onRefillsChanged: (value) =>
onDetailsChanged(injectingNeedleRefills: value),
typeHint: 'e.g., 23G 1.5-inch',
),
// Injection Site Notes
/* SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
TextFormField(
initialValue: injectionDetails.injectionSiteNotes,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Injection Site Notes',
),
maxLines: 2,
onChanged: (value) => onDetailsChanged(injectionSiteNotes: value),
),*/
],
);
}
Widget _buildSuppliesSection(
BuildContext context, {
required String title,
required String typeValue,
required int countValue,
required int refillsValue,
required Function(String) onTypeChanged,
required Function(int) onCountChanged,
required Function(int) onRefillsChanged,
required String typeHint,
String? description,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section title with optional description
Row(
children: [
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (description != null)
Tooltip(
message: description,
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
Icons.info_outline,
size: 16,
color: AppColors.info,
),
),
),
],
),
SharedWidgets.verticalSpace(),
// Type field
TextFormField(
initialValue: typeValue,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Type',
hintText: typeHint,
),
onChanged: onTypeChanged,
),
SharedWidgets.verticalSpace(),
// Count and Refills fields in a row
Row(
children: [
// Current count field
Expanded(
child: TextFormField(
initialValue: countValue.toString(),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Quantity',
),
onChanged: (value) {
final newValue = int.tryParse(value) ?? 0;
if (newValue >= 0) {
onCountChanged(newValue);
}
},
),
),
SharedWidgets.horizontalSpace(),
// Refills count field
Expanded(
child: TextFormField(
initialValue: refillsValue.toString(),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
labelText: 'Refill At',
),
onChanged: (value) {
final newValue = int.tryParse(value) ?? 0;
if (newValue >= 0) {
onRefillsChanged(newValue);
}
},
),
),
],
),
],
);
}
/// Build the next injection site preview
Widget _buildNextSitePreview(BuildContext context) {
if (injectionDetails.siteRotation == null ||
injectionDetails.siteRotation!.sites.isEmpty) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.info.withAlpha(20),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
children: [
Icon(Icons.info_outline),
SizedBox(width: 8),
Expanded(
child: Text(
'No injection sites configured. Tap "Manage Sites" to set up site rotation.',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
],
),
);
}
final nextSite = injectionDetails.siteRotation!.nextSite;
final bodyAreaText =
nextSite.bodyArea == InjectionBodyArea.abdomen ? 'Abdomen' : 'Thigh';
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.tertiary.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.tertiary),
),
child: Row(
children: [
Icon(
Icons.arrow_circle_right,
color: AppColors.tertiary,
),
SharedWidgets.horizontalSpace(AppTheme.doubleSpacing),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Next Injection Site:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.tertiary,
),
),
const SizedBox(height: 4),
Text(
'Site ${nextSite.siteNumber} in $bodyAreaText',
style: AppTextStyles.titleMedium,
),
],
),
),
SharedWidgets.horizontalSpace(),
Text(
'${injectionDetails.siteRotation!.sites.length} sites',
style: TextStyle(
color: AppColors.onSurfaceVariant,
fontSize: 12,
),
),
],
),
);
}
}

View file

@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_inventory.dart
//
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
/// Inventory Section for tracking medication quantities
class InventorySection extends StatelessWidget {
final int currentQuantity;
final int refillThreshold;
final ValueChanged<int> onQuantityChanged;
final ValueChanged<int> onThresholdChanged;
const InventorySection({
super.key,
required this.currentQuantity,
required this.refillThreshold,
required this.onQuantityChanged,
required this.onThresholdChanged,
});
@override
Widget build(BuildContext context) {
return SharedWidgets.basicCard(
context: context,
title: 'Inventory',
children: [
_buildCounterRow(
context,
label: 'Current Quantity',
value: currentQuantity,
onChanged: onQuantityChanged,
minValue: 0,
showAddButton: true,
),
SharedWidgets.verticalSpace(),
_buildCounterRow(
context,
label: 'Refill Alert at',
value: refillThreshold,
onChanged: onThresholdChanged,
minValue: 0,
showAddButton: false,
),
],
);
}
Widget _buildCounterRow(
BuildContext context, {
required String label,
required int value,
required ValueChanged<int> onChanged,
required int minValue,
required bool showAddButton,
int? maxValue,
}) {
final controller = TextEditingController(text: value.toString());
return Row(
children: [
Text('$label: '),
Flexible(
child: TextField(
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
// onChanged callback to update parent state as user types
onChanged: (String val) {
final newValue = int.tryParse(val) ?? value;
if (newValue >= minValue &&
(maxValue == null || newValue <= maxValue)) {
onChanged(newValue);
}
},
// Keep onSubmitted for validation
onSubmitted: (String val) {
final newValue = int.tryParse(val) ?? value;
if (newValue >= minValue &&
(maxValue == null || newValue <= maxValue)) {
onChanged(newValue);
} else {
controller.text = value.toString();
}
},
),
),
if (showAddButton)
TextButton.icon(
icon: Icon(AppIcons.getIcon('add')),
label: const Text('Add More'),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add More'),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Amount to add',
),
),
actions: [
TextButton(
onPressed: () => NavigationService.goBack(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
final amount = int.tryParse(controller.text) ?? 0;
final newValue = value + amount;
if (maxValue == null || newValue <= maxValue) {
onChanged(newValue);
controller.text = newValue.toString();
}
NavigationService.goBack(context);
},
child: const Text('Add'),
),
],
),
);
},
),
],
);
}
}

View file

@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_medical_providers.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/autocomplete_text_field.dart';
import 'package:nokken/src/core/services/database/medical_providers_repository.dart';
/// Doctor and Pharmacy Section
class MedicalProvidersSection extends ConsumerWidget {
final TextEditingController doctorController;
final TextEditingController pharmacyController;
const MedicalProvidersSection({
super.key,
required this.doctorController,
required this.pharmacyController,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the providers for doctor and pharmacy suggestions
final doctorsAsync = ref.watch(doctorsProvider);
final pharmaciesAsync = ref.watch(pharmaciesProvider);
return SharedWidgets.basicCard(
context: context,
title: 'Medical Providers',
children: [
// Doctor field
doctorsAsync.when(
data: (doctors) {
return AutocompleteTextField(
controller: doctorController,
labelText: 'Doctor',
hintText: 'Enter healthcare provider name',
options: doctors,
);
},
error: (_, __) {
// On error, fall back to standard text field
return TextFormField(
controller: doctorController,
decoration: InputDecoration(
labelText: 'Doctor',
hintText: 'Enter healthcare provider name',
),
);
},
loading: () {
// While loading, show autocomplete with loading state
return AutocompleteTextField(
controller: doctorController,
labelText: 'Doctor',
hintText: 'Enter healthcare provider name',
options: const [],
isLoading: true,
);
},
),
SharedWidgets.verticalSpace(),
// Pharmacy field
pharmaciesAsync.when(
data: (pharmacies) {
return AutocompleteTextField(
controller: pharmacyController,
labelText: 'Pharmacy',
hintText: 'Enter pharmacy name',
options: pharmacies,
);
},
error: (_, __) {
// On error, fall back to standard text field
return TextFormField(
controller: pharmacyController,
decoration: InputDecoration(
labelText: 'Pharmacy',
hintText: 'Enter pharmacy name',
),
);
},
loading: () {
// While loading, show autocomplete with loading state
return AutocompleteTextField(
controller: pharmacyController,
labelText: 'Pharmacy',
hintText: 'Enter pharmacy name',
options: const [],
isLoading: true,
);
},
),
],
);
}
}

View file

@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_medication_type.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/dropdown_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/utils/get_icons_colors.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
class MedicationTypeSection extends StatelessWidget {
final MedicationType medicationType;
final ValueChanged<MedicationType> onTypeChanged;
const MedicationTypeSection({
super.key,
required this.medicationType,
required this.onTypeChanged,
});
@override
Widget build(BuildContext context) {
return SharedWidgets.basicCard(
context: context,
title: 'Medication Type',
children: [
DropdownWidgets.customDropdownButtonFormField<MedicationType>(
value: medicationType,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(),
items: MedicationType.values.map((type) {
String displayName;
String description;
switch (type) {
case MedicationType.oral:
displayName = 'Oral';
description =
'taken by mouth in the form of tablets, capsules, or drops';
break;
case MedicationType.injection:
displayName = 'Injection';
description =
'administered via needle into the bloodstream, muscle, or under the skin';
break;
case MedicationType.topical:
displayName = 'Topical';
description =
'applied directly to the skin as a cream, gel, or spray';
break;
case MedicationType.patch:
displayName = 'Patch';
description =
'adhesive patch that delivers medication through the skin over time';
break;
}
return DropdownWidgets.dropdownItemWithDescription<MedicationType>(
value: type,
description: description,
child: Row(
children: [
GetIconsColors.getMedicationIconWithColor(type),
SharedWidgets.horizontalSpace(12),
Text(displayName),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
onTypeChanged(value);
}
},
),
],
);
}
}

View file

@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_notes.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
/// Notes Section for additional information (optional)
class NotesSection extends StatelessWidget {
final TextEditingController controller;
const NotesSection({
super.key,
required this.controller,
});
@override
Widget build(BuildContext context) {
return SharedWidgets.basicCard(
context: context,
title: 'Notes',
children: [
TextFormField(
controller: controller,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
hintText: 'Add any additional notes here',
),
maxLines: 3,
),
],
);
}
}

View file

@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_oral_subtype.dart
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/dropdown_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
/// Oral Subtype Section - Only shown for oral medications
class OralSubtypeSection extends StatelessWidget {
final OralSubtype? subtype;
final ValueChanged<OralSubtype> onSubtypeChanged;
const OralSubtypeSection({
super.key,
required this.subtype,
required this.onSubtypeChanged,
});
@override
Widget build(BuildContext context) {
return SharedWidgets.basicCard(
context: context,
title: 'Oral Medication Form',
children: [
DropdownWidgets.customDropdownButtonFormField<OralSubtype>(
value: subtype ?? OralSubtype.tablets,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(),
items: OralSubtype.values.map((type) {
String displayName;
switch (type) {
case OralSubtype.tablets:
displayName = 'Tablets';
break;
case OralSubtype.capsules:
displayName = 'Capsules';
break;
case OralSubtype.drops:
displayName = 'Drops';
break;
}
return DropdownMenuItem<OralSubtype>(
value: type,
child: Text(displayName),
);
}).toList(),
onChanged: (value) {
if (value != null) {
onSubtypeChanged(value);
}
},
),
],
);
}
}

View file

@ -0,0 +1,318 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_timing.dart
//
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/dropdown_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
import 'package:nokken/src/core/constants/date_constants.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
/// Timing Section for medication schedule
class TimingSection extends StatelessWidget {
final DateTime selectedStartDate;
final ValueChanged<DateTime> onStartDateChanged;
final int frequency;
final List<TimeOfDay> times;
final Set<String> selectedDays;
final ValueChanged<int> onFrequencyChanged;
final Function(int, TimeOfDay) onTimeChanged;
final ValueChanged<Set<String>> onDaysChanged;
final bool isEveryTwoWeeks;
final MedicationType medicationType;
final bool asNeeded;
final ValueChanged<bool> onAsNeededChanged;
const TimingSection({
super.key,
required this.selectedStartDate,
required this.onStartDateChanged,
required this.frequency,
required this.times,
required this.selectedDays,
required this.onFrequencyChanged,
required this.onTimeChanged,
required this.onDaysChanged,
this.isEveryTwoWeeks = false,
required this.medicationType,
required this.asNeeded,
required this.onAsNeededChanged,
});
@override
Widget build(BuildContext context) {
// Customize the title based on medication type
String sectionTitle = 'Timing';
if (medicationType == MedicationType.patch) {
sectionTitle = 'Change Schedule';
}
return SharedWidgets.basicCard(
context: context,
title: sectionTitle,
children: [
_buildStartDateSelector(context),
// "Take as Needed" toggle
_buildAsNeededToggle(),
// Only show scheduling UI if not an as-needed medication
if (!asNeeded) ...[
if (!isEveryTwoWeeks && medicationType != MedicationType.patch)
_buildFrequencySelector(),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
_buildDaySelector(context),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
...List.generate(
frequency,
(index) => _buildTimeInput(context, index),
),
] else ...[
// Show description for as-needed medications
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.info.withAlpha(20),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(AppIcons.getIcon('info'), color: AppColors.info, size: 20),
SharedWidgets.horizontalSpace(),
const Expanded(
child: Text(
'This medication will not be scheduled for specific times. You can record doses as needed from the Daily Tracker screen.',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
],
),
),
],
],
);
}
/// Build the "Take as Needed" toggle
Widget _buildAsNeededToggle() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Take as Needed',
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
Switch(
value: asNeeded,
onChanged: onAsNeededChanged,
activeColor: AppColors.tertiary,
),
],
);
}
/// Build the frequency selector (times per day)
Widget _buildFrequencySelector() {
return Row(
children: [
const Text('Times per day: '),
IconButton(
icon: Icon(AppIcons.getIcon('remove')),
onPressed:
frequency > 1 ? () => onFrequencyChanged(frequency - 1) : null,
),
Text('$frequency'),
IconButton(
icon: Icon(AppIcons.getIcon('add')),
onPressed:
frequency < 10 ? () => onFrequencyChanged(frequency + 1) : null,
),
],
);
}
/// Build the days of week selector
Widget _buildDaySelector(BuildContext context) {
// Create dropdown items for each day
final dayItems = DateConstants.orderedDays.map((day) {
return DropdownMenuItem<String>(
value: day,
child: Text(day),
);
}).toList();
// Callback to determine if a day can be deselected
bool canDeselectDay(String day) {
return !isEveryTwoWeeks || selectedDays.length > 1;
}
return Row(
children: [
Text('Days: ', style: AppTextStyles.titleMedium),
Expanded(
child: DropdownWidgets.multiSelectDropdown(
selectedValues: selectedDays,
items: dayItems,
onChanged: onDaysChanged,
placeholder: 'Select days...',
decoration: const InputDecoration(
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
border: OutlineInputBorder(),
),
canDeselect: canDeselectDay,
),
),
],
);
}
/// Show date picker dialog and handle date selection
Future<void> _selectStartDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedStartDate,
firstDate: DateTime(2000),
lastDate: DateTime(2034),
);
if (picked != null && picked != selectedStartDate) {
onStartDateChanged(picked);
}
}
/// Build the start date selector
Widget _buildStartDateSelector(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SharedWidgets.verticalSpace(),
ListTile(
title: Text(
'Start Date: ${DateTimeFormatter.formatDateDDMMYY(selectedStartDate)}',
),
trailing: Icon(AppIcons.getIcon('calendar')),
onTap: () => _selectStartDate(context),
),
],
);
}
/// Build time input for a specific time slot
Widget _buildTimeInput(BuildContext context, int index) {
final time = times[index];
final isPM = time.hour >= 12;
final hour12 =
time.hour > 12 ? time.hour - 12 : (time.hour == 0 ? 12 : time.hour);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(
'Time ${index + 1}:',
style: const TextStyle(fontSize: 16),
),
),
// Hour input
SizedBox(
width: 45,
child: TextFormField(
initialValue: hour12.toString(),
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: AppTheme.defaultTextFieldDecoration,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
],
onChanged: (value) {
if (value.isNotEmpty) {
final newHour = int.parse(value);
if (newHour >= 1 && newHour <= 12) {
final hour24 = isPM
? (newHour == 12 ? 12 : newHour + 12)
: (newHour == 12 ? 0 : newHour);
onTimeChanged(
index,
TimeOfDay(hour: hour24, minute: time.minute),
);
}
}
},
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text(':', style: TextStyle(fontSize: 20)),
),
// Minute input
SizedBox(
width: 45,
child: TextFormField(
initialValue: time.minute.toString().padLeft(2, '0'),
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
decoration: AppTheme.defaultTextFieldDecoration,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(2),
],
onChanged: (value) {
if (value.isNotEmpty) {
final newMinute = int.parse(value);
if (newMinute >= 0 && newMinute < 60) {
onTimeChanged(
index,
TimeOfDay(hour: time.hour, minute: newMinute),
);
}
}
},
),
),
SharedWidgets.horizontalSpace(12),
// AM/PM toggle
TextButton(
onPressed: () {
final currentHour = time.hour;
final hour12 = currentHour > 12
? currentHour - 12
: (currentHour == 0 ? 12 : currentHour);
final newIsPM = !isPM;
final newHour = newIsPM
? (hour12 == 12 ? 12 : hour12 + 12)
: (hour12 == 12 ? 0 : hour12);
onTimeChanged(
index,
TimeOfDay(hour: newHour, minute: time.minute),
);
},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
side: BorderSide(color: AppColors.outline),
),
child: Text(
isPM ? 'PM' : 'AM',
style: AppTextStyles.buttonText,
),
),
],
),
);
}
}

View file

@ -0,0 +1,60 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_topical_subtype.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/dropdown_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
/// Topical Subtype Section - Only shown for topical medications
class TopicalSubtypeSection extends StatelessWidget {
final TopicalSubtype? subtype;
final ValueChanged<TopicalSubtype> onSubtypeChanged;
const TopicalSubtypeSection({
super.key,
required this.subtype,
required this.onSubtypeChanged,
});
@override
Widget build(BuildContext context) {
return SharedWidgets.basicCard(
context: context,
title: 'Topical Medication Form',
children: [
DropdownWidgets.customDropdownButtonFormField<TopicalSubtype>(
value: subtype ?? TopicalSubtype.gel,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(),
items: TopicalSubtype.values.map((type) {
String displayName;
switch (type) {
case TopicalSubtype.gel:
displayName = 'Gel';
break;
case TopicalSubtype.cream:
displayName = 'Cream';
break;
case TopicalSubtype.spray:
displayName = 'Spray';
break;
}
return DropdownMenuItem<TopicalSubtype>(
value: type,
child: Text(displayName),
);
}).toList(),
onChanged: (value) {
if (value != null) {
onSubtypeChanged(value);
}
},
),
],
);
}
}

View file

@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// add_edit_widgets.dart
// exports all the widgets used in the Add/Edit Medication screen.
//
export 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_basic_info.dart';
export 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_medication_type.dart';
export 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_oral_subtype.dart';
export 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_topical_subtype.dart';
export 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_injection_details.dart';
export 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_timing.dart';
export 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_medical_providers.dart';
export 'package:nokken/src/features/medication_tracker/screens/add_edit_medication/widgets/add_edit_notes.dart';

View file

@ -0,0 +1,825 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// injection_site_tracker_screen.dart
// Screen for tracking injection sites and rotations
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
class InjectionSiteTrackerScreen extends ConsumerStatefulWidget {
final InjectionSiteRotation? initialRotation;
final Function(InjectionSiteRotation) onSave;
const InjectionSiteTrackerScreen({
super.key,
this.initialRotation,
required this.onSave,
});
@override
ConsumerState<InjectionSiteTrackerScreen> createState() =>
_InjectionSiteTrackerScreenState();
}
class _InjectionSiteTrackerScreenState
extends ConsumerState<InjectionSiteTrackerScreen> {
late InjectionBodyArea _selectedBodyArea;
late List<InjectionSite> _sites;
late int _currentSiteIndex;
@override
void initState() {
super.initState();
// Initialize with existing data or defaults
_sites = widget.initialRotation?.sites.toList() ?? [];
_currentSiteIndex = widget.initialRotation?.currentSiteIndex ?? 0;
_selectedBodyArea =
_sites.isNotEmpty ? _sites.first.bodyArea : InjectionBodyArea.abdomen;
}
void _addSite(int siteNumber) {
setState(() {
_sites.add(InjectionSite(
siteNumber: siteNumber,
bodyArea: _selectedBodyArea,
));
});
}
void _removeSite(int index) {
setState(() {
_sites.removeAt(index);
if (_currentSiteIndex >= _sites.length) {
_currentSiteIndex = _sites.isEmpty ? 0 : _sites.length - 1;
}
});
}
void _saveRotation() {
final rotation = InjectionSiteRotation(
sites: _sites,
currentSiteIndex: _currentSiteIndex,
);
widget.onSave(rotation);
NavigationService.goBack(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Injection Site Tracker'),
actions: [
IconButton(
icon: Icon(AppIcons.getIcon('save')),
onPressed: _saveRotation,
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Body area selector
_buildBodyAreaSelector(),
SharedWidgets.verticalSpace(AppTheme.cardSpacing),
// Body diagram
_buildBodyDiagram(),
SharedWidgets.verticalSpace(AppTheme.cardSpacing),
// Site list and rotation
_buildSiteRotationSection(),
],
),
);
}
Widget _buildBodyAreaSelector() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Select Body Area', style: AppTextStyles.titleMedium),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
Row(
children: [
// Abdomen option
Expanded(
child: _buildBodyAreaOption(
title: 'Abdomen',
area: InjectionBodyArea.abdomen,
icon: Icons.circle_outlined,
),
),
SharedWidgets.horizontalSpace(AppTheme.doubleSpacing),
// Thigh option
Expanded(
child: _buildBodyAreaOption(
title: 'Thighs',
area: InjectionBodyArea.thigh,
icon: Icons.rectangle_outlined,
),
),
],
),
],
),
),
);
}
Widget _buildBodyAreaOption({
required String title,
required InjectionBodyArea area,
required IconData icon,
}) {
final isSelected = area == _selectedBodyArea;
return InkWell(
onTap: () {
setState(() {
_selectedBodyArea = area;
});
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(
color: isSelected ? AppColors.primary : AppColors.outline,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
color: isSelected ? AppColors.primary.withAlpha(50) : null,
),
child: Column(
children: [
Icon(
icon,
color: isSelected ? AppColors.primary : null,
size: 32,
),
SharedWidgets.verticalSpace(),
Text(
title,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? AppColors.primary : null,
),
),
],
),
),
);
}
Widget _buildBodyDiagram() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Injection Sites', style: AppTextStyles.titleMedium),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
_selectedBodyArea == InjectionBodyArea.abdomen
? _buildAbdomenDiagram()
: _buildThighDiagram(),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
const Text(
'Tap on a numbered site to add it to your rotation schedule.',
style: TextStyle(fontStyle: FontStyle.italic),
),
],
),
),
);
}
Widget _buildAbdomenDiagram() {
return Center(
child: SizedBox(
width: 300,
height: 300,
child: Stack(
children: [
// Background circle container
Container(
width: 300,
height: 300,
decoration: BoxDecoration(
border: Border.all(color: AppColors.outline),
borderRadius: BorderRadius.circular(150),
color: AppColors.surface.withAlpha(80),
),
),
// Navel indicator
Center(
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppColors.outline,
borderRadius: BorderRadius.circular(5),
),
),
),
// Abdomen separator lines
CustomPaint(
size: const Size(300, 300),
painter: AbdomenLinePainter(),
),
// Left abdomen label
Positioned(
top: 140,
left: 20,
child: RotatedBox(
quarterTurns: 3,
child: Text(
'Left',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Right abdomen label
Positioned(
top: 140,
right: 20,
child: RotatedBox(
quarterTurns: 1,
child: Text(
'Right',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Upper abdomen label
Positioned(
top: 15,
left: 130,
child: Text(
'Upper',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
// Lower abdomen label
Positioned(
bottom: 15,
left: 130,
child: Text(
'Lower',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
// 16 Abdomen Sites - 4 in each quadrant
// Upper Left Quadrant (Sites 1-4)
_buildClickableSiteContainer(top: 60, left: 60, siteNumber: 1),
_buildClickableSiteContainer(top: 60, left: 110, siteNumber: 2),
_buildClickableSiteContainer(top: 95, left: 60, siteNumber: 3),
_buildClickableSiteContainer(top: 95, left: 110, siteNumber: 4),
// Upper Right Quadrant (Sites 5-8)
_buildClickableSiteContainer(top: 60, right: 110, siteNumber: 5),
_buildClickableSiteContainer(top: 60, right: 60, siteNumber: 6),
_buildClickableSiteContainer(top: 95, right: 110, siteNumber: 7),
_buildClickableSiteContainer(top: 95, right: 60, siteNumber: 8),
// Lower Left Quadrant (Sites 9-12)
_buildClickableSiteContainer(bottom: 95, left: 60, siteNumber: 9),
_buildClickableSiteContainer(bottom: 95, left: 110, siteNumber: 10),
_buildClickableSiteContainer(bottom: 60, left: 60, siteNumber: 11),
_buildClickableSiteContainer(bottom: 60, left: 110, siteNumber: 12),
// Lower Right Quadrant (Sites 13-16)
_buildClickableSiteContainer(
bottom: 95, right: 110, siteNumber: 13),
_buildClickableSiteContainer(bottom: 95, right: 60, siteNumber: 14),
_buildClickableSiteContainer(
bottom: 60, right: 110, siteNumber: 15),
_buildClickableSiteContainer(bottom: 60, right: 60, siteNumber: 16),
],
),
),
);
}
Widget _buildThighDiagram() {
return Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Left thigh
SizedBox(
width: 110,
height: 320,
child: Stack(
children: [
// Background container
Container(
width: 110,
height: 320,
decoration: BoxDecoration(
border: Border.all(color: AppColors.outline),
borderRadius: BorderRadius.circular(30),
color: AppColors.surface.withAlpha(80),
),
),
// Inner/Outer separator line
Positioned(
top: 0,
bottom: 0,
right: 55,
child: Container(
width: 1,
color: AppColors.outline.withAlpha(128),
),
),
// Left thigh label
Positioned(
top: 15,
left: 0,
right: 0,
child: Center(
child: Text(
'Left Thigh',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Outer label
Positioned(
top: 150,
left: 10,
child: RotatedBox(
quarterTurns: 3,
child: Text(
'Outer',
style: TextStyle(
fontSize: 11,
color: AppColors.onSurfaceVariant,
),
),
),
),
// Inner label
Positioned(
top: 150,
right: 10,
child: RotatedBox(
quarterTurns: 1,
child: Text(
'Inner',
style: TextStyle(
fontSize: 11,
color: AppColors.onSurfaceVariant,
),
),
),
),
// Fix click positions for thigh sites
_buildClickableSiteContainer(top: 50, left: 15, siteNumber: 1),
_buildClickableSiteContainer(top: 50, right: 15, siteNumber: 2),
_buildClickableSiteContainer(top: 110, left: 15, siteNumber: 3),
_buildClickableSiteContainer(
top: 110, right: 15, siteNumber: 4),
_buildClickableSiteContainer(top: 170, left: 15, siteNumber: 5),
_buildClickableSiteContainer(
top: 170, right: 15, siteNumber: 6),
_buildClickableSiteContainer(top: 230, left: 15, siteNumber: 7),
_buildClickableSiteContainer(
top: 230, right: 15, siteNumber: 8),
],
),
),
const SizedBox(width: 30),
// Right thigh
SizedBox(
width: 110,
height: 320,
child: Stack(
children: [
// Background container
Container(
width: 110,
height: 320,
decoration: BoxDecoration(
border: Border.all(color: AppColors.outline),
borderRadius: BorderRadius.circular(30),
color: AppColors.surface.withAlpha(80),
),
),
// Inner/Outer separator line
Positioned(
top: 0,
bottom: 0,
left: 55,
child: Container(
width: 1,
color: AppColors.outline.withAlpha(128),
),
),
// Right thigh label
Positioned(
top: 15,
left: 0,
right: 0,
child: Center(
child: Text(
'Right Thigh',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Inner label
Positioned(
top: 150,
left: 10,
child: RotatedBox(
quarterTurns: 3,
child: Text(
'Inner',
style: TextStyle(
fontSize: 11,
color: AppColors.onSurfaceVariant,
),
),
),
),
// Outer label
Positioned(
top: 150,
right: 10,
child: RotatedBox(
quarterTurns: 1,
child: Text(
'Outer',
style: TextStyle(
fontSize: 11,
color: AppColors.onSurfaceVariant,
),
),
),
),
_buildClickableSiteContainer(top: 50, left: 15, siteNumber: 9),
_buildClickableSiteContainer(
top: 50, right: 15, siteNumber: 10),
_buildClickableSiteContainer(
top: 110, left: 15, siteNumber: 11),
_buildClickableSiteContainer(
top: 110, right: 15, siteNumber: 12),
_buildClickableSiteContainer(
top: 170, left: 15, siteNumber: 13),
_buildClickableSiteContainer(
top: 170, right: 15, siteNumber: 14),
_buildClickableSiteContainer(
top: 230, left: 15, siteNumber: 15),
_buildClickableSiteContainer(
top: 230, right: 15, siteNumber: 16),
],
),
),
],
),
);
}
// Fixed clickable site container
Widget _buildClickableSiteContainer({
double? top,
double? bottom,
double? left,
double? right,
required int siteNumber,
}) {
return Positioned(
top: top,
bottom: bottom,
left: left,
right: right,
child: _buildSiteButton(siteNumber),
);
}
Widget _buildSiteButton(int siteNumber) {
// Check if this site is in the rotation
final isInRotation = _sites.any((site) =>
site.siteNumber == siteNumber && site.bodyArea == _selectedBodyArea);
return GestureDetector(
behavior: HitTestBehavior
.opaque, // Ensures tap is registered even if transparent
onTap: () {
if (isInRotation) {
// Find and remove this site
final index = _sites.indexWhere((site) =>
site.siteNumber == siteNumber &&
site.bodyArea == _selectedBodyArea);
if (index >= 0) {
_removeSite(index);
}
} else {
// Add this site
_addSite(siteNumber);
}
},
child: Container(
width: 32, // Small circles
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isInRotation ? AppColors.tertiary : AppColors.surface,
border: Border.all(
color: isInRotation ? AppColors.tertiary : AppColors.primary,
width: 2,
),
),
child: Center(
child: Text(
'$siteNumber',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: isInRotation ? AppColors.onTertiary : AppColors.primary,
),
),
),
),
);
}
Widget _buildSiteRotationSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Rotation Schedule', style: AppTextStyles.titleMedium),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
if (_sites.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'No sites in rotation. Tap on numbered sites in the diagram above to add them to your rotation schedule.',
textAlign: TextAlign.center,
),
)
else ...[
// Current site indicator
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.tertiary.withAlpha(50),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.tertiary),
),
child: Row(
children: [
Icon(
Icons.arrow_circle_right,
color: AppColors.tertiary,
),
SharedWidgets.horizontalSpace(AppTheme.doubleSpacing),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Next Injection:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.tertiary,
),
),
const SizedBox(height: 4),
Text(
'Site ${_sites[_currentSiteIndex].siteNumber} in ${_sites[_currentSiteIndex].bodyArea == InjectionBodyArea.abdomen ? 'Abdomen' : 'Thigh'}',
style: AppTextStyles.titleMedium,
),
],
),
),
IconButton(
icon: Icon(AppIcons.getIcon('redo')),
onPressed: () {
setState(() {
_currentSiteIndex =
(_currentSiteIndex + 1) % _sites.length;
});
},
tooltip: 'Advance to next site',
),
],
),
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
// List of all sites in rotation
Text('All Sites in Rotation:', style: AppTextStyles.bodyMedium),
SharedWidgets.verticalSpace(),
for (int i = 0; i < _sites.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _currentSiteIndex
? AppColors.tertiary
: AppColors.surface,
border: Border.all(
color: i == _currentSiteIndex
? AppColors.tertiary
: AppColors.primary,
),
),
child: Center(
child: Text(
'${_sites[i].siteNumber}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: i == _currentSiteIndex
? AppColors.onTertiary
: AppColors.primary,
),
),
),
),
SharedWidgets.horizontalSpace(AppTheme.doubleSpacing),
Text(
_getSiteDescription(_sites[i]),
style: TextStyle(
fontWeight: i == _currentSiteIndex
? FontWeight.bold
: FontWeight.normal,
),
),
const Spacer(),
IconButton(
icon: Icon(
AppIcons.getIcon('remove_circle'),
color: AppColors.error,
),
onPressed: () => _removeSite(i),
),
],
),
),
],
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
Center(
child: ElevatedButton(
onPressed: _saveRotation,
child: const Text('Save Rotation'),
),
),
],
),
),
);
}
String _getSiteDescription(InjectionSite site) {
if (site.bodyArea == InjectionBodyArea.abdomen) {
final siteNum = site.siteNumber;
// Upper Left Quadrant (Sites 1-4)
if (siteNum == 1) return 'Upper Left Outer (Site $siteNum)';
if (siteNum == 2) return 'Upper Left Inner (Site $siteNum)';
if (siteNum == 3) return 'Upper-Mid Left Outer (Site $siteNum)';
if (siteNum == 4) return 'Upper-Mid Left Inner (Site $siteNum)';
// Upper Right Quadrant (Sites 5-8)
if (siteNum == 5) return 'Upper Right Inner (Site $siteNum)';
if (siteNum == 6) return 'Upper Right Outer (Site $siteNum)';
if (siteNum == 7) return 'Upper-Mid Right Inner (Site $siteNum)';
if (siteNum == 8) return 'Upper-Mid Right Outer (Site $siteNum)';
// Lower Left Quadrant (Sites 9-12)
if (siteNum == 9) return 'Lower-Mid Left Outer (Site $siteNum)';
if (siteNum == 10) return 'Lower-Mid Left Inner (Site $siteNum)';
if (siteNum == 11) return 'Lower Left Outer (Site $siteNum)';
if (siteNum == 12) return 'Lower Left Inner (Site $siteNum)';
// Lower Right Quadrant (Sites 13-16)
if (siteNum == 13) return 'Lower-Mid Right Inner (Site $siteNum)';
if (siteNum == 14) return 'Lower-Mid Right Outer (Site $siteNum)';
if (siteNum == 15) return 'Lower Right Inner (Site $siteNum)';
if (siteNum == 16) return 'Lower Right Outer (Site $siteNum)';
return 'Abdomen (Site $siteNum)';
} else {
final siteNum = site.siteNumber;
if (siteNum <= 8) {
final position = siteNum <= 2
? 'Upper'
: siteNum <= 4
? 'Middle Upper'
: siteNum <= 6
? 'Middle Lower'
: 'Lower';
final side = siteNum % 2 == 1 ? 'Outer' : 'Inner';
return 'Left Thigh - $position $side (Site $siteNum)';
} else {
final rightSiteNum = siteNum - 8;
final position = rightSiteNum <= 2
? 'Upper'
: rightSiteNum <= 4
? 'Middle Upper'
: rightSiteNum <= 6
? 'Middle Lower'
: 'Lower';
final side = rightSiteNum % 2 == 1 ? 'Inner' : 'Outer';
return 'Right Thigh - $position $side (Site $siteNum)';
}
}
}
}
// Custom painter for abdomen guidelines
class AbdomenLinePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = AppTheme.grey.withAlpha(76)
..strokeWidth = 1
..style = PaintingStyle.stroke;
// Horizontal dividing line
canvas.drawLine(
Offset(0, size.height / 2),
Offset(size.width, size.height / 2),
paint,
);
// Vertical dividing line
canvas.drawLine(
Offset(size.width / 2, 0),
Offset(size.width / 2, size.height),
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View file

@ -0,0 +1,805 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// injection_site_view_screen.dart
// Screen to view the current injection site on body diagram
//
import 'package:flutter/material.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
class InjectionSiteViewer extends StatelessWidget {
final InjectionSite currentSite;
const InjectionSiteViewer({
super.key,
required this.currentSite,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Injection Site View'),
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title and site info
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
// Site number in circle
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.tertiary,
),
child: Center(
child: Text(
'${currentSite.siteNumber}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.onTertiary,
),
),
),
),
SharedWidgets.horizontalSpace(AppTheme.doubleSpacing),
// Body area text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Next Injection Site',
style: AppTextStyles.titleMedium,
),
SharedWidgets.verticalSpace(),
Text(
_getSiteDescription(),
style: AppTextStyles.bodyMedium,
),
],
),
),
],
),
),
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
// Body diagram
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Site Location', style: AppTextStyles.titleMedium),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
Expanded(
child: Center(
child: currentSite.bodyArea ==
InjectionBodyArea.abdomen
? _buildAbdomenDiagram()
: _buildThighDiagram(),
),
),
],
),
),
),
),
],
),
),
),
);
}
String _getSiteDescription() {
if (currentSite.bodyArea == InjectionBodyArea.abdomen) {
final siteNum = currentSite.siteNumber;
// Upper Left Quadrant (Sites 1-4)
if (siteNum == 1) {
return 'Upper Left Outer - Site ${currentSite.siteNumber}';
}
if (siteNum == 2) {
return 'Upper Left Inner - Site ${currentSite.siteNumber}';
}
if (siteNum == 3) {
return 'Upper-Mid Left Outer - Site ${currentSite.siteNumber}';
}
if (siteNum == 4) {
return 'Upper-Mid Left Inner - Site ${currentSite.siteNumber}';
}
// Upper Right Quadrant (Sites 5-8)
if (siteNum == 5) {
return 'Upper Right Inner - Site ${currentSite.siteNumber}';
}
if (siteNum == 6) {
return 'Upper Right Outer - Site ${currentSite.siteNumber}';
}
if (siteNum == 7) {
return 'Upper-Mid Right Inner - Site ${currentSite.siteNumber}';
}
if (siteNum == 8) {
return 'Upper-Mid Right Outer - Site ${currentSite.siteNumber}';
}
// Lower Left Quadrant (Sites 9-12)
if (siteNum == 9) {
return 'Lower-Mid Left Outer - Site ${currentSite.siteNumber}';
}
if (siteNum == 10) {
return 'Lower-Mid Left Inner - Site ${currentSite.siteNumber}';
}
if (siteNum == 11) {
return 'Lower Left Outer - Site ${currentSite.siteNumber}';
}
if (siteNum == 12) {
return 'Lower Left Inner - Site ${currentSite.siteNumber}';
}
// Lower Right Quadrant (Sites 13-16)
if (siteNum == 13) {
return 'Lower-Mid Right Inner - Site ${currentSite.siteNumber}';
}
if (siteNum == 14) {
return 'Lower-Mid Right Outer - Site ${currentSite.siteNumber}';
}
if (siteNum == 15) {
return 'Lower Right Inner - Site ${currentSite.siteNumber}';
}
if (siteNum == 16) {
return 'Lower Right Outer - Site ${currentSite.siteNumber}';
}
return 'Abdomen - Site ${currentSite.siteNumber}';
} else {
final siteNum = currentSite.siteNumber;
if (siteNum <= 8) {
final position = siteNum <= 2
? 'Upper'
: siteNum <= 4
? 'Middle Upper'
: siteNum <= 6
? 'Middle Lower'
: 'Lower';
final side = siteNum % 2 == 1 ? 'Outer' : 'Inner';
return 'Left Thigh - $position $side - Site ${currentSite.siteNumber}';
} else {
final rightSiteNum = siteNum - 8;
final position = rightSiteNum <= 2
? 'Upper'
: rightSiteNum <= 4
? 'Middle Upper'
: rightSiteNum <= 6
? 'Middle Lower'
: 'Lower';
final side = rightSiteNum % 2 == 1 ? 'Inner' : 'Outer';
return 'Right Thigh - $position $side - Site ${currentSite.siteNumber}';
}
}
}
Widget _buildAbdomenDiagram() {
return SizedBox(
width: 300,
height: 300,
child: Stack(
children: [
// Background circle container
Container(
width: 300,
height: 300,
decoration: BoxDecoration(
border: Border.all(color: AppColors.outline),
borderRadius: BorderRadius.circular(150),
color: AppColors.surface.withAlpha(80),
),
),
// Navel indicator
Center(
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppColors.outline,
borderRadius: BorderRadius.circular(5),
),
),
),
// Abdomen separator lines
CustomPaint(
size: const Size(300, 300),
painter: AbdomenLinePainter(),
),
// Left abdomen label
Positioned(
top: 140,
left: 20,
child: RotatedBox(
quarterTurns: 3,
child: Text(
'Left',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Right abdomen label
Positioned(
top: 140,
right: 20,
child: RotatedBox(
quarterTurns: 1,
child: Text(
'Right',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Upper abdomen label
Positioned(
top: 15,
left: 130,
child: Text(
'Upper',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
// Lower abdomen label
Positioned(
bottom: 15,
left: 130,
child: Text(
'Lower',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
// 16 Abdomen Sites - 4 in each quadrant - Only show the current site
// Upper Left Quadrant (Sites 1-4)
if (currentSite.siteNumber == 1)
_buildHighlightedSitePosition(top: 60, left: 60, siteNumber: 1),
if (currentSite.siteNumber == 2)
_buildHighlightedSitePosition(top: 60, left: 110, siteNumber: 2),
if (currentSite.siteNumber == 3)
_buildHighlightedSitePosition(top: 95, left: 60, siteNumber: 3),
if (currentSite.siteNumber == 4)
_buildHighlightedSitePosition(top: 95, left: 110, siteNumber: 4),
// Upper Right Quadrant (Sites 5-8)
if (currentSite.siteNumber == 5)
_buildHighlightedSitePosition(top: 60, right: 110, siteNumber: 5),
if (currentSite.siteNumber == 6)
_buildHighlightedSitePosition(top: 60, right: 60, siteNumber: 6),
if (currentSite.siteNumber == 7)
_buildHighlightedSitePosition(top: 95, right: 110, siteNumber: 7),
if (currentSite.siteNumber == 8)
_buildHighlightedSitePosition(top: 95, right: 60, siteNumber: 8),
// Lower Left Quadrant (Sites 9-12)
if (currentSite.siteNumber == 9)
_buildHighlightedSitePosition(bottom: 95, left: 60, siteNumber: 9),
if (currentSite.siteNumber == 10)
_buildHighlightedSitePosition(
bottom: 95, left: 110, siteNumber: 10),
if (currentSite.siteNumber == 11)
_buildHighlightedSitePosition(bottom: 60, left: 60, siteNumber: 11),
if (currentSite.siteNumber == 12)
_buildHighlightedSitePosition(
bottom: 60, left: 110, siteNumber: 12),
// Lower Right Quadrant (Sites 13-16)
if (currentSite.siteNumber == 13)
_buildHighlightedSitePosition(
bottom: 95, right: 110, siteNumber: 13),
if (currentSite.siteNumber == 14)
_buildHighlightedSitePosition(
bottom: 95, right: 60, siteNumber: 14),
if (currentSite.siteNumber == 15)
_buildHighlightedSitePosition(
bottom: 60, right: 110, siteNumber: 15),
if (currentSite.siteNumber == 16)
_buildHighlightedSitePosition(
bottom: 60, right: 60, siteNumber: 16),
],
),
);
}
Widget _buildThighDiagram() {
if (currentSite.siteNumber <= 8) {
// Left thigh sites
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
children: [
Container(
width: 130,
height: 320,
decoration: BoxDecoration(
border: Border.all(color: AppColors.outline),
borderRadius: BorderRadius.circular(30),
color: AppColors.surface.withAlpha(80),
),
child: Stack(
children: [
// Inner/Outer separator line
Positioned(
top: 0,
bottom: 0,
right: 65,
child: Container(
width: 1,
color: AppColors.outline.withAlpha(128),
),
),
// Left thigh label
Positioned(
top: 15,
left: 0,
right: 0,
child: Center(
child: Text(
'Left Thigh',
style: TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Thigh details
CustomPaint(
size: const Size(130, 320),
painter: ThighDetailPainter(isLeft: true),
),
// Outer label
Positioned(
top: 150,
left: 15,
child: RotatedBox(
quarterTurns: 3,
child: Text(
'Outer',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Inner label
Positioned(
top: 150,
right: 15,
child: RotatedBox(
quarterTurns: 1,
child: Text(
'Inner',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
// Site 1 - Upper outer left thigh
if (currentSite.siteNumber == 1)
Positioned(
top: 50,
left: 15,
child: _buildHighlightedSite(1),
),
// Site 2 - Upper inner left thigh
if (currentSite.siteNumber == 2)
Positioned(
top: 50,
right: 15,
child: _buildHighlightedSite(2),
),
// Site 3 - Middle upper outer left thigh
if (currentSite.siteNumber == 3)
Positioned(
top: 110,
left: 15,
child: _buildHighlightedSite(3),
),
// Site 4 - Middle upper inner left thigh
if (currentSite.siteNumber == 4)
Positioned(
top: 110,
right: 15,
child: _buildHighlightedSite(4),
),
// Site 5 - Middle lower outer left thigh
if (currentSite.siteNumber == 5)
Positioned(
top: 170,
left: 15,
child: _buildHighlightedSite(5),
),
// Site 6 - Middle lower inner left thigh
if (currentSite.siteNumber == 6)
Positioned(
top: 170,
right: 15,
child: _buildHighlightedSite(6),
),
// Site 7 - Lower outer left thigh
if (currentSite.siteNumber == 7)
Positioned(
top: 230,
left: 15,
child: _buildHighlightedSite(7),
),
// Site 8 - Lower inner left thigh
if (currentSite.siteNumber == 8)
Positioned(
top: 230,
right: 15,
child: _buildHighlightedSite(8),
),
],
),
],
);
} else {
// Right thigh sites
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
children: [
Container(
width: 130,
height: 320,
decoration: BoxDecoration(
border: Border.all(color: AppColors.outline),
borderRadius: BorderRadius.circular(30),
color: AppColors.surface.withAlpha(80),
),
child: Stack(
children: [
// Inner/Outer separator line
Positioned(
top: 0,
bottom: 0,
left: 65,
child: Container(
width: 1,
color: AppColors.outline.withAlpha(128),
),
),
// Right thigh label
Positioned(
top: 15,
left: 0,
right: 0,
child: Center(
child: Text(
'Right Thigh',
style: TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Thigh details
CustomPaint(
size: const Size(130, 320),
painter: ThighDetailPainter(isLeft: false),
),
// Inner label
Positioned(
top: 150,
left: 15,
child: RotatedBox(
quarterTurns: 3,
child: Text(
'Inner',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
// Outer label
Positioned(
top: 150,
right: 15,
child: RotatedBox(
quarterTurns: 1,
child: Text(
'Outer',
style: TextStyle(
fontSize: 12,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
// Site 9 - Upper inner right thigh
if (currentSite.siteNumber == 9)
Positioned(
top: 50,
left: 15,
child: _buildHighlightedSite(9),
),
// Site 10 - Upper outer right thigh
if (currentSite.siteNumber == 10)
Positioned(
top: 50,
right: 15,
child: _buildHighlightedSite(10),
),
// Site 11 - Middle upper inner right thigh
if (currentSite.siteNumber == 11)
Positioned(
top: 110,
left: 15,
child: _buildHighlightedSite(11),
),
// Site 12 - Middle upper outer right thigh
if (currentSite.siteNumber == 12)
Positioned(
top: 110,
right: 15,
child: _buildHighlightedSite(12),
),
// Site 13 - Middle lower inner right thigh
if (currentSite.siteNumber == 13)
Positioned(
top: 170,
left: 15,
child: _buildHighlightedSite(13),
),
// Site 14 - Middle lower outer right thigh
if (currentSite.siteNumber == 14)
Positioned(
top: 170,
right: 15,
child: _buildHighlightedSite(14),
),
// Site 15 - Lower inner right thigh
if (currentSite.siteNumber == 15)
Positioned(
top: 230,
left: 15,
child: _buildHighlightedSite(15),
),
// Site 16 - Lower outer right thigh
if (currentSite.siteNumber == 16)
Positioned(
top: 230,
right: 15,
child: _buildHighlightedSite(16),
),
],
),
],
);
}
}
Widget _buildHighlightedSitePosition({
double? top,
double? bottom,
double? left,
double? right,
required int siteNumber,
}) {
return Positioned(
top: top,
bottom: bottom,
left: left,
right: right,
child: _buildHighlightedSite(siteNumber),
);
}
Widget _buildHighlightedSite(int siteNumber) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.tertiary,
border: Border.all(
color: AppColors.tertiary,
width: 3,
),
boxShadow: [
BoxShadow(
color: AppColors.tertiary.withAlpha(160),
blurRadius: 12,
spreadRadius: 5,
),
],
),
child: Center(
child: Text(
'$siteNumber',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.onTertiary,
),
),
),
);
}
}
// Custom painter for abdomen guidelines
class AbdomenLinePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = AppTheme.grey.withAlpha(76)
..strokeWidth = 1
..style = PaintingStyle.stroke;
// Horizontal dividing line
canvas.drawLine(
Offset(0, size.height / 2),
Offset(size.width, size.height / 2),
paint,
);
// Vertical dividing line
canvas.drawLine(
Offset(size.width / 2, 0),
Offset(size.width / 2, size.height),
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// Custom painter for thigh details
class ThighDetailPainter extends CustomPainter {
final bool isLeft;
ThighDetailPainter({required this.isLeft});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = AppTheme.grey.withAlpha(50)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
// Draw muscle outline
final path = Path();
if (isLeft) {
// Left thigh - quadriceps outline
path.moveTo(size.width * 0.35, size.height * 0.12);
path.quadraticBezierTo(size.width * 0.2, size.height * 0.2,
size.width * 0.15, size.height * 0.4);
path.quadraticBezierTo(size.width * 0.1, size.height * 0.6,
size.width * 0.25, size.height * 0.85);
// Muscle separation line
final muscleLine = Path();
muscleLine.moveTo(size.width * 0.25, size.height * 0.2);
muscleLine.quadraticBezierTo(size.width * 0.15, size.height * 0.5,
size.width * 0.3, size.height * 0.75);
canvas.drawPath(muscleLine, paint..strokeWidth = 0.5);
} else {
// Right thigh - quadriceps outline
path.moveTo(size.width * 0.65, size.height * 0.12);
path.quadraticBezierTo(size.width * 0.8, size.height * 0.2,
size.width * 0.85, size.height * 0.4);
path.quadraticBezierTo(size.width * 0.9, size.height * 0.6,
size.width * 0.75, size.height * 0.85);
// Muscle separation line
final muscleLine = Path();
muscleLine.moveTo(size.width * 0.75, size.height * 0.2);
muscleLine.quadraticBezierTo(size.width * 0.85, size.height * 0.5,
size.width * 0.7, size.height * 0.75);
canvas.drawPath(muscleLine, paint..strokeWidth = 0.5);
}
canvas.drawPath(path, paint);
// Draw knee area
final kneePaint = Paint()
..color = AppTheme.grey.withAlpha(38)
..strokeWidth = 1
..style = PaintingStyle.stroke;
canvas.drawCircle(
Offset(size.width * (isLeft ? 0.3 : 0.7), size.height * 0.9),
size.width * 0.15,
kneePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View file

@ -0,0 +1,408 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication_detail_screen.dart
// Screen that displays more detailed information about a medication
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/utils/get_icons_colors.dart';
import 'package:nokken/src/core/utils/get_labels.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
class MedicationDetailScreen extends ConsumerWidget {
final Medication medication;
const MedicationDetailScreen({
super.key,
required this.medication,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text(medication.name),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(AppIcons.getIcon('arrow_back')),
onPressed: () => NavigationService.goHome(context),
),
],
),
actions: [
// Edit button
TextButton(
onPressed: () => NavigationService.goToMedicationAddEdit(context,
medication: medication),
child: Text(
'Edit',
style: AppTextStyles.titleMedium,
),
),
],
),
body: ListView(
padding: AppTheme.standardCardPadding,
children: [
// Refill alert banner
if (medication.needsRefill())
Card(
color: AppColors.errorContainer,
child: Padding(
padding: AppTheme.standardCardPadding,
child: Row(
children: [
Icon(
AppIcons.getIcon('warning'),
color: AppColors.error,
),
SharedWidgets.verticalSpace(),
Text(
'Refill needed',
style: AppTextStyles.error,
),
],
),
),
),
// Basic medication information card
Card(
child: Padding(
padding: AppTheme.standardCardPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
GetIconsColors.getMedicationIconCirlce(
medication.medicationType),
SharedWidgets.horizontalSpace(),
Expanded(
child: Text(
medication.name,
style: AppTextStyles.titleLarge,
overflow: TextOverflow.ellipsis,
),
),
],
),
SharedWidgets.verticalSpace(AppTheme.cardPadding),
SharedWidgets.buildInfoRow('Dosage', medication.dosage),
SharedWidgets.verticalSpace(),
SharedWidgets.buildInfoRow(
'Type', GetLabels.getMedicationTypeText(medication)),
SharedWidgets.verticalSpace(),
SharedWidgets.buildInfoRow('Frequency',
DateTimeFormatter.formatMedicationFrequency(medication)),
SharedWidgets.verticalSpace(),
if (medication.currentQuantity > 0 ||
medication.refillThreshold > 0)
SharedWidgets.buildInfoRow('Remaining / Refill',
'${medication.currentQuantity} / ${medication.refillThreshold}'),
],
),
),
),
SharedWidgets.verticalSpace(AppTheme.cardSpacing),
// Healthcare Providers card (if either doctor or pharmacy is provided)
if (medication.doctor != null || medication.pharmacy != null) ...[
SharedWidgets.basicCard(
context: context,
title: 'Healthcare Providers',
children: [
if (medication.doctor != null)
SharedWidgets.buildInfoRow('Doctor', medication.doctor!),
if (medication.doctor != null && medication.pharmacy != null)
SharedWidgets.verticalSpace(),
if (medication.pharmacy != null)
SharedWidgets.buildInfoRow('Pharmacy', medication.pharmacy!),
],
),
SharedWidgets.verticalSpace(AppTheme.cardSpacing),
],
// Schedule information card
SharedWidgets.basicCard(
context: context,
title: medication.medicationType == MedicationType.patch
? 'Change Schedule'
: 'Schedule',
children: [
// Display schedule type
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
medication.asNeeded
? Icon(Icons.event_available)
: Icon(AppIcons.getIcon('calendar')),
SharedWidgets.horizontalSpace(),
Expanded(
child: Text(
medication.asNeeded
? 'Take as needed'
: DateTimeFormatter.formatDaysOfWeek(
medication.daysOfWeek),
style: AppTextStyles.bodyLarge),
),
],
),
// Only show time slots for scheduled medications (not as-needed)
if (!medication.asNeeded) ...[
SharedWidgets.verticalSpace(AppTheme.spacing * 2),
// Sort and display all time slots chronologically
...(() {
// Create a sorted copy of all time slots
final sortedTimes = List<DateTime>.from(medication.timeOfDay);
sortedTimes.sort((a, b) {
final aTimeStr = DateTimeFormatter.formatTimeToAMPM(
TimeOfDay.fromDateTime(a));
final bTimeStr = DateTimeFormatter.formatTimeToAMPM(
TimeOfDay.fromDateTime(b));
return DateTimeFormatter.compareTimeSlots(
aTimeStr, bTimeStr);
});
// Convert each time slot to a UI element
return sortedTimes.map((time) {
final timeOfDay = TimeOfDay.fromDateTime(time);
final timeStr =
DateTimeFormatter.formatTimeToAMPM(timeOfDay);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(DateTimeFormatter.getTimeIcon(timeStr)),
SharedWidgets.horizontalSpace(AppTheme.spacing),
Text(timeStr, style: AppTextStyles.bodyMedium),
],
),
);
}).toList();
})(),
],
],
),
SharedWidgets.verticalSpace(AppTheme.cardSpacing),
// Injection details card - only shown for injection medications
if (medication.medicationType == MedicationType.injection &&
medication.injectionDetails != null) ...[
SharedWidgets.basicCard(
context: context,
title: 'Injection Details',
children: [
SharedWidgets.buildInfoRow(
'Type',
GetLabels.getInjectionSubtypeText(
medication.injectionDetails!.subtype)),
SharedWidgets.verticalSpace(AppTheme.spacing * 2),
// Injection Site Rotation - NEW SECTION
if (medication.injectionDetails!.siteRotation != null &&
medication
.injectionDetails!.siteRotation!.sites.isNotEmpty) ...[
Text('Next Injection Site', style: AppTextStyles.titleSmall),
SharedWidgets.verticalSpace(),
_buildNextInjectionSite(medication.injectionDetails!),
SharedWidgets.verticalSpace(AppTheme.spacing * 2),
// Button to advance to next site
/* Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
icon: Icon(AppIcons.getIcon('redo')),
label: const Text('Advance to next site'),
onPressed: () {
_advanceToNextSite(context, ref);
},
),
),*/
SharedWidgets.verticalSpace(),
] else if (medication.injectionDetails!.subtype !=
InjectionSubtype.intravenous) ...[
// Only show this for IM and SC injections (not IV)
Text('Injection Site Rotation',
style: AppTextStyles.titleSmall),
SharedWidgets.verticalSpace(),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.info.withAlpha(20),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(AppIcons.getIcon('info'),
color: AppColors.info, size: 20),
SharedWidgets.horizontalSpace(),
const Expanded(
child: Text(
'No injection site rotation configured. Edit this medication to set up site rotation.',
style: TextStyle(fontStyle: FontStyle.italic),
),
),
],
),
),
SharedWidgets.verticalSpace(AppTheme.spacing * 2),
],
// Syringes section
if (medication.injectionDetails!.syringeType != '' ||
medication.injectionDetails!.syringeRefills > 0) ...[
Text('Syringes', style: AppTextStyles.titleMedium),
SharedWidgets.verticalSpace(),
],
if (medication.injectionDetails!.syringeType != '') ...[
SharedWidgets.buildInfoRow(
'Type', medication.injectionDetails!.syringeType),
SharedWidgets.verticalSpace(),
],
if (medication.injectionDetails!.syringeCount > 0 ||
medication.injectionDetails!.syringeRefills > 0) ...[
SharedWidgets.buildInfoRow('Remaining / Refill',
'${medication.injectionDetails!.syringeCount} / ${medication.injectionDetails!.syringeRefills.toString()}'),
SharedWidgets.verticalSpace(),
],
// Drawing Needles section
if (medication.injectionDetails!.drawingNeedleType != '' ||
medication.injectionDetails!.drawingNeedleRefills > 0) ...[
Text('Drawing Needles', style: AppTextStyles.titleMedium),
SharedWidgets.verticalSpace(),
],
if (medication.injectionDetails!.drawingNeedleType != '') ...[
SharedWidgets.buildInfoRow(
'Type', medication.injectionDetails!.drawingNeedleType),
SharedWidgets.verticalSpace(),
],
if (medication.injectionDetails!.drawingNeedleCount > 0 ||
medication.injectionDetails!.drawingNeedleRefills > 0) ...[
SharedWidgets.buildInfoRow('Remaining / Refill',
'${medication.injectionDetails!.drawingNeedleCount} / ${medication.injectionDetails!.drawingNeedleRefills.toString()}'),
SharedWidgets.verticalSpace(),
],
// Injecting Needles section
if (medication.injectionDetails!.injectingNeedleType != '' ||
medication.injectionDetails!.injectingNeedleRefills >
0) ...[
Text('Injecting Needles', style: AppTextStyles.titleMedium),
SharedWidgets.verticalSpace(),
],
if (medication.injectionDetails!.injectingNeedleType != '') ...[
SharedWidgets.buildInfoRow(
'Type', medication.injectionDetails!.injectingNeedleType),
SharedWidgets.verticalSpace(),
],
if (medication.injectionDetails!.injectingNeedleCount > 0 ||
medication.injectionDetails!.injectingNeedleRefills >
0) ...[
SharedWidgets.buildInfoRow('Remaining / Refill',
'${medication.injectionDetails!.injectingNeedleCount} / ${medication.injectionDetails!.injectingNeedleRefills.toString()}'),
],
// Injection site notes
/* if (medication
.injectionDetails!.injectionSiteNotes.isNotEmpty) ...[
SharedWidgets.verticalSpace(AppTheme.spacing * 2),
Text('Injection Notes', style: AppTextStyles.titleSmall),
SharedWidgets.verticalSpace(),
Text(medication.injectionDetails!.injectionSiteNotes),
],
*/
],
),
SharedWidgets.verticalSpace(AppTheme.cardSpacing),
],
// Notes card - only shown if notes exist
if (medication.notes?.isNotEmpty == true) ...[
SharedWidgets.verticalSpace(AppTheme.cardSpacing),
SharedWidgets.basicCard(
context: context,
title: 'Notes',
children: [
Text(medication.notes!),
],
),
],
],
),
);
}
/// Build a widget showing the next injection site information
Widget _buildNextInjectionSite(InjectionDetails details) {
if (details.siteRotation == null || details.siteRotation!.sites.isEmpty) {
return const SizedBox.shrink();
}
final nextSite = details.siteRotation!.nextSite;
final bodyAreaText =
nextSite.bodyArea == InjectionBodyArea.abdomen ? 'Abdomen' : 'Thigh';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.tertiary.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.tertiary),
),
child: Row(
children: [
// Site number in circle
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.tertiary,
),
child: Center(
child: Text(
'${nextSite.siteNumber}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.onTertiary,
),
),
),
),
SharedWidgets.horizontalSpace(AppTheme.doubleSpacing),
// Body area text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
bodyAreaText,
style: AppTextStyles.titleMedium,
),
Text(
'Total sites: ${details.siteRotation!.sites.length}',
style: AppTextStyles.bodySmall,
),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,307 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication_list_screen.dart
// Screen that displays user medications in a list with sticky headers
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sticky_header/flutter_sticky_header.dart';
import 'package:nokken/src/core/utils/get_icons_colors.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
import 'package:nokken/src/core/utils/get_labels.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
/// This widget adds a sticky header decorator for each medication type section
class MedicationSectionWithStickyHeader extends StatelessWidget {
final String title;
final List<Medication> medications;
final MedicationType type;
const MedicationSectionWithStickyHeader({
super.key,
required this.title,
required this.medications,
required this.type,
});
@override
Widget build(BuildContext context) {
final typeColor = GetIconsColors.getMedicationColor(type);
return SliverStickyHeader(
header: Container(
color: AppColors.surface,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
// Icon based on medication type
GetIconsColors.getMedicationIconWithColor(type),
SharedWidgets.horizontalSpace(),
// Section title
Text(
title,
style: AppTextStyles.titleMedium.copyWith(
color: typeColor,
fontWeight: FontWeight.bold,
),
),
// Count badge
SharedWidgets.horizontalSpace(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: typeColor.withAlpha(20),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${medications.length}',
style: TextStyle(
fontSize: 12,
color: typeColor,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
MedicationListTile(medication: medications[index]),
childCount: medications.length,
),
),
);
}
}
class MedicationListScreen extends ConsumerWidget {
const MedicationListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch for changes to medication data using the grouped provider
final groupedMedications = ref.watch(groupedMedicationTypeProvider);
final isLoading = ref.watch(medicationsLoadingProvider);
final error = ref.watch(medicationsErrorProvider);
final needsRefill = ref.watch(medicationsByNeedRefillProvider);
// Check if there are any medications
final bool hasMedications = groupedMedications['oral']!.isNotEmpty ||
groupedMedications['injection']!.isNotEmpty ||
groupedMedications['topical']!.isNotEmpty ||
groupedMedications['patch']!.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('Medications'),
automaticallyImplyLeading: false,
actions: [
IconButton(
onPressed: () => NavigationService.goToMedicationAddEdit(context),
icon: Icon(AppIcons.getIcon('add')),
color: AppColors.onPrimary,
),
],
),
body: RefreshIndicator(
onRefresh: () =>
ref.read(medicationStateProvider.notifier).loadMedications(),
child: Column(
children: [
// Refill Alert Section - shown only when medications need refill
if (needsRefill.isNotEmpty)
Container(
color: AppColors.errorContainer.withAlpha(25),
padding: AppTheme.standardCardPadding,
child: Row(
children: [
Icon(
AppIcons.getIcon('warning'),
color: AppColors.error,
),
SharedWidgets.horizontalSpace(),
Expanded(
child: Text(
'${needsRefill.length} medication${needsRefill.length != 1 ? 's' : ''} need${needsRefill.length == 1 ? 's' : ''} refill',
style: TextStyle(
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
// Error Display - shown only when there's an error
if (error != null)
Container(
color: AppColors.errorContainer,
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
AppIcons.getIcon('error'),
color: AppColors.error,
),
SharedWidgets.horizontalSpace(),
Expanded(
child: Text(
error,
style: TextStyle(
color: AppColors.error,
),
),
),
],
),
),
// Main Content Area
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: !hasMedications
? _buildEmptyState(context)
: CustomScrollView(
slivers: [
// Only add sections that have medications
if (groupedMedications['oral']!.isNotEmpty)
MedicationSectionWithStickyHeader(
title: 'Oral',
medications: groupedMedications['oral']!,
type: MedicationType.oral,
),
if (groupedMedications['topical']!.isNotEmpty)
MedicationSectionWithStickyHeader(
title: 'Topical',
medications: groupedMedications['topical']!,
type: MedicationType.topical,
),
if (groupedMedications['patch']!.isNotEmpty)
MedicationSectionWithStickyHeader(
title: 'Patch',
medications: groupedMedications['patch']!,
type: MedicationType.patch,
),
if (groupedMedications['injection']!.isNotEmpty)
MedicationSectionWithStickyHeader(
title: 'Injectable',
medications: groupedMedications['injection']!,
type: MedicationType.injection,
),
// If we need spacing at the bottom, add a sliver padding
const SliverPadding(
padding:
EdgeInsets.only(bottom: 80), // Space for FAB
),
],
),
),
],
),
),
);
}
/// Builds the empty state view when no medications exist
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
AppIcons.getOutlined('medication'),
size: 64,
color: AppColors.secondary,
),
SharedWidgets.verticalSpace(),
Text(
'No medications yet',
style: AppTheme.titleLarge,
),
SharedWidgets.verticalSpace(),
ElevatedButton(
onPressed: () => NavigationService.goToMedicationAddEdit(context),
child: const Text('Add Medication'),
),
],
),
);
}
}
/// List tile for displaying a medication in the list
class MedicationListTile extends StatelessWidget {
final Medication medication;
const MedicationListTile({
super.key,
required this.medication,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
contentPadding: AppTheme.standardCardPadding,
title: Text(medication.name, style: AppTextStyles.titleMedium),
leading:
GetIconsColors.getMedicationIconCirlce(medication.medicationType),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SharedWidgets.verticalSpace(),
Row(
children: [
Text(GetLabels.getTypeBadgeText(medication)),
if (medication.currentQuantity != 0 ||
medication.refillThreshold != 0) ...[
const Text(''),
Icon(
Icons.inventory_2_outlined,
size: 16,
),
Text('${medication.currentQuantity}'),
],
],
),
SharedWidgets.verticalSpace(),
Text(DateTimeFormatter.formatMedicationFrequencyDosage(
medication)),
// Show refill indicator if needed
if (medication.needsRefill()) ...[
SharedWidgets.verticalSpace(),
Container(
padding: AppTheme.standardCardPadding,
decoration: BoxDecoration(
color: AppColors.errorContainer,
),
child: Text(
'Refill needed',
style: AppTextStyles.error,
),
),
],
],
),
onTap: () => NavigationService.goToMedicationDetails(context,
medication: medication)),
);
}
}

View file

@ -0,0 +1,648 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// mood_entry.dart
// Model for mood tracking entries
//
import 'package:uuid/uuid.dart';
/// Represents different mood ratings
enum MoodRating {
great,
good,
okay,
meh,
bad,
}
/// Represents different emotions that can be selected
enum Emotion {
excited,
relaxed,
happy,
hot,
hopeful,
calm,
grateful,
proud,
tired,
sad,
angry,
anxious,
stressed,
bored,
lonely,
sick,
}
/// Represents sleep quality levels
enum SleepQuality {
excellent,
good,
fair,
poor,
}
/// Represents energy levels
enum EnergyLevel {
high,
moderate,
low,
depleted,
}
/// Represents libido levels
enum LibidoLevel {
high,
normal,
low,
none,
}
/// Represents appetite or eating habits
enum AppetiteLevel {
increased,
normal,
decreased,
none,
}
/// Represents focus ability
enum FocusLevel {
sharp,
normal,
distracted,
unfocused,
}
/// Represents dysphoria levels
enum DysphoriaLevel {
none,
mild,
moderate,
severe,
}
/// Represents exercise levels
enum ExerciseLevel {
intense,
moderate,
light,
none,
}
/// Extension to add helpful methods to MoodRating enum
extension MoodRatingExtension on MoodRating {
String get displayName {
switch (this) {
case MoodRating.great:
return 'Great';
case MoodRating.good:
return 'Good';
case MoodRating.okay:
return 'Okay';
case MoodRating.meh:
return 'Meh';
case MoodRating.bad:
return 'Bad';
}
}
String get emoji {
switch (this) {
case MoodRating.great:
return '😁';
case MoodRating.good:
return '🙂';
case MoodRating.okay:
return '😐';
case MoodRating.meh:
return '😕';
case MoodRating.bad:
return '😭';
}
}
String get description {
switch (this) {
case MoodRating.great:
return 'Having an amazing day!';
case MoodRating.good:
return 'Having a good day';
case MoodRating.okay:
return 'Day is going okay';
case MoodRating.meh:
return 'Not the best day';
case MoodRating.bad:
return 'Having a difficult day';
}
}
}
/// Extension to add helpful methods to Emotion enum
extension EmotionExtension on Emotion {
String get displayName {
switch (this) {
case Emotion.excited:
return 'Excited';
case Emotion.relaxed:
return 'Relaxed';
case Emotion.happy:
return 'Happy';
case Emotion.hopeful:
return 'Hopeful';
case Emotion.calm:
return 'Calm';
case Emotion.grateful:
return 'Grateful';
case Emotion.proud:
return 'Proud';
case Emotion.tired:
return 'Tired';
case Emotion.sad:
return 'Sad';
case Emotion.angry:
return 'Angry';
case Emotion.anxious:
return 'Anxious';
case Emotion.stressed:
return 'Stressed';
case Emotion.bored:
return 'Bored';
case Emotion.lonely:
return 'Lonely';
case Emotion.hot:
return 'Hot';
case Emotion.sick:
return 'Sick';
}
}
String get emoji {
switch (this) {
case Emotion.excited:
return '🤩';
case Emotion.relaxed:
return '😌';
case Emotion.happy:
return '😊';
case Emotion.hopeful:
return '🌈';
case Emotion.calm:
return '😇';
case Emotion.grateful:
return '🙏';
case Emotion.proud:
return '🏆';
case Emotion.tired:
return '😴';
case Emotion.sad:
return '😢';
case Emotion.angry:
return '😡';
case Emotion.anxious:
return '😰';
case Emotion.stressed:
return '😫';
case Emotion.bored:
return '🥱';
case Emotion.lonely:
return '🥺';
case Emotion.hot:
return '🔥';
case Emotion.sick:
return '🤧';
}
}
}
/// Extension for SleepQuality enum
extension SleepQualityExtension on SleepQuality {
String get displayName {
switch (this) {
case SleepQuality.excellent:
return 'Excellent';
case SleepQuality.good:
return 'Good';
case SleepQuality.fair:
return 'Fair';
case SleepQuality.poor:
return 'Poor';
}
}
String get emoji {
switch (this) {
case SleepQuality.excellent:
return '😵';
case SleepQuality.good:
return '😴';
case SleepQuality.fair:
return '😌';
case SleepQuality.poor:
return '🥱';
}
}
}
/// Extension for EnergyLevel enum
extension EnergyLevelExtension on EnergyLevel {
String get displayName {
switch (this) {
case EnergyLevel.high:
return 'High';
case EnergyLevel.moderate:
return 'Normal';
case EnergyLevel.low:
return 'Low';
case EnergyLevel.depleted:
return 'Fatigued';
}
}
String get emoji {
switch (this) {
case EnergyLevel.high:
return '';
case EnergyLevel.moderate:
return '🔋';
case EnergyLevel.low:
return '🪫';
case EnergyLevel.depleted:
return '😴';
}
}
}
/// Extension for LibidoLevel enum
extension LibidoLevelExtension on LibidoLevel {
String get displayName {
switch (this) {
case LibidoLevel.high:
return 'High';
case LibidoLevel.normal:
return 'Normal';
case LibidoLevel.low:
return 'Low';
case LibidoLevel.none:
return 'None';
}
}
String get emoji {
switch (this) {
case LibidoLevel.high:
return '❤️‍🔥';
case LibidoLevel.normal:
return '❤️';
case LibidoLevel.low:
return '💔';
case LibidoLevel.none:
return '🤍';
}
}
}
/// Extension for AppetiteLevel enum
extension AppetiteLevelExtension on AppetiteLevel {
String get displayName {
switch (this) {
case AppetiteLevel.increased:
return 'Increased';
case AppetiteLevel.normal:
return 'Normal';
case AppetiteLevel.decreased:
return 'Decreased';
case AppetiteLevel.none:
return 'None';
}
}
String get emoji {
switch (this) {
case AppetiteLevel.increased:
return '🍽️';
case AppetiteLevel.normal:
return '🍲';
case AppetiteLevel.decreased:
return '🥄';
case AppetiteLevel.none:
return '🚫';
}
}
}
/// Extension for FocusLevel enum
extension FocusLevelExtension on FocusLevel {
String get displayName {
switch (this) {
case FocusLevel.sharp:
return 'Sharp';
case FocusLevel.normal:
return 'Normal';
case FocusLevel.distracted:
return 'Distracted';
case FocusLevel.unfocused:
return 'Unfocused';
}
}
String get emoji {
switch (this) {
case FocusLevel.sharp:
return '🔍';
case FocusLevel.normal:
return '👁️';
case FocusLevel.distracted:
return '🧠';
case FocusLevel.unfocused:
return '💫';
}
}
}
/// Extension for DysphoriaLevel enum
extension DysphoriaLevelExtension on DysphoriaLevel {
String get displayName {
switch (this) {
case DysphoriaLevel.none:
return 'None';
case DysphoriaLevel.mild:
return 'Mild';
case DysphoriaLevel.moderate:
return 'Moderate';
case DysphoriaLevel.severe:
return 'Severe';
}
}
String get emoji {
switch (this) {
case DysphoriaLevel.none:
return '';
case DysphoriaLevel.mild:
return '🪞';
case DysphoriaLevel.moderate:
return '⚠️';
case DysphoriaLevel.severe:
return '🚨';
}
}
}
/// Extension for ExerciseLevel enum
extension ExerciseLevelExtension on ExerciseLevel {
String get displayName {
switch (this) {
case ExerciseLevel.intense:
return 'Intense';
case ExerciseLevel.moderate:
return 'Moderate';
case ExerciseLevel.light:
return 'Light';
case ExerciseLevel.none:
return 'None';
}
}
String get emoji {
switch (this) {
case ExerciseLevel.intense:
return '🏋️';
case ExerciseLevel.moderate:
return '🚴';
case ExerciseLevel.light:
return '🚶';
case ExerciseLevel.none:
return '🛋️';
}
}
}
/// Custom exception for mood-related errors
class MoodEntryException implements Exception {
final String message;
MoodEntryException(this.message);
@override
String toString() => 'MoodEntryException: $message';
}
/// Primary model for mood tracking entries
class MoodEntry {
final String id;
final DateTime date;
final MoodRating mood;
final Set<Emotion> emotions;
final String? notes;
// New health categories
final SleepQuality? sleepQuality;
final EnergyLevel? energyLevel;
final LibidoLevel? libidoLevel;
final AppetiteLevel? appetiteLevel;
final FocusLevel? focusLevel;
final DysphoriaLevel? dysphoriaLevel;
final ExerciseLevel? exerciseLevel;
/// Constructor with validation
MoodEntry({
String? id,
required this.date,
required this.mood,
required this.emotions,
this.notes,
this.sleepQuality,
this.energyLevel,
this.libidoLevel,
this.appetiteLevel,
this.focusLevel,
this.dysphoriaLevel,
this.exerciseLevel,
}) : id = id ?? const Uuid().v4() {
_validate();
}
/// Validates mood entry fields
void _validate() {
// Add any validation logic here if needed
if (emotions.length > 10) {
throw MoodEntryException('Maximum of 10 emotions can be selected');
}
}
/// Convert to JSON format for database storage
Map<String, dynamic> toJson() {
return {
'id': id,
'date': date.toIso8601String(),
'mood': mood.toString(),
'emotions': emotions.map((e) => e.toString()).toList(),
'notes': notes,
'sleepQuality': sleepQuality?.toString(),
'energyLevel': energyLevel?.toString(),
'libidoLevel': libidoLevel?.toString(),
'appetiteLevel': appetiteLevel?.toString(),
'focusLevel': focusLevel?.toString(),
'dysphoriaLevel': dysphoriaLevel?.toString(),
'exerciseLevel': exerciseLevel?.toString(),
};
}
/// Create a MoodEntry instance from JSON (database record)
factory MoodEntry.fromJson(Map<String, dynamic> json) {
try {
// Parse mood from string (this should stay required with a default)
final moodStr = json['mood'] as String;
final mood = MoodRating.values.firstWhere(
(e) => e.toString() == moodStr,
orElse: () => MoodRating.okay, // Keep default for required field
);
// Parse emotions from string list
final emotionStrList = json['emotions'] as List<dynamic>;
final emotions = emotionStrList
.map((emotionStr) {
try {
return Emotion.values.firstWhere(
(e) => e.toString() == emotionStr,
);
} catch (_) {
// Skip invalid emotions
return null;
}
})
.where((e) => e != null)
.cast<Emotion>()
.toSet();
// Parse the health categories without defaults
SleepQuality? sleepQuality;
if (json['sleepQuality'] != null) {
try {
sleepQuality = SleepQuality.values.firstWhere(
(e) => e.toString() == json['sleepQuality'],
);
} catch (_) {
// If value doesn't match any enum, keep it null
sleepQuality = null;
}
}
// Repeat the same pattern for other categories
EnergyLevel? energyLevel;
if (json['energyLevel'] != null) {
try {
energyLevel = EnergyLevel.values.firstWhere(
(e) => e.toString() == json['energyLevel'],
);
} catch (_) {
energyLevel = null;
}
}
// Similar pattern for the rest of the optional categories
LibidoLevel? libidoLevel;
if (json['libidoLevel'] != null) {
try {
libidoLevel = LibidoLevel.values.firstWhere(
(e) => e.toString() == json['libidoLevel'],
);
} catch (_) {
libidoLevel = null;
}
}
AppetiteLevel? appetiteLevel;
if (json['appetiteLevel'] != null) {
try {
appetiteLevel = AppetiteLevel.values.firstWhere(
(e) => e.toString() == json['appetiteLevel'],
);
} catch (_) {
appetiteLevel = null;
}
}
FocusLevel? focusLevel;
if (json['focusLevel'] != null) {
try {
focusLevel = FocusLevel.values.firstWhere(
(e) => e.toString() == json['focusLevel'],
);
} catch (_) {
focusLevel = null;
}
}
DysphoriaLevel? dysphoriaLevel;
if (json['dysphoriaLevel'] != null) {
try {
dysphoriaLevel = DysphoriaLevel.values.firstWhere(
(e) => e.toString() == json['dysphoriaLevel'],
);
} catch (_) {
dysphoriaLevel = null;
}
}
ExerciseLevel? exerciseLevel;
if (json['exerciseLevel'] != null) {
try {
exerciseLevel = ExerciseLevel.values.firstWhere(
(e) => e.toString() == json['exerciseLevel'],
);
} catch (_) {
exerciseLevel = null;
}
}
return MoodEntry(
id: json['id'] as String,
date: DateTime.parse(json['date'] as String),
mood: mood,
emotions: emotions,
notes: json['notes'] as String?,
sleepQuality: sleepQuality,
energyLevel: energyLevel,
libidoLevel: libidoLevel,
appetiteLevel: appetiteLevel,
focusLevel: focusLevel,
dysphoriaLevel: dysphoriaLevel,
exerciseLevel: exerciseLevel,
);
} catch (e) {
throw MoodEntryException('Invalid mood entry data: $e');
}
}
/// Create a copy of this mood entry with updated fields
MoodEntry copyWith({
DateTime? date,
MoodRating? mood,
Set<Emotion>? emotions,
String? notes,
SleepQuality? sleepQuality,
EnergyLevel? energyLevel,
LibidoLevel? libidoLevel,
AppetiteLevel? appetiteLevel,
FocusLevel? focusLevel,
DysphoriaLevel? dysphoriaLevel,
ExerciseLevel? exerciseLevel,
}) {
return MoodEntry(
id: id,
date: date ?? this.date,
mood: mood ?? this.mood,
emotions: emotions ?? this.emotions,
notes: notes ?? this.notes,
sleepQuality: sleepQuality ?? this.sleepQuality,
energyLevel: energyLevel ?? this.energyLevel,
libidoLevel: libidoLevel ?? this.libidoLevel,
appetiteLevel: appetiteLevel ?? this.appetiteLevel,
focusLevel: focusLevel ?? this.focusLevel,
dysphoriaLevel: dysphoriaLevel ?? this.dysphoriaLevel,
exerciseLevel: exerciseLevel ?? this.exerciseLevel,
);
}
}

View file

@ -0,0 +1,244 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// mood_state.dart
// State management for mood entries using Riverpod
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
import 'package:nokken/src/core/services/database/database_service_mood.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
/// State class to handle loading and error states for mood entry data
class MoodState {
final List<MoodEntry> entries;
final bool isLoading;
final String? error;
const MoodState({
this.entries = const [],
this.isLoading = false,
this.error,
});
/// Create a new state object with updated fields
MoodState copyWith({
List<MoodEntry>? entries,
bool? isLoading,
String? error,
}) {
return MoodState(
entries: entries ?? this.entries,
isLoading: isLoading ?? this.isLoading,
error: error, // Pass null to clear error
);
}
}
/// Notifier class to handle mood state changes
class MoodNotifier extends StateNotifier<MoodState> {
final DatabaseService _databaseService;
MoodNotifier({required DatabaseService databaseService})
: _databaseService = databaseService,
super(const MoodState()) {
// Load mood entries when initialized
loadMoodEntries();
}
/// Load mood entries from the database
Future<void> loadMoodEntries() async {
try {
state = state.copyWith(isLoading: true, error: null);
final entries = await _databaseService.getAllMoodEntries();
state = state.copyWith(
entries: entries,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to load mood entries: $e',
);
}
}
/// Add a new mood entry to the database
Future<void> addMoodEntry(MoodEntry entry) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Save to database
await _databaseService.insertMoodEntry(entry);
// Update state immediately with new entry
state = state.copyWith(
entries: [...state.entries, entry],
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to add mood entry: $e',
);
}
}
/// Update an existing mood entry in the database
Future<void> updateMoodEntry(MoodEntry entry) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Update in database
await _databaseService.updateMoodEntry(entry);
// Update state immediately
state = state.copyWith(
entries:
state.entries.map((e) => e.id == entry.id ? entry : e).toList(),
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to update mood entry: $e',
);
}
}
/// Delete a mood entry
Future<void> deleteMoodEntry(String id) async {
try {
state = state.copyWith(isLoading: true, error: null);
// Delete from database
await _databaseService.deleteMoodEntry(id);
// Update state immediately by filtering out the deleted entry
state = state.copyWith(
entries: state.entries.where((e) => e.id != id).toList(),
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to delete mood entry: $e',
);
}
}
/// Get a mood entry for a specific date
Future<MoodEntry?> getMoodEntryForDate(DateTime date) async {
try {
// Check if we already have it in state
final normalizedDate = DateTime(date.year, date.month, date.day);
final existingEntry = state.entries.firstWhere(
(entry) => DateTime(entry.date.year, entry.date.month, entry.date.day)
.isAtSameMomentAs(normalizedDate),
orElse: () => throw Exception('Not found in state'),
);
return existingEntry;
} catch (_) {
try {
// Try to fetch from database
return await _databaseService.getMoodEntryForDate(date);
} catch (e) {
// Handle error but don't update state
return null;
}
}
}
}
//----------------------------------------------------------------------------
// PROVIDER DEFINITIONS
//----------------------------------------------------------------------------
/// Main state notifier provider for mood entries
final moodStateProvider = StateNotifierProvider<MoodNotifier, MoodState>((ref) {
final databaseService = ref.watch(databaseServiceProvider);
return MoodNotifier(databaseService: databaseService);
});
//----------------------------------------------------------------------------
// CONVENIENCE PROVIDERS
//----------------------------------------------------------------------------
/// Provider for accessing the list of mood entries
final moodEntriesProvider = Provider<List<MoodEntry>>((ref) {
return ref.watch(moodStateProvider).entries;
});
/// Provider for checking if mood entries are loading
final moodEntriesLoadingProvider = Provider<bool>((ref) {
return ref.watch(moodStateProvider).isLoading;
});
/// Provider for accessing mood entry loading errors
final moodEntriesErrorProvider = Provider<String?>((ref) {
return ref.watch(moodStateProvider).error;
});
/// Provider for getting a mood entry for a specific date
final moodEntryForDateProvider =
FutureProvider.family<MoodEntry?, DateTime>((ref, date) async {
final moodNotifier = ref.watch(moodStateProvider.notifier);
return await moodNotifier.getMoodEntryForDate(date);
});
/// Provider for getting mood entry dates
final moodEntryDatesProvider = Provider<Set<DateTime>>((ref) {
final entries = ref.watch(moodStateProvider).entries;
return entries.map((entry) {
final date = entry.date;
return DateTime(date.year, date.month, date.day);
}).toSet();
});
final filteredMoodEntriesProvider =
Provider.family<List<MoodEntry>, String>((ref, timeframe) {
final entries = ref.watch(moodEntriesProvider);
if (timeframe == 'All Time') {
return entries;
}
// Extract start date based on timeframe
final now = DateTime.now();
final DateTime startDate;
switch (timeframe) {
case 'Last 7 Days':
startDate = now.subtract(const Duration(days: 7));
break;
case 'Last 30 Days':
startDate = now.subtract(const Duration(days: 30));
break;
case 'Last 90 Days':
startDate = now.subtract(const Duration(days: 90));
break;
case 'Last 6 Months':
startDate = DateTime(now.year, now.month - 6, now.day);
break;
case 'Last Year':
startDate = DateTime(now.year - 1, now.month, now.day);
break;
default:
return entries;
}
return entries.where((entry) => entry.date.isAfter(startDate)).toList();
});
/// Provider for mood icon colors
final moodColorsProvider = Provider<Map<MoodRating, Color>>((ref) {
return {
MoodRating.great: Colors.green.shade400,
MoodRating.good: Colors.lightGreen.shade400,
MoodRating.okay: Colors.amber.shade400,
MoodRating.meh: Colors.orange.shade400,
MoodRating.bad: Colors.red.shade400,
};
});

View file

@ -0,0 +1,680 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// mood_tracker_screen.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/mood_tracker/utils/mood_utils.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/dialog_service.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
/// This widget adds a header decorator for each section in the mood tracker
class MoodSectionHeader extends StatelessWidget {
final String title;
final Color headerColor;
final Widget content;
final IconData? headerIcon;
const MoodSectionHeader({
super.key,
required this.title,
required this.headerColor,
required this.content,
this.headerIcon,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
color: AppColors.surface,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
// Icon if provided
if (headerIcon != null) ...[
Icon(
headerIcon,
size: 20,
color: headerColor,
),
SharedWidgets.horizontalSpace(),
],
// Section title
Text(
title,
style: AppTextStyles.titleMedium.copyWith(
color: headerColor,
fontWeight: FontWeight.bold,
),
),
],
),
),
// Content
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: content,
),
],
);
}
}
class MoodTrackerScreen extends ConsumerStatefulWidget {
final DateTime selectedDate;
final MoodEntry? existingEntry;
const MoodTrackerScreen({
super.key,
required this.selectedDate,
this.existingEntry,
});
@override
ConsumerState<MoodTrackerScreen> createState() => _MoodTrackerScreenState();
}
class _MoodTrackerScreenState extends ConsumerState<MoodTrackerScreen> {
late MoodRating _selectedMood;
late Set<Emotion> _selectedEmotions;
late SleepQuality? _selectedSleepQuality;
late EnergyLevel? _selectedEnergyLevel;
late LibidoLevel? _selectedLibidoLevel;
late AppetiteLevel? _selectedAppetiteLevel;
late FocusLevel? _selectedFocusLevel;
late DysphoriaLevel? _selectedDysphoriaLevel;
late ExerciseLevel? _selectedExerciseLevel;
late TextEditingController _notesController;
bool _isLoading = false;
@override
void initState() {
super.initState();
_selectedMood = widget.existingEntry?.mood ?? MoodRating.okay;
_selectedEmotions = widget.existingEntry?.emotions != null
? Set<Emotion>.from(widget.existingEntry!.emotions)
: {};
_selectedSleepQuality = widget.existingEntry?.sleepQuality;
_selectedEnergyLevel = widget.existingEntry?.energyLevel;
_selectedLibidoLevel = widget.existingEntry?.libidoLevel;
_selectedAppetiteLevel = widget.existingEntry?.appetiteLevel;
_selectedFocusLevel = widget.existingEntry?.focusLevel;
_selectedDysphoriaLevel = widget.existingEntry?.dysphoriaLevel;
_selectedExerciseLevel = widget.existingEntry?.exerciseLevel;
_notesController =
TextEditingController(text: widget.existingEntry?.notes ?? '');
}
@override
void dispose() {
_notesController.dispose();
super.dispose();
}
/// Save the current mood entry
Future<void> _saveMoodEntry() async {
setState(() => _isLoading = true);
try {
// First check if an entry for this date already exists in the database
final normalizedDate = DateTime(widget.selectedDate.year,
widget.selectedDate.month, widget.selectedDate.day);
final existingEntryInDb = await ref
.read(moodStateProvider.notifier)
.getMoodEntryForDate(normalizedDate);
// Create entry with all fields including the new health categories
final entry = MoodEntry(
// Use the existing entry's ID from the database if available
id: existingEntryInDb?.id ?? widget.existingEntry?.id,
date: widget.selectedDate,
mood: _selectedMood,
emotions: _selectedEmotions,
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
sleepQuality: _selectedSleepQuality,
energyLevel: _selectedEnergyLevel,
libidoLevel: _selectedLibidoLevel,
appetiteLevel: _selectedAppetiteLevel,
focusLevel: _selectedFocusLevel,
dysphoriaLevel: _selectedDysphoriaLevel,
exerciseLevel: _selectedExerciseLevel,
);
// Add or update entry based on database existence, not the widget's existingEntry
if (existingEntryInDb != null) {
await ref.read(moodStateProvider.notifier).updateMoodEntry(entry);
} else {
await ref.read(moodStateProvider.notifier).addMoodEntry(entry);
}
if (mounted) {
// Return true to indicate a successful save
NavigationService.goBackWithResult(context, true);
}
} catch (e) {
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving mood entry: $e'),
backgroundColor: AppColors.error,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
final formattedDate =
DateTimeFormatter.formatDateMMMDDYYYY(widget.selectedDate);
// Get the mood color for theming
final moodColor = MoodUtils.getMoodColor(_selectedMood);
return Scaffold(
appBar: AppBar(
title: Text(formattedDate),
leading: IconButton(
icon: Icon(AppIcons.getIcon('arrow_back')),
onPressed: () => _handleBackButton(context, ref),
),
actions: [
// Show loading indicator or save button
_isLoading
? const Center(
child: Padding(
padding: AppTheme.standardCardPadding,
child: CircularProgressIndicator(),
),
)
: TextButton(
onPressed: _saveMoodEntry,
child: Text(
'Save',
style: TextStyle(
color: AppColors.onPrimary,
fontWeight: FontWeight.w500),
),
),
],
),
body: CustomScrollView(
slivers: [
// Mood selector section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: '',
headerColor: moodColor,
content: _buildMoodSelectorContent(),
),
),
// Emotions selector section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'What emotions did you feel today?',
headerColor: moodColor,
content: _buildEmotionsSelectorContent(),
),
),
// Sleep quality section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'Sleep Quality',
headerColor: moodColor,
content: _buildCategorySelectorContent(
options: MoodUtils.getAllSleepQualities(),
selectedOption: _selectedSleepQuality,
getDisplayName: (option) => option.displayName,
getEmoji: (option) => option.emoji,
getColor: (option) => MoodUtils.getSleepQualityColor(option),
onOptionSelected: (option) {
setState(() {
_selectedSleepQuality = option;
});
},
),
),
),
// Energy level section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'Energy level',
headerColor: moodColor,
content: _buildCategorySelectorContent(
options: MoodUtils.getAllEnergyLevels(),
selectedOption: _selectedEnergyLevel,
getDisplayName: (option) => option.displayName,
getEmoji: (option) => option.emoji,
getColor: (option) => MoodUtils.getEnergyLevelColor(option),
onOptionSelected: (option) {
setState(() {
_selectedEnergyLevel = option;
});
},
),
),
),
// Libido level section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'Libido',
headerColor: moodColor,
content: _buildCategorySelectorContent(
options: MoodUtils.getAllLibidoLevels(),
selectedOption: _selectedLibidoLevel,
getDisplayName: (option) => option.displayName,
getEmoji: (option) => option.emoji,
getColor: (option) => MoodUtils.getLibidoLevelColor(option),
onOptionSelected: (option) {
setState(() {
_selectedLibidoLevel = option;
});
},
),
),
),
// Appetite level section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'Appetite',
headerColor: moodColor,
content: _buildCategorySelectorContent(
options: MoodUtils.getAllAppetiteLevels(),
selectedOption: _selectedAppetiteLevel,
getDisplayName: (option) => option.displayName,
getEmoji: (option) => option.emoji,
getColor: (option) => MoodUtils.getAppetiteLevelColor(option),
onOptionSelected: (option) {
setState(() {
_selectedAppetiteLevel = option;
});
},
),
),
),
// Focus level section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'Focus',
headerColor: moodColor,
content: _buildCategorySelectorContent(
options: MoodUtils.getAllFocusLevels(),
selectedOption: _selectedFocusLevel,
getDisplayName: (option) => option.displayName,
getEmoji: (option) => option.emoji,
getColor: (option) => MoodUtils.getFocusLevelColor(option),
onOptionSelected: (option) {
setState(() {
_selectedFocusLevel = option;
});
},
),
),
),
// Dysphoria level section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'Dysphoria',
headerColor: moodColor,
content: _buildCategorySelectorContent(
options: MoodUtils.getAllDysphoriaLevels(),
selectedOption: _selectedDysphoriaLevel,
getDisplayName: (option) => option.displayName,
getEmoji: (option) => option.emoji,
getColor: (option) => MoodUtils.getDysphoriaLevelColor(option),
onOptionSelected: (option) {
setState(() {
_selectedDysphoriaLevel = option;
});
},
),
),
),
// Exercise level section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'Exercise',
headerColor: moodColor,
content: _buildCategorySelectorContent(
options: MoodUtils.getAllExerciseLevels(),
selectedOption: _selectedExerciseLevel,
getDisplayName: (option) => option.displayName,
getEmoji: (option) => option.emoji,
getColor: (option) => MoodUtils.getExerciseLevelColor(option),
onOptionSelected: (option) {
setState(() {
_selectedExerciseLevel = option;
});
},
),
),
),
// Notes section
SliverToBoxAdapter(
child: MoodSectionHeader(
title: 'Notes',
headerColor: moodColor,
headerIcon: AppIcons.getIcon('note'),
content: _buildNotesContent(),
),
),
// Add padding at the bottom
const SliverPadding(
padding: EdgeInsets.only(bottom: 80),
),
],
),
);
}
/// Build the mood selector content
Widget _buildMoodSelectorContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Display the selected mood description
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
_selectedMood.description,
style: AppTextStyles.titleLarge.copyWith(
color: MoodUtils.getMoodColor(_selectedMood),
),
),
),
// Grid of mood options
GridView.count(
crossAxisCount: 5,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: MoodUtils.getAllMoodRatings().map((mood) {
final isSelected = _selectedMood == mood;
return _buildMoodOption(mood, isSelected);
}).toList(),
),
],
);
}
/// Build an individual mood option for the grid
Widget _buildMoodOption(MoodRating mood, bool isSelected) {
return GestureDetector(
onTap: () {
setState(() {
_selectedMood = mood;
});
},
child: Container(
margin: const EdgeInsets.all(8),
height: 50,
width: 50,
decoration: BoxDecoration(
color: isSelected
? MoodUtils.getMoodColor(mood)
: MoodUtils.getMoodColor(mood).withAlpha(60),
shape: BoxShape.circle,
border: isSelected
? Border.all(color: AppColors.primary, width: 2)
: null,
),
child: Center(
child: Text(
mood.emoji,
style: TextStyle(
fontSize: 24,
fontFamily: AppTheme.emojiStyle,
color: isSelected ? Colors.white : Colors.white.withAlpha(180),
),
),
),
),
);
}
/// Build the emotions selector content
Widget _buildEmotionsSelectorContent() {
final allEmotions = MoodUtils.getAllEmotions();
return GridView.count(
crossAxisCount: 4,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 0.7, // To allow space for description
mainAxisSpacing: 12,
crossAxisSpacing: 8,
children: allEmotions.map((emotion) {
final isSelected = _selectedEmotions.contains(emotion);
return _buildEmotionCircle(emotion, isSelected);
}).toList(),
);
}
/// Build an individual emotion as a circle with text underneath
Widget _buildEmotionCircle(Emotion emotion, bool isSelected) {
return GestureDetector(
onTap: () {
setState(() {
if (_selectedEmotions.contains(emotion)) {
_selectedEmotions.remove(emotion);
} else {
_selectedEmotions.add(emotion);
}
});
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Emoji circle
Container(
margin: const EdgeInsets.all(4),
height: 50,
width: 50,
decoration: BoxDecoration(
color: isSelected
? MoodUtils.getEmotionColor(emotion)
: MoodUtils.getEmotionColor(emotion).withAlpha(60),
shape: BoxShape.circle,
border: isSelected
? Border.all(color: AppColors.primary, width: 2)
: null,
),
child: Center(
child: Text(
emotion.emoji,
style: TextStyle(
fontSize: 24,
fontFamily: AppTheme.emojiStyle,
color:
isSelected ? Colors.white : Colors.white.withAlpha(180),
),
),
),
),
// Emotion name below the circle
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
child: Text(
emotion.displayName,
style: AppTextStyles.bodySmall.copyWith(
color: isSelected
? AppTextStyles.bodySmall.color
: AppTextStyles.bodySmall.color?.withAlpha(180),
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
/// Generic method to build category selector content
Widget _buildCategorySelectorContent<T>({
required List<T> options,
required T? selectedOption,
required String Function(T) getDisplayName,
required String Function(T) getEmoji,
required Color Function(T) getColor,
required void Function(T?) onOptionSelected,
}) {
return GridView.count(
crossAxisCount: 4,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 0.7, // To allow space for description
mainAxisSpacing: 12,
crossAxisSpacing: 8,
children: options.map((option) {
final isSelected = selectedOption == option;
return GestureDetector(
onTap: () {
// Toggle selection - if already selected, deselect it
if (isSelected) {
onOptionSelected(null);
} else {
onOptionSelected(option);
}
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Emoji circle
Container(
margin: const EdgeInsets.all(4),
height: 50,
width: 50,
decoration: BoxDecoration(
color: isSelected
? getColor(option)
: getColor(option).withAlpha(60),
shape: BoxShape.circle,
border: isSelected
? Border.all(color: AppColors.primary, width: 2)
: null,
),
child: Center(
child: Text(
getEmoji(option),
style: TextStyle(
fontSize: 24,
fontFamily: AppTheme.emojiStyle,
color: isSelected
? Colors.white
: Colors.white.withAlpha(180),
),
),
),
),
// Option name below the circle
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
child: Text(
getDisplayName(option),
style: AppTextStyles.bodySmall.copyWith(
color: isSelected
? AppTextStyles.bodySmall.color
: AppTextStyles.bodySmall.color?.withAlpha(180),
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
);
}
/// Build the notes content
Widget _buildNotesContent() {
return TextFormField(
controller: _notesController,
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
hintText: 'Write any thoughts or reflections here...',
),
maxLines: 4,
);
}
bool _checkForUnsavedChanges() {
// If there's no existing entry, check if any fields have been filled
if (widget.existingEntry == null) {
// Check if any optional fields are set or if there are any emotions selected or if notes are added
return _selectedSleepQuality != null ||
_selectedEnergyLevel != null ||
_selectedLibidoLevel != null ||
_selectedAppetiteLevel != null ||
_selectedFocusLevel != null ||
_selectedDysphoriaLevel != null ||
_selectedExerciseLevel != null ||
_selectedEmotions.isNotEmpty ||
_notesController.text.trim().isNotEmpty;
}
// Compare current values with existing entry values
final entry = widget.existingEntry!;
// Emotion set comparison
final emotionsDifferent =
_selectedEmotions.length != entry.emotions.length ||
_selectedEmotions.any((e) => !entry.emotions.contains(e)) ||
entry.emotions.any((e) => !_selectedEmotions.contains(e));
// Check all fields
return _selectedMood != entry.mood ||
emotionsDifferent ||
_selectedSleepQuality != entry.sleepQuality ||
_selectedEnergyLevel != entry.energyLevel ||
_selectedLibidoLevel != entry.libidoLevel ||
_selectedAppetiteLevel != entry.appetiteLevel ||
_selectedFocusLevel != entry.focusLevel ||
_selectedDysphoriaLevel != entry.dysphoriaLevel ||
_selectedExerciseLevel != entry.exerciseLevel ||
_notesController.text.trim() != (entry.notes ?? '');
}
void _handleBackButton(BuildContext context, WidgetRef ref) {
DialogService.handleBackWithUnsavedChanges(
context: context,
ref: ref,
checkForUnsavedChanges: _checkForUnsavedChanges,
saveFn: _saveMoodEntry,
);
}
}

View file

@ -0,0 +1,298 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// mood_utils.dart
// Utility functions for mood tracking
//
import 'package:flutter/material.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
/// Utility class for mood-related functions
class MoodUtils {
// Private constructor to prevent instantiation
MoodUtils._();
/// Get color for a mood rating
static Color getMoodColor(MoodRating mood) {
switch (mood) {
case MoodRating.great:
return AppColors.moodGreat;
case MoodRating.good:
return AppColors.moodGood;
case MoodRating.okay:
return AppColors.moodOkay;
case MoodRating.meh:
return AppColors.moodMeh;
case MoodRating.bad:
return AppColors.moodBad;
}
}
/// Get background color for a mood rating (lighter version)
static Color getMoodBackgroundColor(MoodRating mood) {
switch (mood) {
case MoodRating.great:
return Colors.green.shade100;
case MoodRating.good:
return Colors.lightGreen.shade100;
case MoodRating.okay:
return Colors.amber.shade100;
case MoodRating.meh:
return Colors.orange.shade100;
case MoodRating.bad:
return Colors.red.shade100;
}
}
/// Get color for an emotion
static Color getEmotionColor(Emotion emotion) {
switch (emotion) {
case Emotion.excited:
return Colors.deepPurple.shade300;
case Emotion.relaxed:
return Colors.blue.shade300;
case Emotion.happy:
return Colors.green.shade300;
case Emotion.hopeful:
return Colors.teal.shade300;
case Emotion.calm:
return Colors.lightBlue.shade300;
case Emotion.grateful:
return Colors.purple.shade200;
case Emotion.proud:
return Colors.yellow.shade400;
case Emotion.tired:
return Colors.grey.shade400;
case Emotion.sad:
return Colors.blueGrey.shade300;
case Emotion.angry:
return Colors.red.shade300;
case Emotion.anxious:
return Colors.amber.shade300;
case Emotion.stressed:
return Colors.orange.shade300;
case Emotion.bored:
return Colors.brown.shade300;
case Emotion.lonely:
return Colors.indigo.shade300;
case Emotion.hot:
return Colors.deepOrange.shade300;
case Emotion.sick:
return Colors.green.shade200;
}
}
static Color getEmotionColorString(String emotionName) {
final name = emotionName.toLowerCase();
if (name == 'happy') {
return Colors.amber;
} else if (name == 'sad') {
return Colors.blue;
} else if (name == 'angry') {
return Colors.red;
} else if (name == 'anxious') {
return Colors.orange;
} else if (name == 'relaxed') {
return Colors.teal;
} else if (name == 'tired') {
return Colors.indigo;
} else if (name == 'excited') {
return Colors.pink;
} else if (name == 'bored') {
return AppTheme.grey;
} else if (name == 'proud') {
return Colors.purple;
} else if (name == 'grateful') {
return Colors.green;
} else if (name == 'content') {
return Colors.lightBlue;
} else if (name == 'overwhelmed') {
return Colors.deepOrange;
} else {
return Colors.deepPurple;
}
}
static IconData getEmotionIcon(String emotionName) {
final name = emotionName.toLowerCase();
if (name == 'happy') {
return Icons.sentiment_very_satisfied;
} else if (name == 'sad') {
return Icons.sentiment_very_dissatisfied;
} else if (name == 'angry') {
return Icons.sentiment_very_dissatisfied;
} else if (name == 'anxious' || name == 'stressed') {
return Icons.psychology;
} else if (name == 'relaxed') {
return Icons.spa;
} else if (name == 'tired') {
return Icons.bedtime;
} else if (name == 'excited') {
return Icons.celebration;
} else if (name == 'bored') {
return Icons.sentiment_neutral;
} else if (name == 'proud') {
return Icons.military_tech;
} else if (name == 'grateful') {
return Icons.favorite;
} else if (name == 'content') {
return Icons.sentiment_satisfied;
} else if (name == 'overwhelmed') {
return Icons.waves;
} else {
return Icons.emoji_emotions;
}
}
/// Get color for sleep quality
static Color getSleepQualityColor(SleepQuality sleepQuality) {
switch (sleepQuality) {
case SleepQuality.excellent:
return Colors.indigo.shade300;
case SleepQuality.good:
return Colors.blue.shade300;
case SleepQuality.fair:
return Colors.blueGrey.shade300;
case SleepQuality.poor:
return AppTheme.grey;
}
}
/// Get color for energy level
static Color getEnergyLevelColor(EnergyLevel energyLevel) {
switch (energyLevel) {
case EnergyLevel.high:
return Colors.amber.shade400;
case EnergyLevel.moderate:
return Colors.amber.shade300;
case EnergyLevel.low:
return Colors.amber.shade200;
case EnergyLevel.depleted:
return AppTheme.grey;
}
}
/// Get color for libido level
static Color getLibidoLevelColor(LibidoLevel libidoLevel) {
switch (libidoLevel) {
case LibidoLevel.high:
return Colors.red.shade400;
case LibidoLevel.normal:
return Colors.pink.shade300;
case LibidoLevel.low:
return Colors.pink.shade200;
case LibidoLevel.none:
return AppTheme.grey;
}
}
/// Get color for appetite level
static Color getAppetiteLevelColor(AppetiteLevel appetiteLevel) {
switch (appetiteLevel) {
case AppetiteLevel.increased:
return Colors.green.shade400;
case AppetiteLevel.normal:
return Colors.green.shade300;
case AppetiteLevel.decreased:
return Colors.green.shade200;
case AppetiteLevel.none:
return AppTheme.grey;
}
}
/// Get color for focus level
static Color getFocusLevelColor(FocusLevel focusLevel) {
switch (focusLevel) {
case FocusLevel.sharp:
return Colors.purple.shade400;
case FocusLevel.normal:
return Colors.purple.shade300;
case FocusLevel.distracted:
return Colors.purple.shade200;
case FocusLevel.unfocused:
return AppTheme.grey;
}
}
/// Get color for dysphoria level
static Color getDysphoriaLevelColor(DysphoriaLevel dysphoriaLevel) {
switch (dysphoriaLevel) {
case DysphoriaLevel.none:
return Colors.teal.shade300;
case DysphoriaLevel.mild:
return Colors.yellow.shade400;
case DysphoriaLevel.moderate:
return Colors.orange.shade300;
case DysphoriaLevel.severe:
return Colors.red.shade400;
}
}
/// Get color for exercise level
static Color getExerciseLevelColor(ExerciseLevel exerciseLevel) {
switch (exerciseLevel) {
case ExerciseLevel.intense:
return Colors.deepPurple.shade400;
case ExerciseLevel.moderate:
return Colors.deepPurple.shade300;
case ExerciseLevel.light:
return Colors.deepPurple.shade200;
case ExerciseLevel.none:
return AppTheme.grey;
}
}
/// Get all mood ratings ordered from best to worst
static List<MoodRating> getAllMoodRatings() {
return [
MoodRating.great,
MoodRating.good,
MoodRating.okay,
MoodRating.meh,
MoodRating.bad,
];
}
/// Get all emotions
static List<Emotion> getAllEmotions() {
return Emotion.values;
}
/// Get all sleep quality options
static List<SleepQuality> getAllSleepQualities() {
return SleepQuality.values;
}
/// Get all energy level options
static List<EnergyLevel> getAllEnergyLevels() {
return EnergyLevel.values;
}
/// Get all libido level options
static List<LibidoLevel> getAllLibidoLevels() {
return LibidoLevel.values;
}
/// Get all appetite level options
static List<AppetiteLevel> getAllAppetiteLevels() {
return AppetiteLevel.values;
}
/// Get all focus level options
static List<FocusLevel> getAllFocusLevels() {
return FocusLevel.values;
}
/// Get all dysphoria level options
static List<DysphoriaLevel> getAllDysphoriaLevels() {
return DysphoriaLevel.values;
}
/// Get all exercise level options
static List<ExerciseLevel> getAllExerciseLevels() {
return ExerciseLevel.values;
}
}

View file

@ -0,0 +1,130 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// mood_field_chips.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/features/mood_tracker/utils/mood_utils.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
class MoodFieldChips {
// Helper method to build a consistent field chip
static Widget buildFieldChip(
MoodRating mood, String emoji, String text, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(40),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$emoji\u202F$text',
style: AppTextStyles.bodySmall.copyWith(
color: MoodUtils.getMoodColor(mood),
fontFamily: AppTheme.emojiStyle,
),
),
);
}
// Helper method to build field chips
static List<Widget> buildMoodFieldChips(MoodEntry entry) {
List<Widget> chips = [];
// Add emotions if present
if (entry.emotions.isNotEmpty) {
for (var e in entry.emotions) {
chips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: MoodUtils.getEmotionColor(e).withAlpha(40),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${e.emoji}\u202F${e.displayName}',
style: AppTextStyles.bodySmall.copyWith(
color: MoodUtils.getMoodColor(entry.mood),
fontFamily: AppTheme.emojiStyle,
),
),
),
);
}
}
// Add sleep quality if present
if (entry.sleepQuality != null) {
chips.add(buildFieldChip(
entry.mood,
entry.sleepQuality!.emoji,
'${entry.sleepQuality!.displayName}\u202Fsleep',
MoodUtils.getSleepQualityColor(entry.sleepQuality!),
));
}
// Add energy level if present
if (entry.energyLevel != null) {
chips.add(buildFieldChip(
entry.mood,
entry.energyLevel!.emoji,
'${entry.energyLevel!.displayName}\u202Fenergy',
MoodUtils.getEnergyLevelColor(entry.energyLevel!),
));
}
// Add libido level if present
if (entry.libidoLevel != null) {
chips.add(buildFieldChip(
entry.mood,
entry.libidoLevel!.emoji,
'${entry.libidoLevel!.displayName}\u202Flibido',
MoodUtils.getLibidoLevelColor(entry.libidoLevel!),
));
}
// Add appetite level if present
if (entry.appetiteLevel != null) {
chips.add(buildFieldChip(
entry.mood,
entry.appetiteLevel!.emoji,
'${entry.appetiteLevel!.displayName}\u202Fappetite',
MoodUtils.getAppetiteLevelColor(entry.appetiteLevel!),
));
}
// Add focus level if present
if (entry.focusLevel != null) {
chips.add(buildFieldChip(
entry.mood,
entry.focusLevel!.emoji,
'${entry.focusLevel!.displayName}\u202Ffocus',
MoodUtils.getFocusLevelColor(entry.focusLevel!),
));
}
// Add dysphoria level if present
if (entry.dysphoriaLevel != null) {
chips.add(buildFieldChip(
entry.mood,
entry.dysphoriaLevel!.emoji,
'${entry.dysphoriaLevel!.displayName}\u202Fdysphoria',
MoodUtils.getDysphoriaLevelColor(entry.dysphoriaLevel!),
));
}
// Add exercise level if present
if (entry.exerciseLevel != null) {
chips.add(buildFieldChip(
entry.mood,
entry.exerciseLevel!.emoji,
'${entry.exerciseLevel!.displayName}\u202Fexercise',
MoodUtils.getExerciseLevelColor(entry.exerciseLevel!),
));
}
return chips;
}
}

View file

@ -0,0 +1,269 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// mood_quick_selector.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/mood_tracker/utils/mood_utils.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/features/mood_tracker/widgets/mood_field_chips.dart';
import 'package:nokken/src/core/utils/list_extensions.dart';
/// Stateful widget for handling quick mood selection. used in daily_tracker
class MoodQuickSelector extends ConsumerStatefulWidget {
final DateTime selectedDate;
final MoodEntry? existingEntry;
final Function(MoodEntry?) onNavigateToMoodTracker;
const MoodQuickSelector({
super.key,
required this.selectedDate,
this.existingEntry,
required this.onNavigateToMoodTracker,
});
@override
ConsumerState<MoodQuickSelector> createState() => _MoodQuickSelectorState();
}
class _MoodQuickSelectorState extends ConsumerState<MoodQuickSelector>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _heightAnimation;
bool _isExpanded = false;
MoodEntry? _lastExistingEntry;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_heightAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
if (_isExpanded) {
_animationController.forward();
} else {
_animationController.reverse();
}
});
}
// Method to explicitly collapse the selector
void collapseSelector() {
if (_isExpanded) {
setState(() {
_isExpanded = false;
_animationController.reverse();
});
}
}
void _selectMood(MoodRating mood) {
// Create a temporary mood entry WITH NULL ID to indicate it's not from the database
final temporaryEntry = MoodEntry(
id: null, // Explicitly set ID to null
date: widget.selectedDate,
mood: mood,
emotions: {},
);
// Navigate to full mood tracker with the pre-selected mood
widget.onNavigateToMoodTracker(temporaryEntry);
}
@override
Widget build(BuildContext context) {
// Watch mood entries to ensure component rebuilds when entries change
final moodEntries = ref.watch(moodEntriesProvider);
// Check if there's now an entry for this date when there wasn't before
final currentEntry = moodEntries.firstWhereOrNull((entry) =>
DateTime(entry.date.year, entry.date.month, entry.date.day)
.isAtSameMomentAs(DateTime(widget.selectedDate.year,
widget.selectedDate.month, widget.selectedDate.day)));
// If we've transitioned from no entry to having an entry, collapse the selector
if (_lastExistingEntry == null && currentEntry != null && _isExpanded) {
// Close the selector when returning with a new entry
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isExpanded) {
_toggleExpanded();
}
});
}
// Update our reference for next comparison
_lastExistingEntry = currentEntry;
return Card(
child: Column(
children: [
// Main mood section that can be tapped
InkWell(
onTap: () {
if (widget.existingEntry != null) {
// If there's an existing entry, go directly to the edit screen
widget.onNavigateToMoodTracker(widget.existingEntry);
} else {
// Otherwise toggle the mood selector
_toggleExpanded();
}
},
child: Padding(
padding: AppTheme.standardCardPadding,
child: _buildMoodDisplay(),
),
),
// Animated mood options section
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return ClipRect(
child: Align(
heightFactor: _heightAnimation.value,
child: child,
),
);
},
child: _buildMoodOptions(),
),
],
),
);
}
Widget _buildMoodDisplay() {
if (widget.existingEntry != null) {
// Display existing mood
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.existingEntry!.mood.emoji,
style: TextStyle(fontSize: 32, fontFamily: AppTheme.emojiStyle),
),
SharedWidgets.horizontalSpace(),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.existingEntry!.mood.displayName,
style: AppTextStyles.titleLarge.copyWith(
color: MoodUtils.getMoodColor(widget.existingEntry!.mood),
fontWeight: FontWeight.bold,
),
),
SharedWidgets.verticalSpace(),
Wrap(
spacing: 8.0, // Space between chips
runSpacing: 4.0, // Space between lines
children:
MoodFieldChips.buildMoodFieldChips(widget.existingEntry!),
),
],
),
),
],
);
} else {
// Prompt to track mood
return Row(
children: [
Icon(
_isExpanded ? Icons.add_reaction : Icons.add_reaction,
size: 32,
color: AppColors.primary,
),
SharedWidgets.horizontalSpace(AppTheme.doubleSpacing),
Text(
_isExpanded ? 'How was your day?' : 'Tap to add mood record',
style: AppTextStyles.bodyMedium,
),
],
);
}
}
Widget _buildMoodOptions() {
return Padding(
padding: const EdgeInsets.only(bottom: 16, left: 16, right: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Divider between header and options
const Divider(),
SharedWidgets.verticalSpace(AppTheme.spacing),
// Row of mood options
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: MoodUtils.getAllMoodRatings().map((mood) {
return _buildMoodOption(mood);
}).toList(),
),
],
),
);
}
Widget _buildMoodOption(MoodRating mood) {
return GestureDetector(
onTap: () => _selectMood(mood),
child: Column(
children: [
// Mood emoji circle
Container(
margin: const EdgeInsets.all(4),
height: 40,
width: 40,
decoration: BoxDecoration(
color: MoodUtils.getMoodColor(mood).withAlpha(100),
shape: BoxShape.circle,
),
child: Center(
child: Text(
mood.emoji,
style: const TextStyle(
fontSize: 24,
fontFamily: AppTheme.emojiStyle,
),
),
),
),
// Mood name
Text(
mood.displayName,
style: AppTextStyles.bodySmall
.copyWith(color: MoodUtils.getMoodColor(mood)),
),
],
),
);
}
}

View file

@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// calendar_event_tracker.dart
// Tracks medication schedules and events for the calendar
//
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/features/bloodwork_tracker/providers/bloodwork_state.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/scheduler/services/medication_schedule_service.dart';
/// Provider to efficiently track medication dates for the calendar
final medicationDatesProvider = Provider.family<MedicationDateInfo, DateTime>(
(ref, focusedMonth) {
final medications = ref.watch(medicationsProvider);
final bloodworkDates = ref.watch(bloodworkDatesProvider);
final moodEntries = ref.watch(moodEntriesProvider);
// Create the medication date tracking service
final tracker = CalendarEventTracker(
medications: medications,
bloodworkDates: bloodworkDates,
moodEntries: moodEntries,
);
// Get the visible range for the calendar
final visibleRange = DateTimeFormatter.visibleCalendarRange(focusedMonth);
// Calculate medication dates for the visible range
return tracker.calculateMedicationDates(visibleRange);
},
dependencies: [
medicationsProvider,
bloodworkDatesProvider,
moodEntriesProvider
],
);
/// Provider for all medications
final medicationsProvider = StateProvider<List<Medication>>((ref) => []);
/// Data class to store medication date information
class MedicationDateInfo {
final Map<DateTime, bool> injectionDates;
final Map<DateTime, bool> patchDates;
final Map<DateTime, bool> oralDates;
final Map<DateTime, bool> topicalDates;
final Map<DateTime, AppointmentType?> bloodworkDates;
final Map<DateTime, MoodRating?> moodEntryDates;
MedicationDateInfo({
required this.injectionDates,
required this.patchDates,
required this.oralDates,
required this.topicalDates,
required this.bloodworkDates,
required this.moodEntryDates,
});
}
/// Tracks medication schedules and events for the calendar
///
/// Provides optimized access to medication and event data for specific date ranges
class CalendarEventTracker {
final List<Medication> medications;
final Set<DateTime> bloodworkDates;
final List<MoodEntry> moodEntries;
// Cached data for performance optimization
final Map<DateTime, bool> _cachedInjectionDates = {};
final Map<DateTime, bool> _cachedPatchDates = {};
final Map<DateTime, bool> _cachedOralDates = {};
final Map<DateTime, bool> _cachedTopicalDates = {};
final Map<DateTime, AppointmentType?> _cachedBloodworkTypes = {};
final Map<DateTime, MoodRating?> _cachedMoodEntries = {};
/// Creates a new CalendarEventTracker
CalendarEventTracker({
required this.medications,
required this.bloodworkDates,
required this.moodEntries,
});
/// Calculates medication dates for a specific date range
///
/// Efficiently caches results to avoid recalculation
MedicationDateInfo calculateMedicationDates(Iterable<DateTime> dateRange) {
// Clear previous cached data if medications have changed
final dateRangeSet = dateRange.toSet();
// Process medications by type
_processInjectionMedications(dateRangeSet);
_processPatchMedications(dateRangeSet);
_processOralMedications(dateRangeSet);
_processTopicalMedications(dateRangeSet);
// Process bloodwork dates
_processBloodworkDates();
// Process mood entries
_processMoodEntries();
// Return a snapshot of the current date information
return MedicationDateInfo(
injectionDates: Map.from(_cachedInjectionDates),
patchDates: Map.from(_cachedPatchDates),
oralDates: Map.from(_cachedOralDates),
topicalDates: Map.from(_cachedTopicalDates),
bloodworkDates: Map.from(_cachedBloodworkTypes),
moodEntryDates: Map.from(_cachedMoodEntries),
);
}
void _processMedicationsByType(Set<DateTime> dateRange,
MedicationType medicationType, Map<DateTime, bool> cacheMap) {
final filteredMedications = medications
.where((med) => med.medicationType == medicationType)
.toList();
if (filteredMedications.isEmpty) return;
for (final date in dateRange) {
final normalizedDate = DateTimeFormatter.normalizeDate(date);
// Skip if we've already calculated for this date
if (cacheMap.containsKey(normalizedDate)) continue;
// Check if any medications of this type are scheduled for this date
final hasMedication = filteredMedications.any((medication) {
final medsForDay = MedicationScheduleService.getMedicationsForDate(
[medication], normalizedDate);
return medsForDay.isNotEmpty;
});
cacheMap[normalizedDate] = hasMedication;
}
}
// Then use this generic method for each medication type
void _processInjectionMedications(Set<DateTime> dateRange) {
_processMedicationsByType(
dateRange, MedicationType.injection, _cachedInjectionDates);
}
void _processPatchMedications(Set<DateTime> dateRange) {
_processMedicationsByType(
dateRange, MedicationType.patch, _cachedPatchDates);
}
void _processOralMedications(Set<DateTime> dateRange) {
_processMedicationsByType(
dateRange, MedicationType.patch, _cachedOralDates);
}
void _processTopicalMedications(Set<DateTime> dateRange) {
_processMedicationsByType(
dateRange, MedicationType.patch, _cachedTopicalDates);
}
/// Processes bloodwork dates and appointment types
void _processBloodworkDates() {
for (final date in bloodworkDates) {
final normalizedDate = DateTimeFormatter.normalizeDate(date);
// Skip if we've already calculated for this date
if (_cachedBloodworkTypes.containsKey(normalizedDate)) continue;
// Set a default appointment type (will be updated later when we get the actual data)
_cachedBloodworkTypes[normalizedDate] = AppointmentType.bloodwork;
}
}
/// Processes mood entries to cache dates and ratings
void _processMoodEntries() {
for (final entry in moodEntries) {
final normalizedDate = DateTimeFormatter.normalizeDate(entry.date);
// Store the mood rating for this date
_cachedMoodEntries[normalizedDate] = entry.mood;
}
}
/// Gets the appointment type for a specific date
AppointmentType? getAppointmentTypeForDate(
DateTime date, List<Bloodwork> bloodworkRecords) {
final normalizedDate = DateTimeFormatter.normalizeDate(date);
// Check if we have cached the appointment type
if (_cachedBloodworkTypes.containsKey(normalizedDate)) {
return _cachedBloodworkTypes[normalizedDate];
}
// Get bloodwork records for this date
final recordsForDate = bloodworkRecords.where((record) {
final recordDate = DateTimeFormatter.normalizeDate(record.date);
return recordDate.isAtSameMomentAs(normalizedDate);
}).toList();
// Determine appointment type based on priority
if (recordsForDate.isNotEmpty) {
if (recordsForDate
.any((r) => r.appointmentType == AppointmentType.surgery)) {
_cachedBloodworkTypes[normalizedDate] = AppointmentType.surgery;
return AppointmentType.surgery;
} else if (recordsForDate
.any((r) => r.appointmentType == AppointmentType.appointment)) {
_cachedBloodworkTypes[normalizedDate] = AppointmentType.appointment;
return AppointmentType.appointment;
} else {
_cachedBloodworkTypes[normalizedDate] = AppointmentType.bloodwork;
return AppointmentType.bloodwork;
}
}
// No bloodwork records for this date
return null;
}
/// Checks if a date has an injection due
bool hasInjectionDue(DateTime date) {
final normalizedDate = DateTimeFormatter.normalizeDate(date);
return _cachedInjectionDates[normalizedDate] ?? false;
}
/// Checks if a date has a patch due
bool hasPatchDue(DateTime date) {
final normalizedDate = DateTimeFormatter.normalizeDate(date);
return _cachedPatchDates[normalizedDate] ?? false;
}
/// Checks if a date has oral medication due
bool hasOralMedicationDue(DateTime date) {
final normalizedDate = DateTimeFormatter.normalizeDate(date);
return _cachedOralDates[normalizedDate] ?? false;
}
/// Checks if a date has topical medication due
bool hasTopicalMedicationDue(DateTime date) {
final normalizedDate = DateTimeFormatter.normalizeDate(date);
return _cachedTopicalDates[normalizedDate] ?? false;
}
/// Gets the mood rating for a specific date
MoodRating? getMoodForDate(DateTime date) {
final normalizedDate = DateTimeFormatter.normalizeDate(date);
return _cachedMoodEntries[normalizedDate];
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,528 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// year_screen.dart
// Yearly view of all 365 days
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/constants/date_constants.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/utils/get_icons_colors.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/mood_tracker/utils/mood_utils.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/scheduler/services/medication_schedule_service.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/features/bloodwork_tracker/providers/bloodwork_state.dart';
import 'package:nokken/src/features/scheduler/screens/calendar_screen.dart';
//==============================================================================
// MAIN YEAR SCREEN
//==============================================================================
/// Year screen that displays a 12-month overview calendar
///
/// Shows all days of the year with visual indicators for medications,
/// appointments, and mood entries. Allows the user to select any day
/// to return to the monthly calendar view.
class YearScreen extends ConsumerStatefulWidget {
/// The initially selected date when opening the screen
final DateTime initialDate;
const YearScreen({
super.key,
required this.initialDate,
});
@override
ConsumerState<YearScreen> createState() => _YearScreenState();
}
class _YearScreenState extends ConsumerState<YearScreen> {
late int _selectedYear;
late Map<DateTime, MoodRating> _moodEntryDates;
late Set<DateTime> _injectionDueDates;
late Set<DateTime> _patchDueDates;
late Set<DateTime> _oralMedicationDates;
late Set<DateTime> _topicalMedicationDates;
late Set<DateTime> _bloodworkDates;
//----------------------------------------------------------------------------
// LIFECYCLE METHODS
//----------------------------------------------------------------------------
@override
void initState() {
super.initState();
_selectedYear = widget.initialDate.year;
_updateMoodEntryDates();
_updateMedicalDates();
}
//----------------------------------------------------------------------------
// DATA LOADING & PROCESSING METHODS
//----------------------------------------------------------------------------
/// Updates the map of dates that have mood entries
///
/// Loads all mood entries from the provider and creates a map
/// with normalized dates as keys and mood ratings as values.
void _updateMoodEntryDates() {
final moodEntries = ref.read(moodEntriesProvider);
_moodEntryDates = {};
// Populate map with normalized dates and their mood ratings
for (final entry in moodEntries) {
final normalizedDate = DateTime(
entry.date.year,
entry.date.month,
entry.date.day,
);
_moodEntryDates[normalizedDate] = entry.mood;
}
}
/// Updates all medical-related dates
///
/// Loads and calculates dates for all types of medications and appointments
void _updateMedicalDates() {
// Get medications
final medications = ref.read(medicationsProvider);
// Update injection dates
_injectionDueDates =
MedicationScheduleService.calculateInjectionDueDates(medications);
// Update patch, oral, and topical dates
_patchDueDates =
_calculateMedicationDueDates(medications, MedicationType.patch);
_oralMedicationDates =
_calculateMedicationDueDates(medications, MedicationType.oral);
_topicalMedicationDates =
_calculateMedicationDueDates(medications, MedicationType.topical);
// Update bloodwork dates
_bloodworkDates = ref.read(bloodworkDatesProvider);
}
Set<DateTime> _calculateMedicationDueDates(
List<Medication> medications, MedicationType medicationType) {
Set<DateTime> dueDates = {};
// Filter for medications of the specified type
final filteredMedications = medications
.where((med) => med.medicationType == medicationType)
.toList();
if (filteredMedications.isEmpty) {
return dueDates; // No medications of this type found
}
// Get dates for current year
final startDate = DateTime(_selectedYear, 1, 1);
final endDate = DateTime(_selectedYear, 12, 31);
// Use DateTimeFormatter for date iteration
for (final date in DateTimeFormatter.dateRange(startDate, endDate)) {
for (final medication in filteredMedications) {
// Check if this medication is scheduled for this date
final medsForDay =
MedicationScheduleService.getMedicationsForDate([medication], date);
if (medsForDay.isNotEmpty) {
dueDates.add(date); // Already normalized by dateRange
break; // Only add each date once
}
}
}
return dueDates;
}
//----------------------------------------------------------------------------
// DATE CHECKING METHODS
//----------------------------------------------------------------------------
/// Gets the mood rating for a specific date if it exists
MoodRating? _getMoodForDate(DateTime date) {
// Normalize date for comparison
final normalizedDate = DateTime(date.year, date.month, date.day);
// Check if this date has a mood entry
for (final entry in _moodEntryDates.entries) {
if (DateTime(
entry.key.year,
entry.key.month,
entry.key.day,
).isAtSameMomentAs(normalizedDate)) {
return entry.value;
}
}
return null;
}
/// Checks if a date has an injection due
bool _hasInjectionDue(DateTime date) {
// Compare just the date part, not time
final normalizedDate = DateTime(date.year, date.month, date.day);
return _injectionDueDates.any((injectionDate) {
final normalizedInjectionDate = DateTime(
injectionDate.year,
injectionDate.month,
injectionDate.day,
);
return normalizedInjectionDate.isAtSameMomentAs(normalizedDate);
});
}
/// Checks if a date has a patch due
bool _hasPatchDue(DateTime date) {
final normalizedDate = DateTime(date.year, date.month, date.day);
return _patchDueDates.any((patchDate) {
final normalizedPatchDate = DateTime(
patchDate.year,
patchDate.month,
patchDate.day,
);
return normalizedPatchDate.isAtSameMomentAs(normalizedDate);
});
}
/// Checks if a date has oral medication due
bool _hasOralMedicationDue(DateTime date) {
final normalizedDate = DateTime(date.year, date.month, date.day);
return _oralMedicationDates.any((oralDate) {
final normalizedOralDate = DateTime(
oralDate.year,
oralDate.month,
oralDate.day,
);
return normalizedOralDate.isAtSameMomentAs(normalizedDate);
});
}
/// Checks if a date has topical medication due
bool _hasTopicalMedicationDue(DateTime date) {
final normalizedDate = DateTime(date.year, date.month, date.day);
return _topicalMedicationDates.any((topicalDate) {
final normalizedTopicalDate = DateTime(
topicalDate.year,
topicalDate.month,
topicalDate.day,
);
return normalizedTopicalDate.isAtSameMomentAs(normalizedDate);
});
}
/// Checks if a date has bloodwork tests
bool _hasBloodworkOnDate(DateTime date) {
// Compare just the date part, not time
final normalizedDate = DateTime(date.year, date.month, date.day);
return _bloodworkDates.any((labDate) {
final normalizedLabDate = DateTime(
labDate.year,
labDate.month,
labDate.day,
);
return normalizedLabDate.isAtSameMomentAs(normalizedDate);
});
}
/// Gets the appointment type(a.k.a what color to display)
/// for a given date
/// order of priority:
/// surgery > appointment > bloodwork
AppointmentType? _getAppointmentTypeForDate(DateTime date) {
// Normalize date for comparison
final normalizedDate = DateTime(date.year, date.month, date.day);
// Get bloodwork records for this date
final bloodworkRecords = ref.read(bloodworkRecordsProvider).where((record) {
final recordDate =
DateTime(record.date.year, record.date.month, record.date.day);
return recordDate.isAtSameMomentAs(normalizedDate);
}).toList();
// Prioritize by appointment type
if (bloodworkRecords.isNotEmpty) {
if (bloodworkRecords
.any((r) => r.appointmentType == AppointmentType.surgery)) {
return AppointmentType.surgery;
} else if (bloodworkRecords
.any((r) => r.appointmentType == AppointmentType.appointment)) {
return AppointmentType.appointment;
} else {
return AppointmentType.bloodwork;
}
}
return null;
}
//----------------------------------------------------------------------------
// UI BUILDING METHODS
//----------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
// Update data when the UI is rebuilt
_updateMoodEntryDates();
_updateMedicalDates();
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(AppIcons.getIcon('chevron_left')),
onPressed: () {
setState(() {
_selectedYear--;
});
},
),
Text('$_selectedYear', style: AppTextStyles.titleLarge),
IconButton(
icon: Icon(AppIcons.getIcon('chevron_right')),
onPressed: () {
setState(() {
_selectedYear++;
});
},
),
],
),
centerTitle: true,
),
body: SingleChildScrollView(
child: Column(
children: [
// Add view mode tabs
_buildViewModeTabs(),
// Year calendar
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(12, (index) {
final month = index + 1;
return Expanded(
child: _buildMonthColumn(month, _selectedYear));
}),
),
),
],
),
),
);
}
/// Builds tabs for switching between medical and mood views
Widget _buildViewModeTabs() {
final viewMode = ref.watch(calendarViewModeProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Expanded(
child: _buildViewModeTab(
context,
icon: Icons.medication,
label: 'Medical',
isSelected: viewMode == CalendarViewMode.medical,
onTap: () {
ref.read(calendarViewModeProvider.notifier).state =
CalendarViewMode.medical;
},
),
),
SharedWidgets.horizontalSpace(8),
Expanded(
child: _buildViewModeTab(
context,
icon: Icons.mood,
label: 'Mood',
isSelected: viewMode == CalendarViewMode.mood,
onTap: () {
ref.read(calendarViewModeProvider.notifier).state =
CalendarViewMode.mood;
},
),
),
],
),
);
}
/// Builds a single view mode tab with appropriate styling
Widget _buildViewModeTab(
BuildContext context, {
required IconData icon,
required String label,
required bool isSelected,
required VoidCallback onTap,
}) {
return Material(
color: isSelected ? AppColors.primary : AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: isSelected ? AppColors.onPrimary : AppColors.onSurface,
size: 20,
),
SharedWidgets.horizontalSpace(8),
Text(
label,
style: TextStyle(
color: isSelected ? AppColors.onPrimary : AppColors.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
),
);
}
/// Builds a column for a specific month showing all days
Widget _buildMonthColumn(int month, int year) {
final daysInMonth = DateTime(year, month + 1, 0).day;
final monthName = DateConstants.months[month - 1];
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
children: [
// Month header
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
monthName,
style: AppTextStyles.titleSmall.copyWith(
color: AppColors.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 9,
),
),
),
SharedWidgets.verticalSpace(8),
// Days of the month
...List.generate(daysInMonth, (dayIndex) {
final day = dayIndex + 1;
final date = DateTime(year, month, day);
return _buildDayCircle(date);
}),
],
),
);
}
/// Builds a circle for a specific day with coloring based on view mode
///
/// Colors the day based on:
/// - Medical view: surgery > appointment > bloodwork > injection > patch > oral > topical
/// - Mood view: based on the recorded mood value
Widget _buildDayCircle(DateTime date) {
final viewMode = ref.watch(calendarViewModeProvider);
final isToday = DateTime.now().year == date.year &&
DateTime.now().month == date.month &&
DateTime.now().day == date.day;
// Calculate color for the day
Color circleColor = Colors.transparent;
Color textColor = AppColors.onSurface;
if (viewMode == CalendarViewMode.mood) {
// Mood mode coloring
final moodRating = _getMoodForDate(date);
if (moodRating != null) {
// If there's a mood entry, use its color
circleColor = MoodUtils.getMoodColor(moodRating);
textColor = AppTheme.white;
}
} else {
// Medical mode coloring - follow priority order (injections > patches > oral > topical)
final hasBloodwork = _hasBloodworkOnDate(date);
final hasInjection = _hasInjectionDue(date);
final hasPatch = _hasPatchDue(date);
final hasOral = _hasOralMedicationDue(date);
final hasTopical = _hasTopicalMedicationDue(date);
if (hasBloodwork) {
final appointmentType = _getAppointmentTypeForDate(date);
if (appointmentType != null) {
circleColor = GetIconsColors.getAppointmentColor(appointmentType);
textColor = AppTheme.white;
}
} else if (hasInjection) {
circleColor = AppColors.injection;
textColor = AppTheme.white;
} else if (hasPatch) {
circleColor = AppColors.patch;
textColor = AppTheme.white;
} else if (hasOral) {
circleColor = AppColors.oralMedication.withAlpha(140);
textColor = AppTheme.white;
} else if (hasTopical) {
circleColor = AppColors.topical.withAlpha(140);
textColor = AppTheme.white;
}
}
// Create border for today's date
final border =
isToday ? Border.all(color: AppColors.primary, width: 2) : null;
return GestureDetector(
onTap: () {
// Navigate back to the calendar screen with this date selected
Navigator.pop(context, date);
},
child: Container(
height: 28,
margin: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: circleColor,
border: border,
),
alignment: Alignment.center,
child: Text(
'${date.day}',
style: TextStyle(
fontSize: 12,
color: textColor,
fontWeight: isToday || circleColor != Colors.transparent
? FontWeight.bold
: FontWeight.normal,
),
),
),
);
}
}

View file

@ -0,0 +1,275 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// as_needed_medication_dialog.dart
// Dialog for selecting an as-needed medication to take
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/utils/get_icons_colors.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/medication_tracker/models/medication_dose.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/core/services/navigation/navigation_service.dart';
/// Provider to filter medications that are marked as "take as needed"
final asNeededMedicationsProvider = Provider<List<Medication>>((ref) {
final allMedications = ref.watch(medicationsProvider);
return allMedications.where((med) => med.asNeeded).toList();
});
/// Dialog for selecting an as-needed medication to take now
class AsNeededMedicationDialog extends ConsumerStatefulWidget {
/// Callback for when a medication is selected to be taken
final void Function(Medication medication, DateTime time) onTakeMedication;
const AsNeededMedicationDialog({
super.key,
required this.onTakeMedication,
});
@override
ConsumerState<AsNeededMedicationDialog> createState() =>
_AsNeededMedicationDialogState();
}
class _AsNeededMedicationDialogState
extends ConsumerState<AsNeededMedicationDialog> {
Medication? _selectedMedication;
TimeOfDay _selectedTime = TimeOfDay.now();
bool _isCustomTime = false;
@override
Widget build(BuildContext context) {
final asNeededMedications = ref.watch(asNeededMedicationsProvider);
// Sort medications by name for easier selection
asNeededMedications.sort((a, b) => a.name.compareTo(b.name));
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Dialog title
Text(
'Take As-Needed Medication',
style: AppTextStyles.titleLarge,
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
// If no as-needed medications are available, show a message
if (asNeededMedications.isEmpty)
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(
'You don\'t have any medications marked as "take as needed".',
textAlign: TextAlign.center,
style: AppTextStyles.bodyMedium,
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Navigate to add medication screen
NavigationService.goToMedicationAddEdit(context);
},
child: const Text('Add As-Needed Medication'),
),
],
),
)
else
// Medication dropdown and selection UI
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select Medication:',
style: AppTextStyles.titleSmall,
),
SharedWidgets.verticalSpace(),
// Custom medication selector that handles nullable values
InkWell(
onTap: () async {
final result = await showDialog<Medication>(
context: context,
barrierDismissible: true,
barrierColor: Colors.transparent,
builder: (context) {
return Dialog(
child: ListView.builder(
shrinkWrap: true,
itemCount: asNeededMedications.length,
itemBuilder: (context, index) {
final medication = asNeededMedications[index];
final isSelected = _selectedMedication !=
null &&
medication.id == _selectedMedication!.id;
return ListTile(
leading:
GetIconsColors.getMedicationIconWithColor(
medication.medicationType),
title: Text(medication.name),
subtitle: Text(medication.dosage),
selected: isSelected,
tileColor: isSelected
? AppColors.primary.withAlpha(20)
: null,
onTap: () =>
Navigator.of(context).pop(medication),
);
},
),
);
},
);
if (result != null) {
setState(() {
_selectedMedication = result;
});
}
},
child: InputDecorator(
decoration: AppTheme.defaultTextFieldDecoration.copyWith(
suffixIcon: Icon(Icons.arrow_drop_down),
),
child: _selectedMedication == null
? Text(
"Select a medication",
style: TextStyle(color: AppTheme.grey),
)
: Row(
children: [
GetIconsColors.getMedicationIconWithColor(
_selectedMedication!.medicationType),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedMedication!.name,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
// Time selector
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Use current time:',
style: AppTextStyles.titleSmall,
),
Switch(
value: !_isCustomTime,
onChanged: (value) {
setState(() {
_isCustomTime = !value;
if (!_isCustomTime) {
_selectedTime = TimeOfDay.now();
}
});
},
activeColor: AppColors.tertiary,
),
],
),
if (_isCustomTime) ...[
SharedWidgets.verticalSpace(),
InkWell(
onTap: () => _selectTime(context),
child: InputDecorator(
decoration:
AppTheme.defaultTextFieldDecoration.copyWith(
suffixIcon: Icon(Icons.access_time),
),
child: Text(
_formatTimeOfDay(_selectedTime),
style: AppTextStyles.bodyLarge,
),
),
),
],
SharedWidgets.verticalSpace(AppTheme.doubleSpacing),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel'),
),
SharedWidgets.horizontalSpace(),
ElevatedButton(
onPressed: _selectedMedication == null
? null
: () {
final now = DateTime.now();
final selectedDateTime = DateTime(
now.year,
now.month,
now.day,
_selectedTime.hour,
_selectedTime.minute,
);
widget.onTakeMedication(
_selectedMedication!,
selectedDateTime,
);
Navigator.of(context).pop();
},
child: Text('Take'),
),
],
),
],
),
],
),
),
);
}
/// Show time picker dialog
Future<void> _selectTime(BuildContext context) async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: _selectedTime,
);
if (picked != null && picked != _selectedTime) {
setState(() {
_selectedTime = picked;
});
}
}
/// Format time of day to display string
String _formatTimeOfDay(TimeOfDay time) {
final hour = time.hourOfPeriod == 0 ? 12 : time.hourOfPeriod;
final minute = time.minute.toString().padLeft(2, '0');
final period = time.period == DayPeriod.am ? 'AM' : 'PM';
return '$hour:$minute $period';
}
}

View file

@ -0,0 +1,171 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication_schedule_service.dart
// Service for handling medication scheduling logic
//
import 'package:flutter/material.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/medication_tracker/models/medication_dose.dart';
import 'package:nokken/src/core/constants/date_constants.dart';
import 'package:nokken/src/core/utils/date_time_formatter.dart';
class MedicationScheduleService {
/// Check if a medication is scheduled for the specified date
static bool isMedicationDueOnDate(Medication medication, DateTime date) {
return medication.isDueOnDate(date);
}
/// Get all medications due on a specific date
static List<Medication> getMedicationsForDate(
List<Medication> allMedications, DateTime date) {
return allMedications
.where((med) => isMedicationDueOnDate(med, date))
.toList();
}
/// Get all doses due on a specific date
static List<MedicationDose> getDosesForDate(
List<Medication> medications, DateTime date) {
final dueMeds = getMedicationsForDate(medications, date);
final doses = <MedicationDose>[];
for (final med in dueMeds) {
for (final timeSlot in med.timeOfDay) {
final formattedTime = DateTimeFormatter.formatTimeToAMPM(
TimeOfDay.fromDateTime(timeSlot));
doses.add(MedicationDose(
medicationId: med.id, date: date, timeSlot: formattedTime));
}
}
return doses;
}
/// Group medication doses by time slot
static Map<String, List<Medication>> groupMedicationsByTimeSlot(
List<Medication> medications) {
final Map<String, List<Medication>> result = {};
for (final med in medications) {
for (final time in med.timeOfDay) {
final timeStr =
DateTimeFormatter.formatTimeToAMPM(TimeOfDay.fromDateTime(time));
if (!result.containsKey(timeStr)) {
result[timeStr] = [];
}
result[timeStr]!.add(med);
}
}
return result;
}
/// Sorts medications by their first time slot of the day
///
/// Returns a new sorted list without modifying the original
static List<Medication> sortMedicationsByTimeOfDay(
List<Medication> medications) {
final sortedMeds = [...medications];
sortedMeds.sort((a, b) {
if (a.timeOfDay.isEmpty) return 1;
if (b.timeOfDay.isEmpty) return -1;
final aTime = a.timeOfDay.first;
final bTime = b.timeOfDay.first;
return (aTime.hour * 60 + aTime.minute) -
(bTime.hour * 60 + bTime.minute);
});
return sortedMeds;
}
/// Calculate all dates that have injections due for a set of medications
static Set<DateTime> calculateInjectionDueDates(
List<Medication> medications) {
Set<DateTime> injectionDates = {};
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
// Look back a year and ahead a year (total 730 days)
final startDate = today.subtract(const Duration(days: 365));
const daysToCalculate = 730;
for (var medication in medications) {
if (medication.medicationType == MedicationType.injection) {
// Use medication's start date if it's later than our lookback date
final medicationStartDate = DateTime(medication.startDate.year,
medication.startDate.month, medication.startDate.day);
final calculationStartDate = medicationStartDate.isAfter(startDate)
? medicationStartDate
: startDate;
if (medication.injectionDetails?.frequency ==
InjectionFrequency.weekly) {
_addWeeklyInjections(
injectionDates,
medication.daysOfWeek,
calculationStartDate,
daysToCalculate,
7, // Every 7 days
);
} else if (medication.injectionDetails?.frequency ==
InjectionFrequency.biweekly) {
_addWeeklyInjections(
injectionDates,
medication.daysOfWeek,
calculationStartDate,
daysToCalculate,
14, // Every 14 days
);
}
}
}
return injectionDates;
}
/// Add injection dates based on frequency and selected days
static void _addWeeklyInjections(
Set<DateTime> dates,
Set<String> daysOfWeek,
DateTime startDate,
int daysToLookAhead,
int frequency,
) {
// Convert days of week to int representation (0-6)
Set<int> weekdayNumbers = daysOfWeek
.map((day) => DateConstants.dayAbbreviationToWeekday(day))
.toSet();
// Calculate reference date for biweekly calculation
final referenceDate =
DateTime(startDate.year, startDate.month, startDate.day);
// Look through each day in the period
for (int i = 0; i < daysToLookAhead; i++) {
DateTime currentDate = startDate.add(Duration(days: i));
// Check if this day matches any of the target weekdays
if (weekdayNumbers.contains(currentDate.weekday % 7)) {
// For biweekly, we need to check if this is the right week
bool isCorrectFrequencyWeek = true;
if (frequency == 14) {
// Calculate days since reference date
final daysSinceReference =
currentDate.difference(referenceDate).inDays;
// Check if we're in the correct week
isCorrectFrequencyWeek = (daysSinceReference ~/ 7) % 2 == 0;
}
if (isCorrectFrequencyWeek) {
dates.add(currentDate);
}
}
}
}
}

View file

@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// calendar_day_cell.dart
// Reusable calendar day cell for medical and mood calendars
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/utils/get_icons_colors.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/utils/mood_utils.dart';
/// A reusable widget for rendering calendar day cells with different styles based on events
class CalendarDayCell extends StatelessWidget {
final DateTime day;
final bool isSelected;
final bool isToday;
final bool isOutsideMonth;
final AppointmentType? appointmentType;
final bool hasInjection;
final bool hasPatch;
final bool hasOral;
final bool hasTopical;
final MoodRating? moodRating;
final bool isMoodView;
/// Creates a calendar day cell with appropriate styling based on medical/mood events
const CalendarDayCell({
super.key,
required this.day,
this.isSelected = false,
this.isToday = false,
this.isOutsideMonth = false,
this.appointmentType,
this.hasInjection = false,
this.hasPatch = false,
this.hasOral = false,
this.hasTopical = false,
this.moodRating,
this.isMoodView = false,
});
@override
Widget build(BuildContext context) {
// Calculate border color based on event type and view mode
Color borderColor = _calculateBorderColor();
// Calculate text color based on event type, view mode, and cell selection state
Color textColor = _calculateTextColor(borderColor);
// Apply alpha for outside month dates
if (isOutsideMonth) {
borderColor = borderColor.withAlpha(160);
textColor = textColor.withAlpha(isSelected ? 255 : 160);
}
// Calculate the background color for the cell
Color backgroundColor = _calculateBackgroundColor(borderColor);
// Build the cell with appropriate styling
return Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: backgroundColor,
border: _hasMedicalOrMoodIndicator()
? Border.all(color: borderColor, width: 2)
: (isToday
? Border.all(color: AppColors.primary, width: 1.5)
: null),
),
child: Center(
child: Text(
'${day.day}',
style: TextStyle(
color: textColor,
fontWeight: _hasMedicalOrMoodIndicator() || isSelected || isToday
? FontWeight.bold
: FontWeight.normal,
),
),
),
);
}
/// Determines if the cell has any medical or mood indicators
bool _hasMedicalOrMoodIndicator() {
if (isMoodView) {
return moodRating != null;
} else {
return hasInjection ||
hasPatch ||
hasOral ||
hasTopical ||
appointmentType != null;
}
}
/// Calculates the border color based on cell state and event type
Color _calculateBorderColor() {
if (isMoodView) {
return moodRating != null
? MoodUtils.getMoodColor(moodRating!)
: AppColors.primary;
} else {
if (appointmentType != null) {
return GetIconsColors.getAppointmentColor(appointmentType!);
} else if (hasInjection) {
return AppColors.injection;
} else if (hasPatch) {
return AppColors.patch;
} else if (hasOral) {
return AppColors.oralMedication;
} else if (hasTopical) {
return AppColors.topical;
}
return AppColors.primary;
}
}
/// Calculates the text color based on cell state
Color _calculateTextColor(Color borderColor) {
if (isSelected) {
return AppColors.onPrimary;
} else if (isToday) {
return _hasMedicalOrMoodIndicator() ? borderColor : AppColors.primary;
} else if (_hasMedicalOrMoodIndicator()) {
return borderColor;
} else if (isOutsideMonth) {
return AppTheme.white.withAlpha(100);
}
return AppTheme.white;
}
/// Calculates the background color based on cell state
Color _calculateBackgroundColor(Color borderColor) {
if (isSelected) {
return _hasMedicalOrMoodIndicator() ? borderColor : AppColors.primary;
} else if (isToday) {
return AppColors.primary.withAlpha(40);
}
return Colors.transparent;
}
}

View file

@ -0,0 +1,296 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// admin_screen.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
import 'package:nokken/tools/mood_data_generator.dart';
import 'package:nokken/tools/medication_data_generator.dart';
import 'package:nokken/tools/bloodwork_data_generator.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_taken_provider.dart';
import 'package:nokken/src/features/bloodwork_tracker/providers/bloodwork_state.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
// Provider for DatabaseService
final databaseServiceProvider = Provider<DatabaseService>((ref) {
return DatabaseService();
});
class AdminScreen extends ConsumerStatefulWidget {
const AdminScreen({super.key});
@override
ConsumerState<AdminScreen> createState() => _AdminScreenState();
}
class _AdminScreenState extends ConsumerState<AdminScreen> {
bool _isLoading = false;
String _statusMessage = '';
String _currentAction = '';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Admin Tools'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Data Generation Tools',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Mood Data Generator
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Mood Data Generator',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Generates 365 days of random mood entries from 364 days ago to today.',
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _generateMoodData,
child: _isLoading && _currentAction == 'mood'
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('Generating...'),
],
)
: const Text('Generate Mood Data'),
),
],
),
),
),
const SizedBox(height: 16),
// Medication Data Generator
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Medication Data Generator',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Creates 5 medication types (2 pills, 1 topical, 1 patch, 1 injection) '
'and generates 365 days of adherence data with realistic rates: '
'95-97% for pills/topical and 96-98% for patches/injections.',
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _generateMedicationData,
child: _isLoading && _currentAction == 'medication'
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('Generating...'),
],
)
: const Text('Generate Medication Data'),
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bloodwork Data Generator',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Creates bloodwork labs every 70-110 days with hormone readings, '
'2-4 doctor appointments, and 1 surgery throughout the year.',
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _generateBloodworkData,
child: _isLoading && _currentAction == 'bloodwork'
? const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text('Generating...'),
],
)
: const Text('Generate Bloodwork Data'),
),
],
),
),
),
// Status message
if (_statusMessage.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.grey,
borderRadius: BorderRadius.circular(8),
),
child: Text(_statusMessage),
),
],
),
),
);
}
Future<void> _generateMoodData() async {
setState(() {
_isLoading = true;
_currentAction = 'mood';
_statusMessage = 'Generating mood data...';
});
try {
// Get the database service
final databaseService = ref.read(databaseServiceProvider);
final generator = MoodDataGenerator(databaseService);
await generator.generateMoodEntries();
// Refresh mood state by calling load method directly
await ref.read(moodStateProvider.notifier).loadMoodEntries();
setState(() {
_statusMessage = 'Successfully generated 365 mood entries!';
});
} catch (e) {
setState(() {
_statusMessage = 'Error generating mood data: $e';
});
} finally {
setState(() {
_isLoading = false;
_currentAction = '';
});
}
}
Future<void> _generateMedicationData() async {
setState(() {
_isLoading = true;
_currentAction = 'medication';
_statusMessage = 'Generating medication data...';
});
try {
// Get the database service
final databaseService = ref.read(databaseServiceProvider);
final generator = MedicationDataGenerator(databaseService);
await generator.generateMedicationData();
// Refresh medications by calling load method directly
await ref.read(medicationStateProvider.notifier).loadMedications();
// Refresh taken medications for today
final today = DateTime.now();
await ref
.read(medicationTakenProvider.notifier)
.loadTakenMedicationsForDate(today);
setState(() {
_statusMessage =
'Successfully generated medication data with realistic adherence patterns!';
});
} catch (e) {
setState(() {
_statusMessage = 'Error generating medication data: $e';
});
} finally {
setState(() {
_isLoading = false;
_currentAction = '';
});
}
}
Future<void> _generateBloodworkData() async {
setState(() {
_isLoading = true;
_currentAction = 'bloodwork';
_statusMessage = 'Generating bloodwork data...';
});
try {
// Get the database service
final databaseService = ref.read(databaseServiceProvider);
final generator = BloodworkDataGenerator(databaseService);
await generator.generateBloodworkData();
// Refresh bloodwork state by calling load method directly
await ref.read(bloodworkStateProvider.notifier).loadBloodwork();
setState(() {
_statusMessage = 'Successfully generated bloodwork data!';
});
} catch (e) {
setState(() {
_statusMessage = 'Error generating bloodwork data: $e';
});
} finally {
setState(() {
_isLoading = false;
_currentAction = '';
});
}
}
}

View file

@ -0,0 +1,180 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// settings_screen.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/features/settings/screens/admin_screen.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/providers/theme_provider.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch the theme provider to react to theme changes
final themeMode = ref.watch(themeProvider);
final isDarkMode = themeMode == ThemeMode.dark;
return Scaffold(
appBar: AppBar(
title: Text('Settings', style: AppTextStyles.headlineSmall),
backgroundColor: AppColors.primary,
),
body: Container(
color: AppColors.surface,
child: ListView(
children: [
// Display settings section
SharedWidgets.buildSectionHeader('Display'),
// Theme toggle
_buildSettingItem(
context,
title: 'Dark Mode',
subtitle: isDarkMode ? 'On' : 'Off',
icon: Icons.dark_mode,
trailing: Switch(
value: isDarkMode,
onChanged: (value) {
// Toggle theme
ThemeUtils.setTheme(
ref, value ? ThemeMode.dark : ThemeMode.light);
},
activeColor: AppColors.primary,
),
),
// Divider
Divider(color: AppColors.outline),
// Debug section - ONLY FOR DEVELOPMENT
SharedWidgets.buildSectionHeader('Developer Options'),
// Database debug button
_buildSettingItem(
context,
title: 'Debug Database',
subtitle: 'Check mood entries table',
icon: Icons.bug_report,
onTap: () => _showDatabaseDebugInfo(context),
),
// Admin tools button
_buildSettingItem(
context,
title: 'Admin Tools',
subtitle: 'Data generation and management',
icon: Icons.admin_panel_settings,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const AdminScreen(),
),
),
),
// Divider
Divider(color: AppColors.outline),
// About section
SharedWidgets.buildSectionHeader('About'),
// App version
_buildSettingItem(
context,
title: 'Version',
subtitle: '0.0.1',
icon: AppIcons.getIcon('info'),
),
],
),
),
);
}
}
/// Helper method to build consistent setting items
Widget _buildSettingItem(
BuildContext context, {
required String title,
required IconData icon,
String? subtitle,
Widget? trailing,
VoidCallback? onTap,
}) {
return ListTile(
leading: Icon(icon, color: AppColors.primary),
title: Text(title, style: AppTextStyles.titleMedium),
subtitle: subtitle != null
? Text(subtitle, style: AppTextStyles.bodySmall)
: null,
trailing: trailing,
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
);
}
Future<void> _showDatabaseDebugInfo(BuildContext context) async {
// Show loading dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
try {
// Get the database service
final dbService = DatabaseService();
final debugInfo = await dbService.getDebugInfo();
// Close loading dialog
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
// Show debug info dialog
showDialog(
// ignore: use_build_context_synchronously
context: context,
builder: (context) => AlertDialog(
title: const Text('Database Debug Info'),
content: SingleChildScrollView(
child: Text(debugInfo),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
} catch (e) {
// Close loading dialog
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
// Show error dialog
showDialog(
// ignore: use_build_context_synchronously
context: context,
builder: (context) => AlertDialog(
title: const Text('Error'),
content: Text('Failed to get debug info: $e'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
}
}

View file

@ -0,0 +1,246 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// correlations_chart.dart
//
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
/// A widget that displays correlation relationships between different factors as a visual chart.
///
/// This chart visualizes the strength of relationships between factors,
/// with correlation bars extending from left to right. The chart includes
/// scale markers at 0.25, 0.5, 0.75, and 1.0.
///
/// Each factor can be tapped to navigate to its detailed analysis when [onFactorTap] is provided.
class CorrelationsChart extends StatelessWidget {
final List<FactorRelationship> relationships;
final double height;
final Function(String)? onFactorTap;
const CorrelationsChart({
super.key,
required this.relationships,
this.height = 400,
this.onFactorTap,
});
@override
Widget build(BuildContext context) {
// Sort relationships by strength (descending)
final sortedRelationships = List<FactorRelationship>.from(relationships)
..sort((a, b) => b.strength.abs().compareTo(a.strength.abs()));
// Height calculation that accounts for the content
final calculatedHeight = math.min(
700.0, // Cap maximum height
math.max(height, sortedRelationships.length * 80.0),
);
return SizedBox(
height: calculatedHeight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Chart items
Expanded(
child: ListView.separated(
physics:
const ClampingScrollPhysics(), // Prevents overscroll effect
padding: EdgeInsets.zero,
itemCount: sortedRelationships.length,
separatorBuilder: (context, index) => Divider(
color: Colors.white,
height: 24,
thickness: 1.5,
),
itemBuilder: (context, index) {
return _buildChartItem(sortedRelationships[index], context);
},
),
),
],
),
);
}
/// Builds a single chart item representing one relationship
///
/// Each chart item includes factor buttons at the top and a correlation bar below
/// showing the strength of the relationship.
Widget _buildChartItem(
FactorRelationship relationship, BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Factor buttons
_buildFactorButtons(relationship),
const SizedBox(height: 12),
// Correlation bar
SizedBox(
height: 28,
child: LayoutBuilder(
builder: (context, constraints) {
final chartWidth = constraints.maxWidth;
// Use full width for the bar
final maxBarWidth = chartWidth;
// Calculate bar width based on strength
final barWidth = maxBarWidth * relationship.strength.abs();
return Stack(
clipBehavior: Clip.none,
children: [
// Guidelines at 0, 0.25, 0.5, 0.75, 1.0
// Draw vertical lines for guidelines
Positioned(
left: 0, // 0 position (start)
top: 0,
bottom: 0,
child:
Container(width: 1, color: AppColors.surfaceContainer),
),
Positioned(
left: chartWidth * 0.25, // 0.25 position
top: 0,
bottom: 0,
child: Container(
width: 1,
color: AppColors.surfaceContainer.withAlpha(180)),
),
Positioned(
left: chartWidth * 0.5, // 0.5 position
top: 0,
bottom: 0,
child: Container(
width: 1,
color: AppColors.surfaceContainer.withAlpha(180)),
),
Positioned(
left: chartWidth * 0.75, // 0.75 position
top: 0,
bottom: 0,
child: Container(
width: 1,
color: AppColors.surfaceContainer.withAlpha(180)),
),
Positioned(
left: chartWidth, // 1.0 position (end)
top: 0,
bottom: 0,
child: Container(
width: 1,
color: AppColors.surfaceContainer.withAlpha(180)),
),
// Add scale labels below the guidelines
Positioned(
left: 0 - 5,
bottom: -22,
child: Text('',
style: AppTextStyles.bodySmall.copyWith(fontSize: 10)),
),
Positioned(
left: chartWidth * 0.25 - 10,
bottom: -22,
child: Text('.25',
style: AppTextStyles.bodySmall.copyWith(fontSize: 10)),
),
Positioned(
left: chartWidth * 0.5 - 10,
bottom: -22,
child: Text('.5',
style: AppTextStyles.bodySmall.copyWith(fontSize: 10)),
),
Positioned(
left: chartWidth * 0.75 - 10,
bottom: -22,
child: Text('.75',
style: AppTextStyles.bodySmall.copyWith(fontSize: 10)),
),
Positioned(
left: chartWidth - 10,
bottom: -22,
child: Text('1',
style: AppTextStyles.bodySmall.copyWith(fontSize: 10)),
),
// Correlation bar - always starts at left (0)
Positioned(
left: 0, // Always start at left edge
top: 0,
height: 28,
width: barWidth,
child: Container(
decoration: BoxDecoration(
color: relationship.relationshipColor,
borderRadius: BorderRadius.circular(4),
),
),
),
],
);
},
),
),
const SizedBox(height: 22),
],
);
}
Widget _buildFactorButtons(FactorRelationship relationship) {
// Typically we have 2 factors in a relationship
if (relationship.factorNames.length < 2) {
return _buildSingleFactorButton(relationship.factorNames.first);
}
// Build row with both factor buttons and "and" text between
return Row(
mainAxisAlignment: MainAxisAlignment.start, // Left align the row
children: [
// First factor button
_buildSingleFactorButton(relationship.factorNames[0]),
// "and" text between buttons
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
"and",
style: AppTextStyles.bodyMedium,
),
),
// Second factor button
_buildSingleFactorButton(relationship.factorNames[1]),
],
);
}
Widget _buildSingleFactorButton(String factorName) {
return ElevatedButton(
onPressed: onFactorTap != null ? () => onFactorTap!(factorName) : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
backgroundColor: AppColors.primary.withAlpha(20),
foregroundColor: AppColors.primary,
elevation: 0,
alignment: Alignment.centerLeft, // Left align text inside button
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
factorName,
style: AppTextStyles.bodyMedium.copyWith(
color: AppColors.primary,
),
textAlign: TextAlign.left, // Left align text
overflow: TextOverflow.ellipsis,
),
);
}
}

View file

@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// impact_chart.dart
//
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
class ImpactBarChart extends StatelessWidget {
final FactorImpactRanking ranking;
const ImpactBarChart({
super.key,
required this.ranking,
});
@override
Widget build(BuildContext context) {
// Create data for chart (limited to top 5)
final factors = ranking.impactingFactors.take(5).toList();
final data = <BarChartGroupData>[];
for (int i = 0; i < factors.length; i++) {
final factor = factors[i];
data.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: factor.impactScore,
color: factor.impactColor,
width: 20,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
),
],
),
);
}
return BarChart(
BarChartData(
alignment: BarChartAlignment.center,
maxY: 1,
minY: 0,
barGroups: data,
gridData: FlGridData(
show: true,
horizontalInterval: 0.25,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppColors.onSurfaceVariant.withAlpha(40),
strokeWidth: 1,
);
},
),
borderData: FlBorderData(
show: true,
border: Border.all(color: AppColors.onSurfaceVariant.withAlpha(40)),
),
titlesData: FlTitlesData(
show: true,
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < factors.length) {
final factorName = factors[value.toInt()].name;
// Format factor name for display
String displayName = factorName;
if (displayName.startsWith('Hormone: ')) {
displayName = displayName.substring(9);
} else if (displayName.startsWith('Emotion: ')) {
displayName = displayName.substring(9);
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
displayName,
style: AppTextStyles.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
}
return const SizedBox();
},
reservedSize: 30,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value >= 0 && value <= 1 && value % 0.25 == 0) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Text(
'${(value * 100).toStringAsFixed(0)}%',
style: AppTextStyles.bodySmall,
),
);
}
return const SizedBox();
},
reservedSize: 40,
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: Colors.blueGrey.withAlpha(180),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final factor = factors[group.x];
return BarTooltipItem(
'${factor.name}: ${(factor.impactScore * 100).toStringAsFixed(0)}%',
const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
);
},
),
),
),
);
}
}

View file

@ -0,0 +1,98 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// strength_guide.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
class StrengthGuide extends StatelessWidget {
const StrengthGuide({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildStrengthItem('Very weak', AppTheme.grey),
_buildStrengthItem('Weak', Colors.teal),
_buildStrengthItem('Moderate', Colors.blue),
_buildStrengthItem('Strong', Colors.purple),
],
),
);
}
Widget _buildStrengthItem(String label, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
label,
style: AppTextStyles.bodySmall.copyWith(color: color),
),
],
),
);
}
}
class PatternTypeChip extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
const PatternTypeChip({
super.key,
required this.icon,
required this.label,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: color,
),
const SizedBox(width: 4),
Text(
label,
style: AppTextStyles.bodySmall.copyWith(color: color),
),
],
),
);
}
}

View file

@ -0,0 +1,155 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// weekday_chart.dart
//
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
class WeekdayBarChart extends StatelessWidget {
final WeekdayAnalysis analysis;
final String factor;
const WeekdayBarChart({
super.key,
required this.analysis,
required this.factor,
});
@override
Widget build(BuildContext context) {
// Create data for chart
final data = <BarChartGroupData>[];
final dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
for (int i = 1; i <= 7; i++) {
final dayData = analysis.factorsByWeekday['$factor-$i'] ?? [];
if (dayData.isNotEmpty) {
final avgValue = dayData.reduce((a, b) => a + b) / dayData.length;
Color barColor;
if (analysis.bestDays[factor] == i) {
barColor = Colors.green;
} else if (analysis.worstDays[factor] == i) {
barColor = Colors.red;
} else {
barColor = AppColors.primary;
}
data.add(
BarChartGroupData(
x: i - 1,
barRods: [
BarChartRodData(
toY: avgValue,
color: barColor,
width: 20,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
),
],
),
);
} else {
data.add(
BarChartGroupData(
x: i - 1,
barRods: [
BarChartRodData(
toY: 0,
color: AppTheme.grey,
width: 20,
),
],
),
);
}
}
return BarChart(
BarChartData(
alignment: BarChartAlignment.center,
maxY: 4,
minY: 0,
barGroups: data,
gridData: FlGridData(
show: true,
horizontalInterval: 1,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppColors.onSurfaceVariant.withAlpha(40),
strokeWidth: 1,
);
},
),
borderData: FlBorderData(
show: true,
border: Border.all(color: AppColors.onSurfaceVariant.withAlpha(40)),
),
titlesData: FlTitlesData(
show: true,
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < 7) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
dayLabels[value.toInt()],
style: AppTextStyles.bodySmall,
),
);
}
return const SizedBox();
},
reservedSize: 30,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value % 1 == 0 && value >= 0 && value <= 4) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Text(
value.toInt().toString(),
style: AppTextStyles.bodySmall,
),
);
}
return const SizedBox();
},
reservedSize: 30,
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: Colors.blueGrey.withAlpha(180),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final avg = rod.toY;
return BarTooltipItem(
'${dayLabels[group.x]} Avg: ${avg.toStringAsFixed(1)}',
const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
);
},
),
),
),
);
}
}

View file

@ -0,0 +1,616 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// stat_analysis.dart
// Models for statistical analysis and visualization of health data
//
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
/// Basic statistical models
class HealthCorrelation {
final String factorName;
final double correlation;
final String? description;
HealthCorrelation({
required this.factorName,
required this.correlation,
this.description,
});
String get strengthDescription {
final absCorr = correlation.abs();
if (absCorr > 0.7) return 'Strong';
if (absCorr > 0.5) return 'Moderate';
if (absCorr > 0.3) return 'Weak';
return 'Very weak';
}
String get directionDescription {
if (correlation > 0) return 'positive';
if (correlation < 0) return 'negative';
return 'no';
}
Color get correlationColor {
final absCorr = correlation.abs();
if (absCorr > 0.7) return Colors.purple;
if (absCorr > 0.5) return Colors.blue;
if (absCorr > 0.3) return Colors.teal;
return AppTheme.grey;
}
}
enum TrendDirection {
increasing,
decreasing,
none,
}
class HealthTrend {
final String variableName;
final TrendDirection direction;
final double strength;
final String? description;
HealthTrend({
required this.variableName,
required this.direction,
required this.strength,
this.description,
});
String get strengthDescription {
if (strength > 0.7) return 'Strong';
if (strength > 0.5) return 'Moderate';
if (strength > 0.3) return 'Weak';
return 'Very weak';
}
Color get trendColor {
switch (direction) {
case TrendDirection.increasing:
return Colors.green;
case TrendDirection.decreasing:
return Colors.red;
case TrendDirection.none:
return AppTheme.grey;
}
}
IconData get trendIcon {
switch (direction) {
case TrendDirection.increasing:
return Icons.trending_up;
case TrendDirection.decreasing:
return Icons.trending_down;
case TrendDirection.none:
return Icons.trending_flat;
}
}
}
class VariableStatistics {
final String variableName;
final double average;
final double median;
final double minimum;
final double maximum;
final double standardDeviation;
VariableStatistics({
required this.variableName,
required this.average,
required this.median,
required this.minimum,
required this.maximum,
required this.standardDeviation,
});
}
class StatisticalInsight {
final String title;
final String description;
final double confidence; // 0-1 scale
final List<HealthCorrelation>? correlations;
final List<HealthTrend>? trends;
final List<VariableStatistics>? statistics;
StatisticalInsight({
required this.title,
required this.description,
required this.confidence,
this.correlations,
this.trends,
this.statistics,
});
Color get confidenceColor {
if (confidence >= 0.8) return Colors.green;
if (confidence >= 0.6) return Colors.blue;
if (confidence >= 0.4) return Colors.amber;
return AppTheme.grey;
}
String get confidenceDescription {
if (confidence >= 0.8) return 'High confidence';
if (confidence >= 0.6) return 'Medium confidence';
if (confidence >= 0.4) return 'Lower confidence';
return 'Low confidence';
}
}
/// Represents a relationship between two or more health factors
class FactorRelationship {
final List<String> factorNames;
final double strength;
final String description;
final RelationshipType type;
FactorRelationship({
required this.factorNames,
required this.strength,
required this.description,
required this.type,
});
String get strengthDescription {
final absStrength = strength.abs();
if (absStrength > 0.7) return 'Strong';
if (absStrength > 0.5) return 'Moderate';
if (absStrength > 0.3) return 'Weak';
return 'Very weak';
}
Color get relationshipColor {
final absStrength = strength.abs();
if (absStrength > 0.7) return Colors.purple;
if (absStrength > 0.5) return Colors.blue;
if (absStrength > 0.3) return Colors.teal;
return AppTheme.grey;
}
}
/// Relationship types for multi-factor analysis
enum RelationshipType {
correlation,
causation,
cluster,
threshold,
unknown,
}
/// Represents a pattern identified in the data
class HealthPattern {
final String name;
final String description;
final double significance;
final List<String> relatedFactors;
final PatternType type;
HealthPattern({
required this.name,
required this.description,
required this.significance,
required this.relatedFactors,
required this.type,
});
Color get patternColor {
if (significance > 0.7) return Colors.purple;
if (significance > 0.5) return Colors.blue;
if (significance > 0.3) return Colors.teal;
return AppTheme.grey;
}
}
/// Types of patterns that can be detected in health data
enum PatternType {
trend,
cyclical,
cluster,
anomaly,
threshold,
unknown,
}
/// Represents a prediction of future health metrics
class HealthPrediction {
final String factorName;
final double predictedValue;
final double confidence;
final String description;
final DateTime targetDate;
HealthPrediction({
required this.factorName,
required this.predictedValue,
required this.confidence,
required this.description,
required this.targetDate,
});
Color get confidenceColor {
if (confidence > 0.7) return Colors.green;
if (confidence > 0.5) return Colors.blue;
if (confidence > 0.3) return Colors.amber;
return AppTheme.grey;
}
/// Get a user-friendly confidence description
String get confidenceDescription {
if (confidence >= 0.8) {
return 'Very High';
} else if (confidence >= 0.6) {
return 'High';
} else if (confidence >= 0.4) {
return 'Moderate';
} else if (confidence >= 0.2) {
return 'Low';
} else {
return 'Very Low';
}
}
/// Format target date
String get formattedTargetDate => DateFormat('MMMM d').format(targetDate);
/// Format target date (short)
String get shortFormattedTargetDate => DateFormat('MMM d').format(targetDate);
}
/// Represents an anomaly or unusual data point
class HealthAnomaly {
final DateTime date;
final String factorName;
final double anomalyScore;
final String description;
final List<String>? possibleCauses;
HealthAnomaly({
required this.date,
required this.factorName,
required this.anomalyScore,
required this.description,
this.possibleCauses,
});
Color get anomalyColor {
if (anomalyScore > 0.7) return Colors.red;
if (anomalyScore > 0.5) return Colors.orange;
if (anomalyScore > 0.3) return Colors.amber;
return AppTheme.grey;
}
/// Get formatted date string
String get formattedDate => DateFormat('MMM d').format(date);
}
/// Represents the impact ranking of different factors on a target variable
class FactorImpactRanking {
final String targetFactor;
final List<RankedFactor> impactingFactors;
final String description;
FactorImpactRanking({
required this.targetFactor,
required this.impactingFactors,
required this.description,
});
}
class RankedFactor {
final String name;
final double impactScore;
final String description;
RankedFactor({
required this.name,
required this.impactScore,
required this.description,
});
Color get impactColor {
if (impactScore > 0.7) return Colors.purple;
if (impactScore > 0.5) return Colors.blue;
if (impactScore > 0.3) return Colors.teal;
return AppTheme.grey;
}
String get impactDescription {
if (impactScore > 0.7) return 'Very high impact';
if (impactScore > 0.5) return 'High impact';
if (impactScore > 0.3) return 'Moderate impact';
return 'Low impact';
}
}
/// Represents a set of health metrics across days of the week
class WeekdayAnalysis {
final Map<String, List<double>> factorsByWeekday;
final Map<String, int> bestDays;
final Map<String, int> worstDays;
final String description;
WeekdayAnalysis({
required this.factorsByWeekday,
required this.bestDays,
required this.worstDays,
required this.description,
});
/// Get day name from day index (1-7)
String getDayName(int weekday) {
switch (weekday) {
case 1:
return 'Monday';
case 2:
return 'Tuesday';
case 3:
return 'Wednesday';
case 4:
return 'Thursday';
case 5:
return 'Friday';
case 6:
return 'Saturday';
case 7:
return 'Sunday';
default:
return 'Unknown';
}
}
/// Get short day name from day index (1-7)
String getShortDayName(int dayIndex) {
final shortDayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Ensure dayIndex is valid
if (dayIndex < 1 || dayIndex > 7) {
return 'Unk';
}
return shortDayNames[dayIndex - 1];
}
/// Calculate average value for a specific factor on a specific day
double getFactorAverage(String factor, int dayIndex) {
final dayData = factorsByWeekday['$factor-$dayIndex'] ?? [];
if (dayData.isEmpty) {
return 0.0;
}
return dayData.reduce((a, b) => a + b) / dayData.length;
}
}
/// Represents a streak or consistent period for a health metric
class HealthStreak {
final String factorName;
final int streakLength;
final DateTime startDate;
final DateTime endDate;
final StreakType type;
final String description;
HealthStreak({
required this.factorName,
required this.streakLength,
required this.startDate,
required this.endDate,
required this.type,
required this.description,
});
Color get streakColor {
switch (type) {
case StreakType.positive:
return Colors.green;
case StreakType.negative:
return Colors.red;
case StreakType.neutral:
return Colors.amber;
case StreakType.consistent:
return Colors.blue;
}
}
}
enum StreakType {
positive,
negative,
neutral,
consistent,
}
/// Represents hormone level analysis
class HormoneAnalysis {
final String hormoneName;
final double averageLevel;
final double optimalMinLevel;
final double optimalMaxLevel;
final HealthTrend trend;
final List<FactorRelationship> relationships;
final String description;
HormoneAnalysis({
required this.hormoneName,
required this.averageLevel,
required this.optimalMinLevel,
required this.optimalMaxLevel,
required this.trend,
required this.relationships,
required this.description,
});
bool get isWithinOptimalRange {
return averageLevel >= optimalMinLevel && averageLevel <= optimalMaxLevel;
}
Color get levelColor {
if (isWithinOptimalRange) return Colors.green;
if (averageLevel < optimalMinLevel) return Colors.orange;
return Colors.red;
}
}
/// Represents medication impact analysis
class MedicationImpact {
final String medicationName;
final Map<String, double> beforeAfterChanges;
final int daysToEffect;
final double effectStrength;
final String description;
MedicationImpact({
required this.medicationName,
required this.beforeAfterChanges,
required this.daysToEffect,
required this.effectStrength,
required this.description,
});
Color get impactColor {
if (effectStrength > 0.7) return Colors.purple;
if (effectStrength > 0.5) return Colors.blue;
if (effectStrength > 0.3) return Colors.teal;
return AppTheme.grey;
}
}
/// Comprehensive analysis object containing all types of insights
class ComprehensiveAnalysis {
final List<StatisticalInsight> generalInsights;
final List<FactorRelationship> relationships;
final List<HealthPattern> patterns;
final List<HealthPrediction> predictions;
final List<HealthAnomaly> anomalies;
final List<FactorImpactRanking> factorRankings;
final WeekdayAnalysis? weekdayAnalysis;
final List<HealthStreak> streaks;
final List<HormoneAnalysis> hormoneAnalyses;
final List<MedicationImpact> medicationImpacts;
final TrendAnalysis moodTrend;
final DateTime analysisDate;
final String timeframe;
ComprehensiveAnalysis({
required this.generalInsights,
required this.relationships,
required this.patterns,
required this.predictions,
required this.anomalies,
required this.factorRankings,
this.weekdayAnalysis,
required this.streaks,
required this.hormoneAnalyses,
required this.medicationImpacts,
required this.moodTrend,
required this.analysisDate,
required this.timeframe,
});
// Helper to get all factor names
Set<String> get allFactorNames {
final factors = <String>{};
// Add from relationships
for (final rel in relationships) {
factors.addAll(rel.factorNames);
}
// Add from patterns
for (final pattern in patterns) {
factors.addAll(pattern.relatedFactors);
}
// Add from predictions
for (final pred in predictions) {
factors.add(pred.factorName);
}
// Add from anomalies
for (final anomaly in anomalies) {
factors.add(anomaly.factorName);
}
// Add from factor rankings
for (final ranking in factorRankings) {
factors.add(ranking.targetFactor);
for (final factor in ranking.impactingFactors) {
factors.add(factor.name);
}
}
// Add from streaks
for (final streak in streaks) {
factors.add(streak.factorName);
}
// Add from hormone analyses
for (final hormone in hormoneAnalyses) {
factors.add(hormone.hormoneName);
}
return factors;
}
// Get analysis for a specific factor
Map<String, dynamic> getFactorAnalysis(String factorName) {
final result = <String, dynamic>{
'relationships': relationships
.where((r) => r.factorNames.contains(factorName))
.toList(),
'patterns':
patterns.where((p) => p.relatedFactors.contains(factorName)).toList(),
'predictions':
predictions.where((p) => p.factorName == factorName).toList(),
'anomalies': anomalies.where((a) => a.factorName == factorName).toList(),
'factorRankings': factorRankings
.where((r) =>
r.targetFactor == factorName ||
r.impactingFactors.any((f) => f.name == factorName))
.toList(),
'streaks': streaks.where((s) => s.factorName == factorName).toList(),
'hormoneAnalyses':
hormoneAnalyses.where((h) => h.hormoneName == factorName).toList(),
'medicationImpacts': medicationImpacts
.where((m) =>
m.medicationName == factorName ||
m.beforeAfterChanges.containsKey(factorName))
.toList(),
};
return result;
}
}
// Original TrendType from health_analytics_service
// Kept for compatibility
enum TrendType {
none(0),
increasing(1),
decreasing(2),
cyclic(3),
variable(4);
final int value;
const TrendType(this.value);
}
// Original TrendAnalysis from health_analytics_service
// Kept for compatibility
class TrendAnalysis {
final TrendType type;
final double strength;
final String description;
TrendAnalysis({
required this.type,
required this.strength,
required this.description,
});
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,774 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/timeframe_selector.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/mood_tracker/utils/mood_utils.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/stats/providers/statistics_provider.dart';
import 'package:nokken/src/features/stats/widgets/analysis_card.dart';
class EmotionAnalysisScreen extends ConsumerStatefulWidget {
final String initialTimeFrame;
const EmotionAnalysisScreen({
super.key,
this.initialTimeFrame = 'All Time',
});
@override
ConsumerState<EmotionAnalysisScreen> createState() =>
_EmotionAnalysisScreenState();
}
class _EmotionAnalysisScreenState extends ConsumerState<EmotionAnalysisScreen> {
late String _selectedTimeFrame;
String? _selectedEmotion;
@override
void initState() {
super.initState();
_selectedTimeFrame = widget.initialTimeFrame;
}
void _selectEmotion(String emotion) {
setState(() {
if (_selectedEmotion == emotion) {
_selectedEmotion = null; // Deselect if already selected
} else {
_selectedEmotion = emotion;
}
});
}
@override
Widget build(BuildContext context) {
// Get data for analysis
final moodEntries = ref.watch(moodEntriesProvider);
final filteredMoodEntries = TimeframeSelector.filterByTimeframe(
items: moodEntries,
timeframe: _selectedTimeFrame,
getDate: (entry) => entry.date,
);
// Watch for comprehensive analysis
final analysisAsync =
ref.watch(comprehensiveAnalysisProvider(_selectedTimeFrame));
return Scaffold(
appBar: AppBar(
title: const Text('Emotion Analysis'),
actions: [
TimeframeSelector.dropdownButton(
context: context,
selectedTimeframe: _selectedTimeFrame,
onTimeframeSelected: (value) {
setState(() {
_selectedTimeFrame = value;
_selectedEmotion =
null; // Reset selection when timeframe changes
});
},
),
],
),
body: analysisAsync.when(
data: (analysis) {
if (filteredMoodEntries.isEmpty) {
return _buildNoDataView();
}
return _buildEmotionAnalysisContent(
filteredMoodEntries,
analysis,
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => _buildErrorView(error.toString()),
),
);
}
Widget _buildEmotionAnalysisContent(
List<MoodEntry> moodEntries,
ComprehensiveAnalysis analysis,
) {
return ListView(
padding: const EdgeInsets.all(12),
children: [
// Emotion Selection Grid at the top
AnalysisCard(
title: 'Select an Emotion',
subtitle: 'Tap for insights',
content: _buildEmotionSelectionGrid(moodEntries),
icon: Icons.emoji_emotions,
color: Colors.purple,
),
SharedWidgets.verticalSpace(8),
// Selected emotion analysis (if any)
if (_selectedEmotion != null) ...[
_buildSelectedEmotionAnalysis(
_selectedEmotion!, moodEntries, analysis),
SharedWidgets.verticalSpace(8),
],
// Emotions Distribution
AnalysisCard(
title: 'Emotions Overview',
subtitle: 'Most common emotions',
content: _buildEmotionsOverview(moodEntries),
icon: Icons.emoji_emotions,
color: Colors.purple,
),
],
);
}
Widget _buildEmotionSelectionGrid(List<MoodEntry> moodEntries) {
// Count emotions
final emotionCounts = <String, int>{};
for (final entry in moodEntries) {
for (final emotion in entry.emotions) {
final emotionName = emotion.name;
emotionCounts[emotionName] = (emotionCounts[emotionName] ?? 0) + 1;
}
}
// Sort emotions by frequency
final sortedEmotions = emotionCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Grid of emotions to select
Wrap(
spacing: 6,
runSpacing: 6,
children: [
// Add emotions
...sortedEmotions.map((entry) {
final emotionName = entry.key;
final count = entry.value;
final isSelected = _selectedEmotion == emotionName;
return _buildCompactSelectionChip(
emotionName,
MoodUtils.getEmotionIcon(emotionName),
MoodUtils.getEmotionColorString(emotionName),
isSelected,
count,
() => _selectEmotion(emotionName),
);
}),
],
),
],
);
}
Widget _buildCompactSelectionChip(
String name,
IconData icon,
Color color,
bool isSelected,
int? count,
VoidCallback onTap,
) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected ? color : color.withAlpha(20),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color,
width: isSelected ? 2 : 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: isSelected ? Colors.white : color,
size: 12,
),
SharedWidgets.horizontalSpace(4),
Text(
name,
style: TextStyle(
color: isSelected ? Colors.white : color,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 10,
),
),
if (count != null) ...[
SharedWidgets.horizontalSpace(2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: isSelected
? Colors.white.withAlpha(50)
: color.withAlpha(40),
borderRadius: BorderRadius.circular(6),
),
child: Text(
count.toString(),
style: TextStyle(
fontSize: 8,
color: isSelected ? Colors.white : color,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
);
}
Widget _buildSelectedEmotionAnalysis(
String emotionName,
List<MoodEntry> entries,
ComprehensiveAnalysis analysis,
) {
// Filter entries that have this emotion
final entriesWithEmotion = entries.where((entry) {
return entry.emotions.any((e) => e.name == emotionName);
}).toList();
if (entriesWithEmotion.isEmpty) {
return Text(
'No data available for $emotionName analysis.',
style: AppTextStyles.bodySmall,
);
}
// Get dates when this emotion was recorded
final dates = entriesWithEmotion.map((e) => e.date).toList();
// Find relationships involving this emotion
final emotionRelationships = analysis.relationships.where((rel) {
return rel.factorNames.any((name) => name == 'Emotion: $emotionName');
}).toList();
return AnalysisCard(
title: '$emotionName Analysis',
subtitle: 'Insights for this emotion',
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Basic stats - compact layout
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildCompactStatBox(
'Occurrences',
entriesWithEmotion.length.toString(),
MoodUtils.getEmotionColorString(emotionName),
),
_buildCompactStatBox(
'Frequency',
'${(entriesWithEmotion.length / entries.length * 100).toStringAsFixed(0)}%',
MoodUtils.getEmotionColorString(emotionName),
),
_buildCompactStatBox(
'Last Felt',
_getDaysAgo(dates.isNotEmpty ? dates.last : null),
MoodUtils.getEmotionColorString(emotionName),
),
],
),
const SizedBox(height: 12),
// Show associated moods - compact chart
Text(
'Associated Moods:',
style:
AppTextStyles.bodySmall.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
SizedBox(
height: 60,
child: _buildAssociatedMoodsChart(entriesWithEmotion),
),
const SizedBox(height: 12),
// Show correlations
if (emotionRelationships.isNotEmpty) ...[
Text(
'Correlations:',
style:
AppTextStyles.bodySmall.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
// More compact correlation display
...emotionRelationships.take(2).map((relationship) {
// Get the other factor name
final otherFactorName = relationship.factorNames.firstWhere(
(name) => name != 'Emotion: $emotionName',
orElse: () => relationship.factorNames.first,
);
return Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: relationship.relationshipColor.withAlpha(30),
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${(relationship.strength * 100).toStringAsFixed(0)}%',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: relationship.relationshipColor,
),
),
),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getDisplayName(otherFactorName),
style: AppTextStyles.bodySmall.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
relationship.description,
style:
AppTextStyles.bodySmall.copyWith(fontSize: 10),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}),
],
const SizedBox(height: 12),
// Insights
Text(
'Insights:',
style:
AppTextStyles.bodySmall.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
_buildEmotionInsights(emotionName, entriesWithEmotion, dates),
],
),
icon: MoodUtils.getEmotionIcon(emotionName),
color: MoodUtils.getEmotionColorString(emotionName),
);
}
Widget _buildCompactStatBox(String label, String value, Color color) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: AppTextStyles.bodySmall.copyWith(fontSize: 10),
),
],
),
);
}
Widget _buildAssociatedMoodsChart(List<MoodEntry> entries) {
// Count mood ratings
final moodCounts = <MoodRating, int>{};
for (final mood in MoodRating.values) {
moodCounts[mood] = 0;
}
for (final entry in entries) {
moodCounts[entry.mood] = (moodCounts[entry.mood] ?? 0) + 1;
}
// Calculate percentages
final moodPercentages = <MoodRating, double>{};
for (final mood in MoodRating.values) {
moodPercentages[mood] =
entries.isEmpty ? 0.0 : (moodCounts[mood] ?? 0) / entries.length;
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: MoodRating.values.map((mood) {
final count = moodCounts[mood] ?? 0;
final percentage = moodPercentages[mood] ?? 0.0;
return Column(
children: [
// Bar visualization
Container(
width: 20,
height: 30 * percentage,
decoration: BoxDecoration(
color: MoodUtils.getMoodColor(mood),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(3),
),
),
),
// Mood emoji
Text(
mood.emoji,
style: const TextStyle(
fontSize: 12,
fontFamily: 'EmojiFont',
),
),
// Count
Text(
count.toString(),
style: AppTextStyles.bodySmall.copyWith(fontSize: 9),
),
],
);
}).toList(),
);
}
Widget _buildEmotionInsights(
String emotionName,
List<MoodEntry> entries,
List<DateTime> dates,
) {
if (entries.isEmpty) {
return Text(
'No data available for $emotionName yet.',
style: AppTextStyles.bodySmall,
);
}
// Calculate insights
final totalEntries = entries.length;
// Most common day of week
final dayOfWeekCounts = <int, int>{};
for (final date in dates) {
final dayOfWeek = date.weekday;
dayOfWeekCounts[dayOfWeek] = (dayOfWeekCounts[dayOfWeek] ?? 0) + 1;
}
int? mostCommonDay;
int maxCount = 0;
for (final entry in dayOfWeekCounts.entries) {
if (entry.value > maxCount) {
maxCount = entry.value;
mostCommonDay = entry.key;
}
}
// Get associated mood
MoodRating? mostCommonMood;
int maxMoodCount = 0;
final moodCounts = <MoodRating, int>{};
for (final entry in entries) {
moodCounts[entry.mood] = (moodCounts[entry.mood] ?? 0) + 1;
if ((moodCounts[entry.mood] ?? 0) > maxMoodCount) {
maxMoodCount = moodCounts[entry.mood] ?? 0;
mostCommonMood = entry.mood;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.insights,
color: MoodUtils.getEmotionColorString(emotionName),
size: 14,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'You\'ve recorded $emotionName $totalEntries times.',
style: AppTextStyles.bodySmall.copyWith(fontSize: 10),
),
),
],
),
if (mostCommonDay != null) ...[
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.calendar_today,
color: MoodUtils.getEmotionColorString(emotionName),
size: 14,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Most often on ${_getDayName(mostCommonDay)}s.',
style: AppTextStyles.bodySmall.copyWith(fontSize: 10),
),
),
],
),
],
if (mostCommonMood != null) ...[
const SizedBox(height: 6),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.mood,
color: MoodUtils.getMoodColor(mostCommonMood),
size: 14,
),
const SizedBox(width: 6),
Expanded(
child: Text(
'Usually feel ${mostCommonMood.displayName.toLowerCase()} when experiencing $emotionName.',
style: AppTextStyles.bodySmall.copyWith(fontSize: 10),
),
),
],
),
],
],
);
}
Widget _buildEmotionsOverview(List<MoodEntry> entries) {
// Calculate emotion frequencies
final emotionCounts = <String, int>{};
int totalCount = 0;
for (final entry in entries) {
for (final emotion in entry.emotions) {
final emotionName = emotion.name;
emotionCounts[emotionName] = (emotionCounts[emotionName] ?? 0) + 1;
totalCount++;
}
}
// Sort by frequency
final sortedEmotions = emotionCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Emotion list - more compact version
...sortedEmotions.take(6).map((entry) {
final emotionName = entry.key;
final count = entry.value;
final percentage = (count / entries.length * 100).toStringAsFixed(1);
return Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Row(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: MoodUtils.getEmotionColorString(emotionName)
.withAlpha(40),
shape: BoxShape.circle,
),
child: Center(
child: Icon(
MoodUtils.getEmotionIcon(emotionName),
color: MoodUtils.getEmotionColorString(emotionName),
size: 12,
),
),
),
SharedWidgets.horizontalSpace(6),
// Emotion name
Expanded(
child: Text(
emotionName,
style: AppTextStyles.bodySmall,
),
),
// Percentage
Text(
'$percentage%',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.bold,
fontSize: 10,
),
),
SharedWidgets.horizontalSpace(4),
// Progress bar - compact
Container(
width: 40,
height: 6,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.surfaceContainer,
),
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: count / entries.length,
child: Container(
color: MoodUtils.getEmotionColorString(emotionName),
),
),
),
],
),
);
}),
],
);
}
String _getDisplayName(String factorName) {
if (factorName.startsWith('Emotion: ')) {
return factorName.substring(9);
} else if (factorName.startsWith('Hormone: ')) {
return factorName.substring(9);
}
return factorName;
}
String _getDaysAgo(DateTime? date) {
if (date == null) {
return 'N/A';
}
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays < 1) {
return 'Today';
} else if (difference.inDays == 1) {
return '1d ago';
} else if (difference.inDays < 7) {
return '${difference.inDays}d ago';
} else if (difference.inDays < 30) {
return '${(difference.inDays / 7).floor()}w ago';
} else if (difference.inDays < 365) {
return '${(difference.inDays / 30).floor()}m ago';
} else {
return '${(difference.inDays / 365).floor()}y ago';
}
}
String _getDayName(int day) {
const days = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun',
];
return days[day - 1];
}
Widget _buildNoDataView() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.emoji_emotions,
size: 48,
color: AppColors.primary.withAlpha(120),
),
SharedWidgets.verticalSpace(8),
Text(
'No Emotion Data Available',
style: AppTextStyles.titleMedium,
),
SharedWidgets.verticalSpace(8),
Text(
'Start tracking your emotions to see analytics.',
textAlign: TextAlign.center,
style: AppTextStyles.bodySmall,
),
],
),
);
}
Widget _buildErrorView(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: AppColors.error,
),
SharedWidgets.verticalSpace(8),
Text(
'Analysis Error',
style: AppTextStyles.titleMedium,
),
SharedWidgets.verticalSpace(8),
Text(
'Could not generate insights: $error',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.error, fontSize: 12),
),
],
),
);
}
}

View file

@ -0,0 +1,362 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// insights_screen.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/stats/utils/stat_utils.dart';
import 'package:nokken/src/features/stats/widgets/loading_error_views.dart';
import 'package:nokken/src/features/stats/charts/impact_chart.dart';
import 'package:nokken/src/features/stats/screens/emotion_analysis_screen.dart';
class InsightsScreen extends ConsumerWidget {
final ComprehensiveAnalysis analysis;
final String timeframe;
const InsightsScreen({
super.key,
required this.analysis,
required this.timeframe,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Health Insights'),
actions: [
// Add navigation to Health Analysis
IconButton(
icon: const Icon(Icons.analytics_outlined),
tooltip: 'Detailed Analysis',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => EmotionAnalysisScreen(
initialTimeFrame: timeframe,
),
));
},
),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
children: [
// Introduction card with analysis button
_buildIntroCard(context),
const SizedBox(height: 16),
// Top Patterns Section
if (analysis.patterns.isNotEmpty) ...[
_buildHeader('Key Patterns', Icons.trending_up, Colors.indigo),
const SizedBox(height: 4),
...analysis.patterns
.take(3)
.map((pattern) => _buildCompactInsightCard(
title: pattern.name,
description: pattern.description,
icon: StatUtils.getPatternIcon(pattern.type),
color: pattern.patternColor,
strength: pattern.significance,
)),
const SizedBox(height: 16),
],
// Top Correlations Section
if (analysis.relationships.isNotEmpty) ...[
_buildHeader(
'Strongest Correlations', Icons.compare_arrows, Colors.purple),
Padding(
padding: const EdgeInsets.only(left: 26, bottom: 4),
child: Text(
'Shows how two health factors relate to each other',
style: AppTextStyles.bodySmall
.copyWith(fontSize: 10, color: Colors.grey),
),
),
...analysis.relationships
.take(3)
.map((relationship) => _buildCompactInsightCard(
title: relationship.factorNames.join(' & '),
description: relationship.description,
icon: Icons.compare_arrows,
color: relationship.relationshipColor,
strength: relationship.strength.abs(),
)),
const SizedBox(height: 16),
],
// Key Factors Section
if (analysis.factorRankings.isNotEmpty) ...[
_buildHeader('Impact Factors', Icons.bar_chart, Colors.teal),
Padding(
padding: const EdgeInsets.only(left: 26, bottom: 4),
child: Text(
'Shows which factors have the most impact on specific outcomes',
style: AppTextStyles.bodySmall
.copyWith(fontSize: 10, color: Colors.grey),
),
),
...analysis.factorRankings
.take(3)
.map((ranking) => _buildCompactFactorCard(ranking: ranking)),
const SizedBox(height: 16),
],
// Anomalies & Unusual Data
if (analysis.anomalies.isNotEmpty) ...[
_buildHeader(
'Unusual Data Points', Icons.warning_amber, Colors.orange),
const SizedBox(height: 4),
...analysis.anomalies
.take(3)
.map((anomaly) => _buildCompactInsightCard(
title:
'${anomaly.factorName} on ${anomaly.formattedDate}',
description: anomaly.description,
icon: Icons.warning,
color: anomaly.anomalyColor,
strength: anomaly.anomalyScore,
)),
],
],
),
);
}
Widget _buildIntroCard(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withAlpha(20),
shape: BoxShape.circle,
),
child:
const Icon(Icons.info_outline, color: Colors.blue, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Health insights based on your tracked data. Insights update as you add more entries.',
style: AppTextStyles.bodySmall,
),
const SizedBox(height: 6),
TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, 0),
),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => EmotionAnalysisScreen(
initialTimeFrame: timeframe,
),
));
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('View Detailed Analysis'),
const SizedBox(width: 4),
Icon(Icons.arrow_forward, size: 14),
],
),
),
],
),
),
],
),
),
);
}
Widget _buildHeader(String title, IconData icon, Color color) {
return Padding(
padding: const EdgeInsets.only(left: 4.0, bottom: 2.0),
child: Row(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 6),
Text(
title,
style: AppTextStyles.titleSmall.copyWith(color: color),
),
],
),
);
}
Widget _buildCompactInsightCard({
required String title,
required String description,
required IconData icon,
required Color color,
required double strength,
}) {
// Format strength as percentage
final strengthPercent = (strength * 100).toInt();
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 0,
color: AppColors.surfaceContainer,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with strength indicator
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withAlpha(30),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: color,
size: 14,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: AppTextStyles.bodySmall.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$strengthPercent%',
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 6),
Text(
description,
style: AppTextStyles.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
Widget _buildCompactFactorCard({
required FactorImpactRanking ranking,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 0,
color: AppColors.surfaceContainer,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.teal.withAlpha(30),
shape: BoxShape.circle,
),
child: Icon(
Icons.insights,
color: Colors.teal,
size: 14,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Factors affecting ${ranking.targetFactor}',
style: AppTextStyles.bodySmall.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 6),
// Top factors list (just names)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: ranking.impactingFactors.take(3).map((factor) {
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Row(
children: [
Icon(
Icons.arrow_right,
color: factor.impactColor,
size: 12,
),
const SizedBox(width: 2),
Expanded(
child: Text(
factor.name,
style: AppTextStyles.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
Text(
'${(factor.impactScore * 100).toInt()}%',
style: TextStyle(
fontSize: 10,
color: factor.impactColor,
fontWeight: FontWeight.bold,
),
),
],
),
);
}).toList(),
),
],
),
),
);
}
}

View file

@ -0,0 +1,331 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// stats_overview_screen.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/timeframe_selector.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/stats/providers/statistics_provider.dart';
import 'package:nokken/src/features/stats/screens/emotion_analysis_screen.dart';
import 'package:nokken/src/features/stats/widgets/loading_error_views.dart';
import 'package:nokken/src/features/stats/widgets/empty_state_view.dart';
import 'package:nokken/src/features/stats/screens/totals_screen.dart';
import 'package:nokken/src/features/stats/screens/insights_screen.dart';
import 'package:nokken/src/features/stats/widgets/day_of_week_card.dart';
import 'package:nokken/src/features/stats/widgets/monthly_trends_card.dart';
import 'package:nokken/src/features/stats/widgets/insights_overview_card.dart';
import 'package:nokken/src/features/stats/widgets/analysis_card.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/features/bloodwork_tracker/providers/bloodwork_state.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
class StatsOverviewScreen extends ConsumerStatefulWidget {
final String initialTimeFrame;
const StatsOverviewScreen({
super.key,
this.initialTimeFrame = 'All Time',
});
@override
ConsumerState<StatsOverviewScreen> createState() =>
_StatsOverviewScreenState();
}
class _StatsOverviewScreenState extends ConsumerState<StatsOverviewScreen> {
late String _selectedTimeFrame;
final Set<String> _expandedCardIds = {};
@override
void initState() {
super.initState();
_selectedTimeFrame = widget.initialTimeFrame;
}
void _toggleExpanded(String cardId) {
setState(() {
if (_expandedCardIds.contains(cardId)) {
_expandedCardIds.remove(cardId);
} else {
_expandedCardIds.add(cardId);
}
});
}
void _navigateToFactorAnalysis(String factorName) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EmotionAnalysisScreen(
initialTimeFrame: _selectedTimeFrame,
),
),
);
}
void _navigateToInsightsScreen(ComprehensiveAnalysis analysis) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => InsightsScreen(
analysis: analysis,
timeframe: _selectedTimeFrame,
),
),
);
}
bool _hasNoData(ComprehensiveAnalysis analysis) {
return analysis.generalInsights.isEmpty &&
analysis.relationships.isEmpty &&
analysis.patterns.isEmpty &&
analysis.factorRankings.isEmpty;
}
@override
Widget build(BuildContext context) {
// Watch for comprehensive analysis data based on the selected timeframe
final analysisAsync =
ref.watch(comprehensiveAnalysisProvider(_selectedTimeFrame));
return Scaffold(
appBar: AppBar(
title: const Text('Health Analytics'),
elevation: 0,
actions: [
TimeframeSelector.dropdownButton(
context: context,
selectedTimeframe: _selectedTimeFrame,
onTimeframeSelected: (value) {
setState(() {
_selectedTimeFrame = value;
});
},
),
],
),
body: analysisAsync.when(
data: (analysis) {
if (_hasNoData(analysis)) {
return EmptyStateView.noData();
}
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(comprehensiveAnalysisProvider(_selectedTimeFrame));
},
child: ListView(
padding: AppTheme.standardCardPadding,
children: [
// Health Summary Card
AnalysisCard(
title: 'Health Summary',
subtitle: 'Summary of your tracked data',
content: _buildTotalsCardContent(analysis),
icon: Icons.format_list_numbered,
color: Colors.deepPurple,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => TotalsScreen(
analysis: analysis,
timeframe: _selectedTimeFrame,
),
),
),
),
const SizedBox(height: 16),
// Insights Overview Card
InsightsOverviewCard(
analysis: analysis,
onTap: () => _navigateToInsightsScreen(analysis),
),
const SizedBox(height: 16),
// Day of Week Patterns Card
DayOfWeekCard(
timeframe: _selectedTimeFrame,
),
const SizedBox(height: 16),
// Monthly Trends Card
MonthlyTrendsCard(
timeframe: _selectedTimeFrame,
),
],
),
);
},
loading: () => LoadingErrorViews.loading(),
error: (error, stack) => LoadingErrorViews.error(
error: error.toString(),
onRetry: () {
ref.invalidate(comprehensiveAnalysisProvider(_selectedTimeFrame));
},
onGoBack: () => Navigator.of(context).pop(),
),
),
);
}
Widget _buildTotalsCardContent(ComprehensiveAnalysis analysis) {
// Get data from providers
final totalFactors =
ref.watch(totalHealthFactorsProvider(_selectedTimeFrame));
final totalRelationships = analysis.relationships.length;
final totalPatterns = analysis.patterns.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Preview grid - 3 cells showing totals
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildTotalStat('Factors', totalFactors.toString(), Colors.blue),
_buildTotalStat(
'Relationships', totalRelationships.toString(), Colors.purple),
_buildTotalStat('Patterns', totalPatterns.toString(), Colors.teal),
],
),
const SizedBox(height: 16),
// Tap to see more
Center(
child: Text(
'Tap to see detailed statistics',
style: AppTextStyles.bodySmall.copyWith(
fontStyle: FontStyle.italic,
color: AppColors.onSurfaceVariant,
),
),
),
],
);
}
Widget _buildTotalStat(String label, String value, Color color) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
label,
style: AppTextStyles.bodySmall,
),
],
),
);
}
}
// Provider for counting total health factors across different data sources
final totalHealthFactorsProvider =
Provider.family<int, String>((ref, timeframe) {
final moodEntries = ref.watch(filteredMoodEntriesProvider(timeframe));
final medications = ref.watch(medicationsProvider);
final bloodwork = ref.watch(filteredBloodworkProvider(timeframe));
// Count unique factors
final factorSet = <String>{};
// Count mood factors
if (moodEntries.isNotEmpty) {
factorSet.add('Mood');
// Count health metrics that have data
if (moodEntries.any((e) => e.sleepQuality != null)) factorSet.add('Sleep');
if (moodEntries.any((e) => e.energyLevel != null)) factorSet.add('Energy');
if (moodEntries.any((e) => e.focusLevel != null)) factorSet.add('Focus');
if (moodEntries.any((e) => e.appetiteLevel != null)) {
factorSet.add('Appetite');
}
if (moodEntries.any((e) => e.dysphoriaLevel != null)) {
factorSet.add('Dysphoria');
}
if (moodEntries.any((e) => e.exerciseLevel != null)) {
factorSet.add('Exercise');
}
if (moodEntries.any((e) => e.libidoLevel != null)) factorSet.add('Libido');
// Count emotions as factors
final emotionSet = <String>{};
for (final entry in moodEntries) {
for (final emotion in entry.emotions) {
emotionSet.add(emotion.name);
}
}
// Add each unique emotion as a factor
factorSet.addAll(emotionSet.map((e) => 'Emotion: $e'));
}
// Count medications
factorSet.addAll(medications.map((m) => 'Medication: ${m.name}'));
// Count hormones
for (final record in bloodwork) {
for (final reading in record.hormoneReadings) {
factorSet.add('Hormone: ${reading.name}');
}
}
return factorSet.length;
});
// Filter bloodwork records by timeframe
final filteredBloodworkProvider =
Provider.family<List<Bloodwork>, String>((ref, timeframe) {
final allBloodwork = ref.watch(bloodworkRecordsProvider);
if (timeframe == 'All Time') {
return allBloodwork;
}
// Extract start date based on timeframe
final now = DateTime.now();
final DateTime startDate;
switch (timeframe) {
case 'Last 7 Days':
startDate = now.subtract(const Duration(days: 7));
break;
case 'Last 30 Days':
startDate = now.subtract(const Duration(days: 30));
break;
case 'Last 90 Days':
startDate = now.subtract(const Duration(days: 90));
break;
case 'Last 6 Months':
startDate = DateTime(now.year, now.month - 6, now.day);
break;
case 'Last Year':
startDate = DateTime(now.year - 1, now.month, now.day);
break;
default:
return allBloodwork;
}
// Filter records by date
return allBloodwork
.where((record) => record.date.isAfter(startDate))
.toList();
});

View file

@ -0,0 +1,943 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// totals_screen.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/core/ui/widgets/timeframe_selector.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_state.dart';
import 'package:nokken/src/features/medication_tracker/providers/medication_taken_provider.dart';
import 'package:nokken/src/features/bloodwork_tracker/providers/bloodwork_state.dart';
import 'package:nokken/src/features/medication_tracker/models/medication_dose.dart';
class TotalsScreen extends ConsumerStatefulWidget {
final ComprehensiveAnalysis analysis;
final String timeframe;
const TotalsScreen({
super.key,
required this.analysis,
required this.timeframe,
});
@override
ConsumerState<TotalsScreen> createState() => _TotalsScreenState();
}
class _TotalsScreenState extends ConsumerState<TotalsScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 5, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Health Data Totals'),
bottom: TabBar(
controller: _tabController,
isScrollable: true,
tabs: const [
Tab(text: 'Mood & Emotions'),
Tab(text: 'Health Factors'),
Tab(text: 'Medications'),
Tab(text: 'Appointments'),
Tab(text: 'Hormones'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildMoodEmotionsTab(),
_buildHealthFactorsTab(),
_buildMedicationsTab(),
_buildAppointmentsTab(),
_buildHormonesTab(),
],
),
);
}
Widget _buildMoodEmotionsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('Mood Ratings', Colors.amber),
SharedWidgets.verticalSpace(16),
// Mood ratings table
_buildTable(
columns: ['Mood', 'Count', 'Percentage'],
rows: _getMoodRows(),
colors: [Colors.amber.withAlpha(20), Colors.amber.withAlpha(40)],
),
SharedWidgets.verticalSpace(24),
_buildSectionHeader('Emotions Tracked', Colors.pink),
SharedWidgets.verticalSpace(16),
// Emotions table
_buildTable(
columns: ['Emotion', 'Count', 'Percentage'],
rows: _getEmotionRows(),
colors: [Colors.pink.withAlpha(20), Colors.pink.withAlpha(40)],
),
],
),
);
}
Widget _buildHealthFactorsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('Health Factors Tracked', Colors.teal),
SharedWidgets.verticalSpace(16),
// Health factors table
_buildTable(
columns: ['Factor', 'Records', 'Avg Value'],
rows: _getHealthFactorRows(),
colors: [Colors.teal.withAlpha(20), Colors.teal.withAlpha(40)],
),
SharedWidgets.verticalSpace(24),
_buildSectionHeader('Factor Relationships', Colors.purple),
SharedWidgets.verticalSpace(16),
// Relationships table
_buildTable(
columns: ['Factors', 'Strength', 'Type'],
rows: _getRelationshipRows(),
colors: [Colors.purple.withAlpha(20), Colors.purple.withAlpha(40)],
),
],
),
);
}
Widget _buildMedicationsTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('Medications Tracked', Colors.blue),
SharedWidgets.verticalSpace(16),
// Medications table
_buildTable(
columns: ['Medication', 'Doses', 'Adherence'],
rows: _getMedicationRows(),
colors: [Colors.blue.withAlpha(20), Colors.blue.withAlpha(40)],
),
SharedWidgets.verticalSpace(24),
_buildSectionHeader('Medication Impacts', Colors.indigo),
SharedWidgets.verticalSpace(16),
// Medication impacts table
_buildTable(
columns: ['Medication', 'Effect Strength', 'Days to Effect'],
rows: _getMedicationImpactRows(),
colors: [Colors.indigo.withAlpha(20), Colors.indigo.withAlpha(40)],
),
],
),
);
}
Widget _buildAppointmentsTab() {
final bloodworkRecords = ref.watch(bloodworkRecordsProvider);
final filteredRecords = TimeframeSelector.filterByTimeframe(
items: bloodworkRecords,
timeframe: widget.timeframe,
getDate: (record) => record.date,
);
if (filteredRecords.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event_note,
size: 64,
color: AppColors.secondary.withAlpha(120),
),
SharedWidgets.verticalSpace(24),
Text(
'No Appointment Data',
style: AppTextStyles.headlineSmall,
),
SharedWidgets.verticalSpace(16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
'Start tracking your appointments to view statistics and analytics.',
textAlign: TextAlign.center,
style: AppTextStyles.bodyLarge,
),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('Appointments Tracked', Colors.blue),
SharedWidgets.verticalSpace(16),
// Appointments table
_buildTable(
columns: ['Type', 'Count', 'Percentage'],
rows: _getAppointmentRows(),
colors: [Colors.blue.withAlpha(20), Colors.blue.withAlpha(40)],
),
SharedWidgets.verticalSpace(24),
_buildSectionHeader('Locations', Colors.teal),
SharedWidgets.verticalSpace(16),
// Locations table
_buildTable(
columns: ['Location', 'Visits', 'Percentage'],
rows: _getLocationRows(),
colors: [Colors.teal.withAlpha(20), Colors.teal.withAlpha(40)],
),
],
),
);
}
Widget _buildHormonesTab() {
final bloodworkRecords = ref.watch(bloodworkRecordsProvider);
final filteredRecords = TimeframeSelector.filterByTimeframe(
items: bloodworkRecords,
timeframe: widget.timeframe,
getDate: (record) => record.date,
);
// Extract all hormone readings
final allReadings = <String, List<double>>{};
for (final record in filteredRecords) {
for (final reading in record.hormoneReadings) {
if (!allReadings.containsKey(reading.name)) {
allReadings[reading.name] = [];
}
allReadings[reading.name]!.add(reading.value);
}
}
if (allReadings.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.science,
size: 64,
color: AppColors.secondary.withAlpha(120),
),
SharedWidgets.verticalSpace(24),
Text(
'No Hormone Data',
style: AppTextStyles.headlineSmall,
),
SharedWidgets.verticalSpace(16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
'Start tracking your hormone levels to view statistics and analytics.',
textAlign: TextAlign.center,
style: AppTextStyles.bodyLarge,
),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('Hormone Levels Tracked', Colors.deepPurple),
SharedWidgets.verticalSpace(16),
// Hormones table
_buildTable(
columns: ['Hormone', 'Records', 'Avg Value', 'In Range'],
rows: _getHormoneRows(),
colors: [
Colors.deepPurple.withAlpha(20),
Colors.deepPurple.withAlpha(40)
],
),
SharedWidgets.verticalSpace(24),
_buildSectionHeader('Hormone Trends', Colors.purple),
SharedWidgets.verticalSpace(16),
// Hormone trends table
_buildTable(
columns: ['Hormone', 'Trend', 'Confidence'],
rows: _getHormoneTrendRows(),
colors: [Colors.purple.withAlpha(20), Colors.purple.withAlpha(40)],
),
],
),
);
}
// Helper methods for building UI components
Widget _buildSectionHeader(String title, Color color) {
return Row(
children: [
Container(
width: 4,
height: 24,
color: color,
),
SharedWidgets.horizontalSpace(8),
Text(
title,
style: AppTextStyles.titleMedium,
),
],
);
}
Widget _buildTable({
required List<String> columns,
required List<List<String>> rows,
required List<Color> colors,
}) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: AppColors.surfaceContainer),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: colors[0],
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
),
child: Row(
children: columns.map((column) {
return Expanded(
child: Text(
column,
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.bold,
),
),
);
}).toList(),
),
),
// Rows
...rows.asMap().entries.map((entry) {
final index = entry.key;
final row = entry.value;
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: index % 2 == 0 ? colors[0] : colors[1],
border: Border(
top: BorderSide(color: AppColors.surfaceContainer),
),
),
child: Row(
children: row.asMap().entries.map((cell) {
return Expanded(
child: Text(
cell.value,
style: AppTextStyles.bodyMedium,
),
);
}).toList(),
),
);
}),
],
),
);
}
// Methods to generate data rows for tables
List<List<String>> _getMoodRows() {
// Get mood entries from the provider
final moodEntries = ref.watch(moodEntriesProvider);
final filteredEntries = TimeframeSelector.filterByTimeframe(
items: moodEntries,
timeframe: widget.timeframe,
getDate: (entry) => entry.date,
);
if (filteredEntries.isEmpty) {
return [
['No data', '-', '-']
];
}
// Count occurrences of each mood rating
final moodCounts = <MoodRating, int>{};
for (final entry in filteredEntries) {
moodCounts[entry.mood] = (moodCounts[entry.mood] ?? 0) + 1;
}
// Calculate total and percentages
final totalEntries = filteredEntries.length;
final rows = <List<String>>[];
// Add rows for each mood rating
for (final rating in MoodRating.values) {
final count = moodCounts[rating] ?? 0;
final percentage = totalEntries > 0 ? (count / totalEntries * 100) : 0;
rows.add([
rating.displayName,
count.toString(),
'${percentage.toStringAsFixed(1)}%',
]);
}
// Add total row
rows.add([
'Total',
totalEntries.toString(),
'100%',
]);
return rows;
}
List<List<String>> _getEmotionRows() {
// Get mood entries from the provider
final moodEntries = ref.watch(moodEntriesProvider);
final filteredEntries = TimeframeSelector.filterByTimeframe(
items: moodEntries,
timeframe: widget.timeframe,
getDate: (entry) => entry.date,
);
if (filteredEntries.isEmpty) {
return [
['No data', '-', '-']
];
}
// Count occurrences of each emotion
final emotionCounts = <Emotion, int>{};
for (final entry in filteredEntries) {
for (final emotion in entry.emotions) {
emotionCounts[emotion] = (emotionCounts[emotion] ?? 0) + 1;
}
}
// Calculate total emotions and percentages
final totalEmotions = emotionCounts.values.fold(0, (a, b) => a + b);
final rows = <List<String>>[];
// Sort emotions by count (descending)
final sortedEmotions = emotionCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
// Add rows for each emotion
for (final entry in sortedEmotions) {
final percentage =
totalEmotions > 0 ? (entry.value / totalEmotions * 100) : 0;
rows.add([
entry.key.displayName,
entry.value.toString(),
'${percentage.toStringAsFixed(1)}%',
]);
}
return rows;
}
List<List<String>> _getHealthFactorRows() {
// Get mood entries from the provider
final moodEntries = ref.watch(moodEntriesProvider);
final filteredEntries = TimeframeSelector.filterByTimeframe(
items: moodEntries,
timeframe: widget.timeframe,
getDate: (entry) => entry.date,
);
if (filteredEntries.isEmpty) {
return [
['No data', '-', '-']
];
}
// Define the health factors with dynamic level tracking
final healthFactors = <String, Map<String, dynamic>>{
'Mood': {'count': 0, 'levels': <String, int>{}},
'Sleep': {'count': 0, 'levels': <String, int>{}},
'Energy': {'count': 0, 'levels': <String, int>{}},
'Focus': {'count': 0, 'levels': <String, int>{}},
'Appetite': {'count': 0, 'levels': <String, int>{}},
'Dysphoria': {'count': 0, 'levels': <String, int>{}},
'Exercise': {'count': 0, 'levels': <String, int>{}},
'Libido': {'count': 0, 'levels': <String, int>{}},
};
// Process each mood entry
for (final entry in filteredEntries) {
// Process mood
final moodName = entry.mood.displayName;
healthFactors['Mood']!['count'] += 1;
healthFactors['Mood']!['levels']![moodName] =
(healthFactors['Mood']!['levels']![moodName] ?? 0) + 1;
// Process sleep quality
if (entry.sleepQuality != null) {
final levelName = entry.sleepQuality!.displayName;
healthFactors['Sleep']!['count'] += 1;
healthFactors['Sleep']!['levels']![levelName] =
(healthFactors['Sleep']!['levels']![levelName] ?? 0) + 1;
}
// Process energy level
if (entry.energyLevel != null) {
final levelName = entry.energyLevel!.displayName;
healthFactors['Energy']!['count'] += 1;
healthFactors['Energy']!['levels']![levelName] =
(healthFactors['Energy']!['levels']![levelName] ?? 0) + 1;
}
// Process focus level
if (entry.focusLevel != null) {
final levelName = entry.focusLevel!.displayName;
healthFactors['Focus']!['count'] += 1;
healthFactors['Focus']!['levels']![levelName] =
(healthFactors['Focus']!['levels']![levelName] ?? 0) + 1;
}
// Process appetite level
if (entry.appetiteLevel != null) {
final levelName = entry.appetiteLevel!.displayName;
healthFactors['Appetite']!['count'] += 1;
healthFactors['Appetite']!['levels']![levelName] =
(healthFactors['Appetite']!['levels']![levelName] ?? 0) + 1;
}
// Process dysphoria level
if (entry.dysphoriaLevel != null) {
final levelName = entry.dysphoriaLevel!.displayName;
healthFactors['Dysphoria']!['count'] += 1;
healthFactors['Dysphoria']!['levels']![levelName] =
(healthFactors['Dysphoria']!['levels']![levelName] ?? 0) + 1;
}
// Process exercise level
if (entry.exerciseLevel != null) {
final levelName = entry.exerciseLevel!.displayName;
healthFactors['Exercise']!['count'] += 1;
healthFactors['Exercise']!['levels']![levelName] =
(healthFactors['Exercise']!['levels']![levelName] ?? 0) + 1;
}
// Process libido level
if (entry.libidoLevel != null) {
final levelName = entry.libidoLevel!.displayName;
healthFactors['Libido']!['count'] += 1;
healthFactors['Libido']!['levels']![levelName] =
(healthFactors['Libido']!['levels']![levelName] ?? 0) + 1;
}
}
// Generate table rows
final rows = <List<String>>[];
// Filter out factors with no data
final factorsWithData = healthFactors.entries
.where((factor) => factor.value['count'] > 0)
.toList();
// Sort factors by total count (descending)
factorsWithData
.sort((a, b) => b.value['count'].compareTo(a.value['count']));
// Generate rows for each factor and its levels
for (final factor in factorsWithData) {
// Add main row for the factor
rows.add([
(factor.key),
'Total: ${factor.value['count']}',
'',
]);
// Add sub-rows for each level
final levels = factor.value['levels'] as Map<String, int>;
// Sort levels by count (descending)
final sortedLevels = levels.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
for (final level in sortedLevels) {
// Only add levels that have data
if (level.value > 0) {
// Calculate percentage
final percentage =
(level.value / factor.value['count'] * 100).toStringAsFixed(1);
rows.add([
' - ${level.key}',
level.value.toString(),
'$percentage%',
]);
}
}
}
return rows.isNotEmpty
? rows
: [
['No data', '-', '-']
];
}
List<List<String>> _getRelationshipRows() {
final relationships = widget.analysis.relationships;
// Sort by strength
relationships.sort((a, b) => b.strength.compareTo(a.strength));
// Convert to table rows
if (relationships.isEmpty) {
return [
['No data', '-', '-']
];
}
return relationships.take(10).map((relationship) {
return [
relationship.factorNames.join(' & '),
'${(relationship.strength * 100).toStringAsFixed(0)}%',
relationship.type.name,
];
}).toList();
}
List<List<String>> _getMedicationRows() {
// Get medications and taken medication data
final medications = ref.watch(medicationsProvider);
final takenMeds = ref.watch(medicationTakenProvider);
if (medications.isEmpty) {
return [
['No data', '-', '-']
];
}
final rows = <List<String>>[];
// For each medication, calculate doses taken and adherence
for (final medication in medications) {
// Calculate expected doses within the timeframe
final doseSchedules = <MedicationDose>[];
// Get date range from timeframe
final dateRange =
TimeframeSelector.getDateRangeForTimeframe(widget.timeframe);
if (dateRange == null) {
continue;
}
// Generate all expected doses for the medication in the date range
for (DateTime date = dateRange.start;
!date.isAfter(dateRange.end);
date = date.add(const Duration(days: 1))) {
// Check if medication is due on this date
if (medication.isDueOnDate(date)) {
// Add a dose for each time slot
for (int i = 0; i < medication.frequency; i++) {
final timeSlot = i < medication.timeOfDay.length
? '${medication.timeOfDay[i].hour.toString().padLeft(2, '0')}:00'
: i.toString();
doseSchedules.add(MedicationDose(
medicationId: medication.id,
date: date,
timeSlot: timeSlot,
));
}
}
}
// Count taken doses
int takenCount = 0;
for (final dose in doseSchedules) {
if (takenMeds.contains(dose.toKey())) {
takenCount++;
}
}
// Calculate adherence
final adherencePercentage = doseSchedules.isNotEmpty
? (takenCount / doseSchedules.length * 100)
: 0.0;
rows.add([
medication.name,
doseSchedules.length.toString(),
'${adherencePercentage.toStringAsFixed(1)}%',
]);
}
// Sort by adherence (descending)
rows.sort((a, b) {
final aAdherence = double.tryParse(a[2].replaceAll('%', '')) ?? 0;
final bAdherence = double.tryParse(b[2].replaceAll('%', '')) ?? 0;
return bAdherence.compareTo(aAdherence);
});
return rows;
}
List<List<String>> _getMedicationImpactRows() {
final impacts = widget.analysis.medicationImpacts;
if (impacts.isEmpty) {
return [
['No data', '-', '-']
];
}
// Convert to table rows
return impacts.map((impact) {
return [
impact.medicationName,
'${(impact.effectStrength * 100).toStringAsFixed(0)}%',
impact.daysToEffect.toString(),
];
}).toList();
}
List<List<String>> _getAppointmentRows() {
final bloodworkRecords = ref.watch(bloodworkRecordsProvider);
final filteredRecords = TimeframeSelector.filterByTimeframe(
items: bloodworkRecords,
timeframe: widget.timeframe,
getDate: (record) => record.date,
);
if (filteredRecords.isEmpty) {
return [
['No data', '-', '-']
];
}
// Count by appointment type
final typeCounts = <String, int>{};
for (final record in filteredRecords) {
final type = record.appointmentType.toString().split('.').last;
typeCounts[type] = (typeCounts[type] ?? 0) + 1;
}
// Calculate total and percentages
final totalAppointments = filteredRecords.length;
final rows = <List<String>>[];
for (final entry in typeCounts.entries) {
final percentage = (entry.value / totalAppointments * 100);
rows.add([
entry.key,
entry.value.toString(),
'${percentage.toStringAsFixed(1)}%',
]);
}
// Add total row
rows.add([
'Total',
totalAppointments.toString(),
'100%',
]);
return rows;
}
List<List<String>> _getLocationRows() {
final bloodworkRecords = ref.watch(bloodworkRecordsProvider);
final filteredRecords = TimeframeSelector.filterByTimeframe(
items: bloodworkRecords,
timeframe: widget.timeframe,
getDate: (record) => record.date,
);
if (filteredRecords.isEmpty) {
return [
['No data', '-', '-']
];
}
// Count by location
final locationCounts = <String, int>{};
for (final record in filteredRecords) {
final location = record.location ?? 'Unknown';
locationCounts[location] = (locationCounts[location] ?? 0) + 1;
}
// Calculate total and percentages
final totalAppointments = filteredRecords.length;
final rows = <List<String>>[];
for (final entry in locationCounts.entries) {
final percentage = (entry.value / totalAppointments * 100);
rows.add([
entry.key,
entry.value.toString(),
'${percentage.toStringAsFixed(1)}%',
]);
}
// Sort by count (descending)
rows.sort((a, b) => int.parse(b[1]).compareTo(int.parse(a[1])));
return rows;
}
List<List<String>> _getHormoneRows() {
final bloodworkRecords = ref.watch(bloodworkRecordsProvider);
final filteredRecords = TimeframeSelector.filterByTimeframe(
items: bloodworkRecords,
timeframe: widget.timeframe,
getDate: (record) => record.date,
);
if (filteredRecords.isEmpty) {
return [
['No data', '-', '-']
];
}
// Extract all hormone readings
final hormoneData = <String, Map<String, dynamic>>{};
for (final record in filteredRecords) {
for (final reading in record.hormoneReadings) {
if (!hormoneData.containsKey(reading.name)) {
hormoneData[reading.name] = {
'values': <double>[],
'units': reading.unit,
'min': reading.minValue,
'max': reading.maxValue,
};
}
// Add this reading's value
hormoneData[reading.name]!['values'].add(reading.value);
// Update min/max reference ranges if available
if (reading.minValue != null &&
hormoneData[reading.name]!['min'] == null) {
hormoneData[reading.name]!['min'] = reading.minValue;
}
if (reading.maxValue != null &&
hormoneData[reading.name]!['max'] == null) {
hormoneData[reading.name]!['max'] = reading.maxValue;
}
}
}
// Generate table rows
final rows = <List<String>>[];
for (final entry in hormoneData.entries) {
final values = entry.value['values'] as List<double>;
final avgValue = values.reduce((a, b) => a + b) / values.length;
final unit = entry.value['units'] as String;
// Determine if in range
final minValue = entry.value['min'] as double?;
final maxValue = entry.value['max'] as double?;
String inRange = '-';
if (minValue != null && maxValue != null) {
inRange = (avgValue >= minValue && avgValue <= maxValue) ? '' : '';
}
rows.add([
entry.key,
values.length.toString(),
'${avgValue.toStringAsFixed(1)} $unit',
inRange,
]);
}
// Sort by name
rows.sort((a, b) => a[0].compareTo(b[0]));
return rows;
}
List<List<String>> _getHormoneTrendRows() {
final analyses = widget.analysis.hormoneAnalyses;
if (analyses.isEmpty) {
return [
['No data', '-', '-']
];
}
// Convert to table rows
return analyses.map((hormone) {
final trend = hormone.trend;
return [
hormone.hormoneName,
trend.direction.name,
'${(trend.strength * 100).toStringAsFixed(0)}%',
];
}).toList();
}
}

View file

@ -0,0 +1,252 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// stat_utils.dart
//
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/utils/mood_utils.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
class StatUtils {
/// Get color for mood score (0-4 scale)
static Color getMoodColorFromScore(double score) {
if (score >= 3.5) return MoodUtils.getMoodColor(MoodRating.great);
if (score >= 2.5) return MoodUtils.getMoodColor(MoodRating.good);
if (score >= 1.5) return MoodUtils.getMoodColor(MoodRating.okay);
if (score >= 0.5) return MoodUtils.getMoodColor(MoodRating.meh);
return MoodUtils.getMoodColor(MoodRating.bad);
}
/// Get mood description from score
static String getMoodDescriptionFromScore(double score) {
if (score >= 3.5) return 'Great';
if (score >= 2.5) return 'Good';
if (score >= 1.5) return 'Okay';
if (score >= 0.5) return 'Meh';
return 'Bad';
}
/// Check if data spans multiple months
static bool hasMultiMonthData(List<MoodEntry> entries) {
if (entries.length < 2) return false;
final months = entries.map((e) => '${e.date.year}-${e.date.month}').toSet();
return months.length > 1;
}
static IconData getFactorIcon(String factorName) {
if (factorName == 'Mood') {
return Icons.mood;
} else if (factorName == 'Sleep') {
return Icons.bedtime;
} else if (factorName == 'Energy') {
return Icons.battery_charging_full;
} else if (factorName == 'Appetite') {
return Icons.restaurant;
} else if (factorName == 'Focus') {
return Icons.center_focus_strong;
} else if (factorName == 'Dysphoria') {
return Icons.sentiment_dissatisfied;
} else if (factorName == 'Libido') {
return Icons.favorite;
} else if (factorName == 'Exercise') {
return Icons.fitness_center;
} else if (factorName.startsWith('Emotion:')) {
return Icons.emoji_emotions;
} else if (factorName.startsWith('Hormone:')) {
return Icons.science;
} else {
return Icons.analytics;
}
}
static Color getFactorColor(String factorName) {
if (factorName == 'Mood') {
return Colors.amber;
} else if (factorName == 'Sleep') {
return Colors.indigo;
} else if (factorName == 'Energy') {
return Colors.orange;
} else if (factorName == 'Appetite') {
return Colors.green;
} else if (factorName == 'Focus') {
return Colors.blue;
} else if (factorName == 'Dysphoria') {
return Colors.purple;
} else if (factorName == 'Libido') {
return Colors.red;
} else if (factorName == 'Exercise') {
return Colors.teal;
} else if (factorName.startsWith('Emotion:')) {
return Colors.pinkAccent;
} else if (factorName.startsWith('Hormone:')) {
return Colors.deepPurple;
} else {
return AppColors.primary;
}
}
static IconData getPatternIcon(PatternType type) {
switch (type) {
case PatternType.cyclical:
return Icons.autorenew;
case PatternType.trend:
return Icons.trending_up;
case PatternType.cluster:
return Icons.bubble_chart;
case PatternType.anomaly:
return Icons.warning;
case PatternType.threshold:
return Icons.speed;
case PatternType.unknown:
return Icons.help_outline;
}
}
static Color getHormoneColor(String hormoneName, HormoneReading reading) {
final name = hormoneName.toLowerCase();
// Check if in range first
if (reading.minValue != null && reading.maxValue != null) {
if (reading.value < reading.minValue!) {
return Colors.red;
} else if (reading.value > reading.maxValue!) {
return Colors.red;
} else {
return Colors.green;
}
}
// Default colors by hormone type
if (name.contains('estrogen') || name.contains('estradiol')) {
return Colors.purple;
} else if (name.contains('testosterone')) {
return Colors.blue;
} else if (name.contains('thyroid') || name.contains('tsh')) {
return Colors.teal;
} else if (name.contains('cortisol')) {
return Colors.orange;
} else {
return Colors.indigo;
}
}
static String getTrendTitle(HealthTrend trend) {
switch (trend.direction) {
case TrendDirection.increasing:
return 'Increasing Levels';
case TrendDirection.decreasing:
return 'Decreasing Levels';
case TrendDirection.none:
return 'Stable Levels';
}
}
static String getDefaultTrendDescription(HormoneAnalysis analysis) {
final hormoneName = analysis.hormoneName;
switch (analysis.trend.direction) {
case TrendDirection.increasing:
if (analysis.averageLevel > analysis.optimalMaxLevel) {
return '$hormoneName levels are rising and currently above the optimal range.';
} else if (analysis.averageLevel < analysis.optimalMinLevel) {
return '$hormoneName levels are rising, which is positive as they are currently below the optimal range.';
} else {
return '$hormoneName levels are rising while remaining within the optimal range.';
}
case TrendDirection.decreasing:
if (analysis.averageLevel > analysis.optimalMaxLevel) {
return '$hormoneName levels are decreasing, which is positive as they are currently above the optimal range.';
} else if (analysis.averageLevel < analysis.optimalMinLevel) {
return '$hormoneName levels are decreasing and currently below the optimal range.';
} else {
return '$hormoneName levels are decreasing while remaining within the optimal range.';
}
case TrendDirection.none:
if (analysis.isWithinOptimalRange) {
return '$hormoneName levels are stable and within the optimal range, which is ideal.';
} else {
return '$hormoneName levels are stable but ${analysis.averageLevel < analysis.optimalMinLevel ? 'below' : 'above'} the optimal range.';
}
}
}
static String getDayName(int weekday) {
switch (weekday) {
case 0:
return 'Mon';
case 1:
return 'Tue';
case 2:
return 'Wed';
case 3:
return 'Thu';
case 4:
return 'Fri';
case 5:
return 'Sat';
case 6:
return 'Sun';
default:
return '';
}
}
static String getCustomInsight(String emotionName) {
final name = emotionName.toLowerCase();
if (name == 'happy' || name == 'joy') {
return 'Tracking what makes you happy can help reinforce positive habits and increase overall well-being.';
} else if (name == 'sad') {
return 'Noticing patterns in sadness can help identify triggers and develop coping strategies.';
} else if (name == 'angry') {
return 'Understanding anger patterns helps with emotional regulation and reducing stress.';
} else if (name == 'anxious' || name == 'stressed') {
return 'Recognizing anxiety triggers can be the first step toward developing effective management techniques.';
} else if (name == 'relaxed') {
return 'Identifying what helps you feel relaxed can be valuable for stress management.';
} else if (name == 'tired') {
return 'Tracking fatigue patterns can help optimize your sleep and energy management.';
} else if (name == 'excited') {
return 'Noticing what excites you can help bring more positive experiences into your life.';
} else if (name == 'bored') {
return 'Understanding boredom patterns can help identify areas where you might need more stimulation or purpose.';
} else {
return 'Understanding your emotional patterns can help you develop greater emotional intelligence and self-awareness.';
}
}
static String calculateDiversityScore(
int uniqueEmotions, double avgPerEntry) {
// Simple algorithm for diversity score (0-10 scale)
final rawScore = (uniqueEmotions * 0.3) + (avgPerEntry * 1.5);
return math.min(rawScore, 10).toStringAsFixed(1);
}
static String getDiversityDescription(
int uniqueEmotions, double avgPerEntry) {
final score =
double.parse(calculateDiversityScore(uniqueEmotions, avgPerEntry));
if (score >= 8.0) {
return 'You have a rich emotional vocabulary and track a diverse range of emotions';
} else if (score >= 6.0) {
return 'You have a good emotional awareness with a healthy range of tracked emotions';
} else if (score >= 4.0) {
return 'You track a moderate range of emotions, with room to expand your emotional awareness';
} else {
return 'Consider expanding your emotional tracking to develop greater emotional intelligence';
}
}
int min(double a, double b) {
return a < b ? a.toInt() : b.toInt();
}
}

View file

@ -0,0 +1,122 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// ui_helpers.dart
//
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
/// Helper methods for UI formatting and presentation
class UiHelpers {
/// Format a date for display
static String formatDate(DateTime date, {bool includeYear = false}) {
if (includeYear) {
return DateFormat('MMM d, yyyy').format(date);
} else {
return DateFormat('MMM d').format(date);
}
}
/// Get color for trend type
static Color getTrendColor(TrendType trendType) {
switch (trendType) {
case TrendType.increasing:
return Colors.green;
case TrendType.decreasing:
return Colors.red;
case TrendType.cyclic:
return Colors.purple;
case TrendType.none:
default:
return Colors.grey;
}
}
/// Get icon for trend type
static IconData getTrendIcon(TrendType trendType) {
switch (trendType) {
case TrendType.increasing:
return Icons.trending_up;
case TrendType.decreasing:
return Icons.trending_down;
case TrendType.cyclic:
return Icons.autorenew;
case TrendType.none:
default:
return Icons.trending_flat;
}
}
/// Format a percentage from a 0-1 ratio
static String formatPercentage(double ratio, {int decimals = 0}) {
return '${(ratio * 100).toStringAsFixed(decimals)}%';
}
/// Format a score from 0-1 to a human-readable strength description
static String getStrengthDescription(double score) {
if (score >= 0.8) {
return 'Very Strong';
} else if (score >= 0.6) {
return 'Strong';
} else if (score >= 0.4) {
return 'Moderate';
} else if (score >= 0.2) {
return 'Weak';
} else {
return 'Very Weak';
}
}
/// Format a factor name for display (remove category prefixes)
static String formatFactorName(String factorName) {
if (factorName.startsWith('Hormone: ')) {
return factorName.substring(9);
} else if (factorName.startsWith('Emotion: ')) {
return factorName.substring(9);
} else if (factorName.startsWith('Activity: ')) {
return factorName.substring(10);
} else if (factorName.startsWith('Symptom: ')) {
return factorName.substring(9);
}
return factorName;
}
/// Create a gradient color for visualization based on value
static LinearGradient getValueGradient(double value,
{bool reversed = false}) {
final colors = [
if (!reversed) ...[
Colors.red,
Colors.orange,
Colors.yellow,
Colors.green,
] else ...[
Colors.green,
Colors.yellow,
Colors.orange,
Colors.red,
],
];
return LinearGradient(
colors: colors,
stops: const [0.0, 0.33, 0.66, 1.0],
);
}
/// Get confidence level description
static String getConfidenceDescription(double confidence) {
if (confidence >= 0.8) {
return 'very high';
} else if (confidence >= 0.6) {
return 'high';
} else if (confidence >= 0.4) {
return 'moderate';
} else if (confidence >= 0.2) {
return 'low';
} else {
return 'very low';
}
}
}

View file

@ -0,0 +1,142 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// analysis_card.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
//import 'package:nokken/src/core/ui/shared_widgets.dart';
/// A card for displaying health analytics data
class AnalysisCard extends StatefulWidget {
final String title;
final String? subtitle;
final Widget content;
final IconData icon;
final Color color;
final VoidCallback? onTap;
final bool isExpandable;
final bool initiallyExpanded;
final VoidCallback? onExpandToggle;
const AnalysisCard({
super.key,
required this.title,
this.subtitle,
required this.content,
required this.icon,
required this.color,
this.onTap,
this.isExpandable = false,
this.initiallyExpanded = false,
this.onExpandToggle,
});
@override
State<AnalysisCard> createState() => _AnalysisCardState();
}
class _AnalysisCardState extends State<AnalysisCard> {
late bool _isExpanded;
@override
void initState() {
super.initState();
_isExpanded = widget.initiallyExpanded;
}
@override
void didUpdateWidget(AnalysisCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initiallyExpanded != widget.initiallyExpanded) {
_isExpanded = widget.initiallyExpanded;
}
}
void _toggleExpanded() {
setState(() {
_isExpanded = !_isExpanded;
});
// Notify parent if needed
if (widget.onExpandToggle != null) {
widget.onExpandToggle!();
}
}
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Card header
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: widget.color.withAlpha(30),
shape: BoxShape.circle,
),
child: Icon(
widget.icon,
color: widget.color,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: AppTextStyles.titleMedium,
),
if (widget.subtitle != null)
Text(
widget.subtitle!,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
),
),
if (widget.isExpandable)
IconButton(
icon: Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
),
onPressed: _toggleExpanded,
),
],
),
),
// Card content
AnimatedSize(
duration: const Duration(milliseconds: 200),
child: Container(
padding: EdgeInsets.all(
widget.isExpandable && !_isExpanded ? 0 : 16),
height: widget.isExpandable && !_isExpanded ? 0 : null,
child:
widget.isExpandable && !_isExpanded ? null : widget.content,
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,265 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// day_of_week_card.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/timeframe_selector.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/stats/charts/weekday_chart.dart';
class DayOfWeekCard extends ConsumerWidget {
final String timeframe;
const DayOfWeekCard({
super.key,
required this.timeframe,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Get mood entries from the provider
final moodEntries = ref.watch(moodEntriesProvider);
final filteredEntries = TimeframeSelector.filterByTimeframe(
items: moodEntries,
timeframe: timeframe,
getDate: (entry) => entry.date,
);
// Check if we have enough data
if (filteredEntries.length < 7) {
return _buildEmptyState();
}
// Create the analysis
final weekdayAnalysis = _createWeekdayAnalysis(filteredEntries);
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.amber.withAlpha(30),
shape: BoxShape.circle,
),
child: const Icon(
Icons.calendar_today,
color: Colors.amber,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Day of Week Patterns',
style: AppTextStyles.titleMedium,
),
Text(
'How your mood varies by day',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Chart
SizedBox(
height: 180,
child: WeekdayBarChart(
analysis: weekdayAnalysis,
factor: 'Mood',
),
),
const SizedBox(height: 16),
// Key findings
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.mood,
color: Colors.green,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Best day: ${weekdayAnalysis.getDayName(weekdayAnalysis.bestDays['Mood'] ?? 1)}',
style: AppTextStyles.bodyMedium,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.mood_bad,
color: Colors.red,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Worst day: ${weekdayAnalysis.getDayName(weekdayAnalysis.worstDays['Mood'] ?? 7)}',
style: AppTextStyles.bodyMedium,
),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildEmptyState() {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.amber.withAlpha(30),
shape: BoxShape.circle,
),
child: const Icon(
Icons.calendar_today,
color: Colors.amber,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Day of Week Patterns',
style: AppTextStyles.titleMedium,
),
Text(
'How your mood varies by day',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Empty state
Text(
'Not enough data to analyze patterns by day of week. Continue tracking your mood for at least a week to see day-specific patterns.',
style: AppTextStyles.bodyMedium,
),
],
),
),
);
}
WeekdayAnalysis _createWeekdayAnalysis(List<MoodEntry> entries) {
// Group entries by day of week
final Map<int, List<double>> moodByWeekday = {};
// Initialize all days of week
for (int i = 1; i <= 7; i++) {
moodByWeekday[i] = [];
}
// Process each entry
for (final entry in entries) {
final weekday = entry.date.weekday; // 1 = Monday, 7 = Sunday
// Convert mood to a 0-4 scale (4 = Great, 0 = Bad)
final moodValue = 4 - entry.mood.index.toDouble();
moodByWeekday[weekday]!.add(moodValue);
}
// Calculate average mood for each day
final Map<String, List<double>> factorsByWeekday = {};
for (int i = 1; i <= 7; i++) {
final dayMoods = moodByWeekday[i] ?? [];
if (dayMoods.isNotEmpty) {
factorsByWeekday['Mood-$i'] = dayMoods;
}
}
// Find best and worst days
int bestDay = 1;
int worstDay = 1;
double bestAvg = -1;
double worstAvg = 5;
for (int i = 1; i <= 7; i++) {
final dayMoods = moodByWeekday[i] ?? [];
if (dayMoods.isNotEmpty) {
final avg = dayMoods.reduce((a, b) => a + b) / dayMoods.length;
if (avg > bestAvg) {
bestAvg = avg;
bestDay = i;
}
if (avg < worstAvg) {
worstAvg = avg;
worstDay = i;
}
}
}
// Create WeekdayAnalysis object
return WeekdayAnalysis(
factorsByWeekday: factorsByWeekday,
bestDays: {'Mood': bestDay},
worstDays: {'Mood': worstDay},
description: 'Analysis of how your mood varies by day of the week',
);
}
}

View file

@ -0,0 +1,81 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// empty_state_view.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
class EmptyStateView {
static Widget noData() {
return Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
AppIcons.getOutlined('analytics'),
size: 80,
color: AppColors.secondary.withAlpha(160),
),
SharedWidgets.verticalSpace(24),
Text(
'Not enough data for analysis',
style: AppTextStyles.headlineSmall,
textAlign: TextAlign.center,
),
SharedWidgets.verticalSpace(16),
Text(
'Continue tracking your health data regularly to unlock comprehensive analytics and personalized insights.',
textAlign: TextAlign.center,
style: AppTextStyles.bodyLarge,
),
SharedWidgets.verticalSpace(40),
],
),
),
),
);
}
static Widget emptyTab({
required IconData icon,
required String title,
required String message,
}) {
return Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 80,
color: AppColors.secondary.withAlpha(160),
),
SharedWidgets.verticalSpace(24),
Text(
title,
style: AppTextStyles.headlineSmall,
textAlign: TextAlign.center,
),
SharedWidgets.verticalSpace(16),
Text(
message,
textAlign: TextAlign.center,
style: AppTextStyles.bodyLarge,
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,193 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// insights_overview_card.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/stats/utils/stat_utils.dart';
class InsightsOverviewCard extends StatelessWidget {
final ComprehensiveAnalysis analysis;
final VoidCallback onTap;
const InsightsOverviewCard({
super.key,
required this.analysis,
required this.onTap,
});
@override
Widget build(BuildContext context) {
// Get key insights from each category
final patterns = analysis.patterns.take(1).toList();
final relationships = analysis.relationships.take(1).toList();
final factorRankings = analysis.factorRankings.take(1).toList();
final anomalies = analysis.anomalies.take(1).toList();
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.purple.withAlpha(30),
shape: BoxShape.circle,
),
child: const Icon(
Icons.psychology,
color: Colors.purple,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Health Insights',
style: AppTextStyles.titleMedium,
),
Text(
'Patterns, correlations & key factors',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
),
),
const Icon(Icons.arrow_forward_ios, size: 16),
],
),
const SizedBox(height: 16),
// Preview of insights
// Pattern insight
if (patterns.isNotEmpty)
_buildInsightPreview(
title: 'Pattern detected',
description: patterns.first.name,
icon: StatUtils.getPatternIcon(patterns.first.type),
color: patterns.first.patternColor,
),
// Relationship insight
if (relationships.isNotEmpty)
_buildInsightPreview(
title: 'Strong correlation',
description: relationships.first.factorNames.join(' & '),
icon: Icons.compare_arrows,
color: Colors.blue,
),
// Factor ranking insight
if (factorRankings.isNotEmpty)
_buildInsightPreview(
title: 'Key factor identified',
description: 'Impact on ${factorRankings.first.targetFactor}',
icon: Icons.insights,
color: Colors.teal,
),
// Anomaly insight
if (anomalies.isNotEmpty)
_buildInsightPreview(
title: 'Unusual data point',
description:
'${anomalies.first.factorName} on ${anomalies.first.formattedDate}',
icon: Icons.warning,
color: Colors.orange,
),
// No insights case
if (patterns.isEmpty &&
relationships.isEmpty &&
factorRankings.isEmpty &&
anomalies.isEmpty)
_buildInsightPreview(
title: 'Keep tracking',
description: 'Continue tracking to reveal insights',
icon: Icons.info_outline,
color: Colors.grey,
),
const SizedBox(height: 8),
Center(
child: Text(
'Tap to explore all insights',
style: AppTextStyles.bodySmall.copyWith(
fontStyle: FontStyle.italic,
color: AppColors.onSurfaceVariant,
),
),
),
],
),
),
),
);
}
Widget _buildInsightPreview({
required String title,
required String description,
required IconData icon,
required Color color,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withAlpha(20),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: color,
size: 14,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTextStyles.bodySmall.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
description,
style: AppTextStyles.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,186 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// key_insights_card.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/stats/utils/stat_utils.dart';
class KeyInsightsCard extends StatelessWidget {
final ComprehensiveAnalysis analysis;
final Set<String> expandedCardIds;
final Function(String) onToggleExpanded;
const KeyInsightsCard({
super.key,
required this.analysis,
required this.expandedCardIds,
required this.onToggleExpanded,
});
@override
Widget build(BuildContext context) {
// Generate key insights based on analysis data
final insights = <Map<String, dynamic>>[];
// Add mood trend insight
if (analysis.moodTrend.strength > 0.3) {
insights.add({
'icon': analysis.moodTrend.type == TrendType.increasing
? Icons.trending_up
: analysis.moodTrend.type == TrendType.decreasing
? Icons.trending_down
: Icons.autorenew,
'color': analysis.moodTrend.type == TrendType.increasing
? Colors.green
: analysis.moodTrend.type == TrendType.decreasing
? Colors.red
: Colors.purple,
'text': analysis.moodTrend.description,
});
}
// Add top correlation insight
if (analysis.relationships.isNotEmpty) {
final topCorrelation = analysis.relationships.first;
insights.add({
'icon': Icons.compare_arrows,
'color': Colors.blue,
'text': topCorrelation.description,
});
}
// Add pattern insight
if (analysis.patterns.isNotEmpty) {
final topPattern = analysis.patterns.first;
insights.add({
'icon': StatUtils.getPatternIcon(topPattern.type),
'color': topPattern.patternColor,
'text': topPattern.description,
});
}
// Add factor impact insight
if (analysis.factorRankings.isNotEmpty) {
final topFactor = analysis.factorRankings.first;
insights.add({
'icon': Icons.insights,
'color': Colors.teal,
'text': topFactor.description,
});
}
// If we have no insights, add a default one
if (insights.isEmpty) {
insights.add({
'icon': Icons.info_outline,
'color': AppTheme.grey,
'text':
'Continue tracking your health to discover meaningful insights.',
});
}
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Key Insights',
style: AppTextStyles.titleMedium,
),
SharedWidgets.verticalSpace(16),
// Display key insights
...insights.take(3).map((insight) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: insight['color'].withAlpha(20),
shape: BoxShape.circle,
),
child: Icon(
insight['icon'],
color: insight['color'],
size: 20,
),
),
SharedWidgets.horizontalSpace(12),
Expanded(
child: Text(
insight['text'],
style: AppTextStyles.bodyMedium,
),
),
],
),
)),
// Show all insights button
if (insights.length > 3)
Center(
child: TextButton.icon(
onPressed: () => onToggleExpanded('key-insights'),
icon: Icon(
expandedCardIds.contains('key-insights')
? Icons.expand_less
: Icons.expand_more,
),
label: Text(
expandedCardIds.contains('key-insights')
? 'Show less'
: 'Show more',
),
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
),
),
),
// Additional insights if expanded
if (expandedCardIds.contains('key-insights') && insights.length > 3)
...insights.skip(3).map((insight) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: insight['color'].withAlpha(20),
shape: BoxShape.circle,
),
child: Icon(
insight['icon'],
color: insight['color'],
size: 20,
),
),
SharedWidgets.horizontalSpace(12),
Expanded(
child: Text(
insight['text'],
style: AppTextStyles.bodyMedium,
),
),
],
),
)),
],
),
),
);
}
}

View file

@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// loading_error_views.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_icons.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
class LoadingErrorViews {
static Widget loading() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
SharedWidgets.verticalSpace(24),
Text(
'Analyzing your health data...',
style: AppTextStyles.titleMedium,
),
SharedWidgets.verticalSpace(16),
const Text(
'Our AI is processing your health data to generate personalized insights.',
textAlign: TextAlign.center,
),
],
),
);
}
static Widget error({
required String error,
required VoidCallback onRetry,
required VoidCallback onGoBack,
}) {
return Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
AppIcons.getIcon('error'),
size: 80,
color: AppColors.error,
),
SharedWidgets.verticalSpace(24),
Text(
'Analysis Error',
style: AppTextStyles.headlineSmall,
),
SharedWidgets.verticalSpace(16),
Text(
'We encountered a problem while analyzing your health data: $error',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.error),
),
SharedWidgets.verticalSpace(40),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Try Again'),
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: AppColors.error,
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
SharedWidgets.verticalSpace(16),
TextButton(
onPressed: onGoBack,
child: const Text('Go Back'),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,376 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// monthly_trends_card.dart
//
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import 'package:nokken/src/core/ui/theme/app_colors.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/theme/app_theme.dart';
import 'package:nokken/src/core/ui/widgets/timeframe_selector.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:nokken/src/features/mood_tracker/providers/mood_state.dart';
class MonthlyTrendsCard extends ConsumerWidget {
final String timeframe;
const MonthlyTrendsCard({
super.key,
required this.timeframe,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Get mood entries from the provider
final moodEntries = ref.watch(moodEntriesProvider);
final filteredEntries = TimeframeSelector.filterByTimeframe(
items: moodEntries,
timeframe: timeframe,
getDate: (entry) => entry.date,
);
// Check if we have enough data
final hasEnoughData = filteredEntries.length >= 30;
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withAlpha(30),
shape: BoxShape.circle,
),
child: const Icon(
Icons.trending_up,
color: Colors.blue,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Monthly Trends',
style: AppTextStyles.titleMedium,
),
Text(
'Mood averages over months',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Content
if (hasEnoughData) ...[
SizedBox(
height: 200,
child: _buildMonthlyAverageChart(filteredEntries),
),
] else ...[
// Empty state
Text(
'Not enough data to analyze monthly trends. Continue tracking your mood for at least a month to see monthly averages.',
style: AppTextStyles.bodyMedium,
),
],
],
),
),
);
}
Widget _buildMonthlyAverageChart(List<MoodEntry> entries) {
try {
// Get the date range for the past 12 months
final now = DateTime.now();
final twelveMonthsAgo = DateTime(now.year - 1, now.month, now.day);
// Group entries by month
final Map<String, List<double>> moodByMonth = {};
// Initialize past 12 months
for (int i = 0; i < 12; i++) {
final month = DateTime(now.year, now.month - i, 1);
final monthKey =
'${month.year}-${month.month.toString().padLeft(2, '0')}';
moodByMonth[monthKey] = [];
}
// Process each entry
for (final entry in entries) {
// Skip entries older than 12 months
if (entry.date.isBefore(twelveMonthsAgo)) {
continue;
}
final monthKey =
'${entry.date.year}-${entry.date.month.toString().padLeft(2, '0')}';
// Convert mood to a 0-4 scale (4 = Great, 0 = Bad)
final moodValue = 4 - entry.mood.index.toDouble();
if (moodByMonth.containsKey(monthKey)) {
moodByMonth[monthKey]!.add(moodValue);
} else {
moodByMonth[monthKey] = [moodValue];
}
}
// Calculate monthly averages
final List<Map<String, dynamic>> monthlyData = [];
// Sort months chronologically (oldest first)
final sortedMonths = moodByMonth.keys.toList()..sort();
for (final monthKey in sortedMonths) {
final monthMoods = moodByMonth[monthKey] ?? [];
if (monthMoods.isNotEmpty) {
final avgMood =
monthMoods.reduce((a, b) => a + b) / monthMoods.length;
// Extract year and month from key
final parts = monthKey.split('-');
final year = int.parse(parts[0]);
final month = int.parse(parts[1]);
// Get month name
final monthName = DateFormat('MMM').format(DateTime(year, month));
monthlyData.add({
'month': monthName,
'year': year,
'averageMood': avgMood,
'count': monthMoods.length,
});
}
}
// Check if we have any data to show
if (monthlyData.isEmpty) {
return Center(
child: Text(
'No monthly data available',
style: AppTextStyles.bodyMedium,
),
);
}
// Only keep the most recent 6 months with data
final recentMonths =
monthlyData.reversed.take(6).toList().reversed.toList();
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: true,
horizontalInterval: 1,
verticalInterval: 1,
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppColors.onSurfaceVariant.withAlpha(40),
strokeWidth: 1,
);
},
getDrawingVerticalLine: (value) {
return FlLine(
color: AppColors.onSurfaceVariant.withAlpha(40),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < recentMonths.length) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
recentMonths[value.toInt()]['month'] ?? '',
style: AppTextStyles.bodySmall,
),
);
}
return const SizedBox();
},
reservedSize: 30,
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 1,
getTitlesWidget: (value, meta) {
if (value >= 0 && value <= 4 && value % 1 == 0) {
String moodLabel;
switch (value.toInt()) {
case 0:
moodLabel = 'Bad';
break;
case 1:
moodLabel = 'Meh';
break;
case 2:
moodLabel = 'OK';
break;
case 3:
moodLabel = 'Good';
break;
case 4:
moodLabel = 'Great';
break;
default:
moodLabel = '';
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Text(
moodLabel,
style: AppTextStyles.bodySmall,
),
);
}
return const SizedBox();
},
reservedSize: 50,
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: AppColors.onSurfaceVariant.withAlpha(40)),
),
minX: 0,
maxX: recentMonths.length - 1,
minY: 0,
maxY: 4,
lineBarsData: [
LineChartBarData(
spots: recentMonths.asMap().entries.map((entry) {
// Handle potential missing averageMood values safely
final averageMood =
entry.value['averageMood'] as double? ?? 2.0;
return FlSpot(
entry.key.toDouble(),
averageMood.clamp(0.0, 4.0), // Ensure value is in range
);
}).toList(),
isCurved: true,
gradient: const LinearGradient(
colors: [Colors.blue, Colors.purple],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 5,
color: _getMoodColor(spot.y),
strokeWidth: 2,
strokeColor: Colors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withAlpha(60),
Colors.purple.withAlpha(20),
],
),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.blueGrey.withAlpha(180),
getTooltipItems: (List<LineBarSpot> touchedSpots) {
return touchedSpots.map((spot) {
final index = spot.x.toInt();
if (index < 0 || index >= recentMonths.length) {
return LineTooltipItem(
'Invalid data point',
const TextStyle(color: AppTheme.white),
);
}
final month = recentMonths[index]['month'] ?? 'Unknown';
final year =
recentMonths[index]['year']?.toString() ?? 'Unknown';
final avgMood = spot.y.toStringAsFixed(1);
final count = recentMonths[index]['count']?.toString() ?? '0';
return LineTooltipItem(
'$month $year\nAvg: $avgMood (from $count entries)',
const TextStyle(
color: AppTheme.white,
fontWeight: FontWeight.bold,
),
);
}).toList();
},
),
),
),
);
} catch (e) {
return Center(
child: Text(
'Error creating chart: $e',
style: AppTextStyles.bodyMedium,
),
);
}
}
Color _getMoodColor(double moodValue) {
// Round to nearest integer
final moodIndex = moodValue.round();
switch (moodIndex) {
case 0:
return Colors.red.shade300; // Bad
case 1:
return Colors.orange.shade300; // Meh
case 2:
return Colors.yellow.shade300; // Okay
case 3:
return Colors.lime.shade300; // Good
case 4:
return Colors.green.shade300; // Great
default:
return Colors.blue.shade300; // Fallback
}
}
}

View file

@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// pattern_explanation_widget.dart
//
import 'package:flutter/material.dart';
import 'package:nokken/src/core/ui/theme/app_text_styles.dart';
import 'package:nokken/src/core/ui/widgets/shared_widgets.dart';
import 'package:nokken/src/features/stats/models/stat_analysis.dart';
import 'package:nokken/src/features/stats/utils/stat_utils.dart';
class PatternExplanationWidget extends StatelessWidget {
const PatternExplanationWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pattern Types:',
style: AppTextStyles.bodyMedium.copyWith(fontWeight: FontWeight.bold),
),
SharedWidgets.verticalSpace(12),
_buildPatternExplanation(
PatternType.trend,
Colors.blue,
'Trend patterns show steady increases or decreases in a health factor over time.',
),
SharedWidgets.verticalSpace(8),
_buildPatternExplanation(
PatternType.cyclical,
Colors.purple,
'Cyclical patterns show regular fluctuations that repeat over time (e.g., weekly or monthly cycles).',
),
SharedWidgets.verticalSpace(8),
_buildPatternExplanation(
PatternType.cluster,
Colors.teal,
'Cluster patterns reveal groups of related factors that tend to change together.',
),
SharedWidgets.verticalSpace(8),
_buildPatternExplanation(
PatternType.threshold,
Colors.orange,
'Threshold patterns identify when values consistently cross important boundaries.',
),
],
);
}
Widget _buildPatternExplanation(
PatternType type, Color color, String explanation) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
StatUtils.getPatternIcon(type),
size: 20,
color: color,
),
),
SharedWidgets.horizontalSpace(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
type.name,
style: AppTextStyles.bodyMedium.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
Text(
explanation,
style: AppTextStyles.bodySmall,
),
],
),
),
],
);
}
}
/// Helper widget for pattern type chips
class PatternTypeChip extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
const PatternTypeChip({
super.key,
required this.icon,
required this.label,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withAlpha(20),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: color,
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: color,
),
SharedWidgets.horizontalSpace(4),
Text(
label,
style: AppTextStyles.bodySmall.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}

View file

@ -0,0 +1,383 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// bloodwork_data_generator.dart
//
import 'dart:math' show Random;
import 'package:flutter/material.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
import 'package:nokken/src/features/bloodwork_tracker/models/bloodwork.dart';
/// A tool class to generate historical bloodwork data
class BloodworkDataGenerator {
final DatabaseService databaseService;
BloodworkDataGenerator(this.databaseService);
/// Generate bloodwork data, including:
/// - Bloodwork labs every 70-110 days
/// - 2-4 doctor appointments throughout the year
/// - 1 surgery appointment
Future<void> generateBloodworkData() async {
final random = Random();
// Start date: 364 days ago from today
final endDate = DateTime.now();
final startDate = endDate.subtract(const Duration(days: 364));
debugPrint(
'Generating bloodwork data from ${startDate.toIso8601String()} to ${endDate.toIso8601String()}');
// Generate bloodwork labs
await _generateBloodworkLabs(startDate, endDate, random);
// Generate doctor appointments
await _generateDoctorAppointments(startDate, endDate, random);
// Generate a surgery appointment
await _generateSurgeryAppointment(startDate, endDate, random);
debugPrint('Successfully generated bloodwork data.');
}
/// Generate bloodwork labs every 70-110 days
Future<void> _generateBloodworkLabs(
DateTime startDate, DateTime endDate, Random random) async {
// Start a bit before the actual start date to ensure we have consistent coverage
DateTime currentDate = startDate.subtract(const Duration(days: 30));
int labCount = 0;
while (currentDate.isBefore(endDate)) {
// Add a random interval between 70-110 days
final interval = random.nextInt(41) + 70; // 70-110 days
currentDate = currentDate.add(Duration(days: interval));
// Only add if it's within our target range
if (currentDate.isAfter(startDate) && currentDate.isBefore(endDate)) {
// Generate a bloodwork entry
final bloodwork = _generateBloodworkEntry(currentDate, random);
await databaseService.insertBloodwork(bloodwork);
labCount++;
}
}
debugPrint('Generated $labCount bloodwork lab records');
}
/// Generate 2-4 doctor appointments throughout the year
Future<void> _generateDoctorAppointments(
DateTime startDate, DateTime endDate, Random random) async {
// Decide how many appointments (2-4)
final appointmentCount = random.nextInt(3) + 2; // 2-4
debugPrint('Generating $appointmentCount doctor appointments');
// Generate appointment dates (evenly spaced with some randomness)
final yearInDays = 364;
final appointmentDates = <DateTime>[];
for (int i = 0; i < appointmentCount; i++) {
// Calculate roughly evenly spaced dates with some randomness
final targetDay = ((i + 1) * yearInDays / (appointmentCount + 1)).round();
final randomOffset = random.nextInt(21) - 10; // +/- 10 days
final dayOffset = targetDay + randomOffset;
final appointmentDate = startDate.add(Duration(days: dayOffset));
appointmentDates.add(appointmentDate);
}
// Sort dates and ensure no duplicates
appointmentDates.sort((a, b) => a.compareTo(b));
final uniqueDates = <DateTime>[];
DateTime? lastDate;
for (final date in appointmentDates) {
if (lastDate == null || date.difference(lastDate).inDays > 30) {
// Ensure at least 30 days between appointments
uniqueDates.add(date);
lastDate = date;
}
}
// Create and insert appointment records
for (final date in uniqueDates) {
final appointment = _generateAppointmentEntry(date, random);
await databaseService.insertBloodwork(appointment);
}
debugPrint('Generated ${uniqueDates.length} doctor appointment records');
}
/// Generate a surgery appointment within the year
Future<void> _generateSurgeryAppointment(
DateTime startDate, DateTime endDate, Random random) async {
// Place surgery in the middle third of the year (not too recent, not too far back)
final yearInDays = 364;
final targetDayStart = yearInDays ~/ 3;
final targetDayEnd = (yearInDays * 2) ~/ 3;
final dayOffset =
random.nextInt(targetDayEnd - targetDayStart) + targetDayStart;
final surgeryDate = startDate.add(Duration(days: dayOffset));
final surgery = _generateSurgeryEntry(surgeryDate, random);
await databaseService.insertBloodwork(surgery);
debugPrint('Generated surgery record for ${surgeryDate.toIso8601String()}');
}
/// Generate a bloodwork lab entry
Bloodwork _generateBloodworkEntry(DateTime date, Random random) {
// Always include the four required hormones
final hormoneReadings = <HormoneReading>[
_generateHormoneReading('Estradiol', random),
_generateHormoneReading('Testosterone', random),
_generateHormoneReading('Progesterone', random),
_generateHormoneReading('Prolactin', random),
];
// Add 0-6 additional random hormones
final additionalCount = random.nextInt(7); // 0-6
final allHormoneTypes = HormoneTypes.getHormoneTypes();
final requiredTypes = {
'Estradiol',
'Testosterone',
'Progesterone',
'Prolactin'
};
final availableTypes =
allHormoneTypes.where((type) => !requiredTypes.contains(type)).toList();
// Shuffle and take random selection
availableTypes.shuffle(random);
for (int i = 0; i < additionalCount && i < availableTypes.length; i++) {
hormoneReadings.add(_generateHormoneReading(availableTypes[i], random));
}
// Random time
final timeHour = random.nextInt(8) + 8; // Between a8am and 4pm
final timeMinute = random.nextInt(4) * 15; // 0, 15, 30, or 45 minutes
final dateTime = DateTime(
date.year,
date.month,
date.day,
timeHour,
timeMinute,
);
// Random location
final locations = [
'Main Hospital Lab',
'Downtown Clinic',
'Westside Medical Center',
'University Health Services',
'Eastside Lab',
];
final location = locations[random.nextInt(locations.length)];
// Random doctor
final doctors = [
'Dr. Martinez',
'Dr. Johnson',
'Dr. Williams',
'Dr. Chen',
'Dr. Taylor',
];
final doctor = doctors[random.nextInt(doctors.length)];
// Random notes
final notesOptions = [
'Routine bloodwork',
'Follow-up lab work',
'Quarterly hormone check',
'Regular monitoring',
'Medication adjustment labs',
null, // Sometimes no notes
];
final notes = notesOptions[random.nextInt(notesOptions.length)];
return Bloodwork(
date: dateTime,
appointmentType: AppointmentType.bloodwork,
hormoneReadings: hormoneReadings,
location: location,
doctor: doctor,
notes: notes,
);
}
/// Generate a doctor appointment entry
Bloodwork _generateAppointmentEntry(DateTime date, Random random) {
// Random time
final timeHour = random.nextInt(8) + 9; // Between 9am and 5pm
final timeMinute = random.nextInt(4) * 15; // 0, 15, 30, or 45 minutes
final dateTime = DateTime(
date.year,
date.month,
date.day,
timeHour,
timeMinute,
);
// Random location
final locations = [
'Primary Care Clinic',
'Endocrinology Department',
'Specialty Clinic',
'Medical Center',
'Healthcare Associates',
];
final location = locations[random.nextInt(locations.length)];
// Random doctor
final doctors = [
'Dr. Martinez',
'Dr. Johnson',
'Dr. Williams',
'Dr. Chen',
'Dr. Taylor',
];
final doctor = doctors[random.nextInt(doctors.length)];
// Random notes
final notesOptions = [
'Regular checkup',
'Follow-up appointment',
'Medication review',
'Consultation',
'Care planning',
'Treatment discussion',
null, // Sometimes no notes
];
final notes = notesOptions[random.nextInt(notesOptions.length)];
return Bloodwork(
date: dateTime,
appointmentType: AppointmentType.appointment,
hormoneReadings: [], // No hormone readings for appointments
location: location,
doctor: doctor,
notes: notes,
);
}
/// Generate a surgery entry
Bloodwork _generateSurgeryEntry(DateTime date, Random random) {
// Random time (surgeries often early)
final timeHour = random.nextInt(5) + 7; // Between 7am and 12pm
final timeMinute = random.nextInt(4) * 15; // 0, 15, 30, or 45 minutes
final dateTime = DateTime(
date.year,
date.month,
date.day,
timeHour,
timeMinute,
);
// Random location
final locations = [
'University Hospital',
'Memorial Surgery Center',
'Medical Center',
'Surgical Institute',
'Regional Hospital',
];
final location = locations[random.nextInt(locations.length)];
// Random doctor
final doctors = [
'Dr. Garcia',
'Dr. Smith',
'Dr. Patel',
'Dr. Wilson',
'Dr. Lee',
];
final doctor = doctors[random.nextInt(doctors.length)];
// Random notes about the surgery
final notesOptions = [
'Scheduled procedure',
'Outpatient surgery',
'Routine operation',
'Elective procedure',
'Standard intervention',
];
final notes = notesOptions[random.nextInt(notesOptions.length)];
return Bloodwork(
date: dateTime,
appointmentType: AppointmentType.surgery,
hormoneReadings: [], // No hormone readings for surgery
location: location,
doctor: doctor,
notes: notes,
);
}
/// Generate a random hormone reading
HormoneReading _generateHormoneReading(String hormoneName, Random random) {
// Get the default unit for this hormone
final unit = HormoneTypes.getDefaultUnit(hormoneName);
// Generate a reasonable value based on the hormone type
double value;
switch (hormoneName) {
case 'Estradiol':
// Typical range: 30-400 pg/mL
value = 30.0 + random.nextDouble() * 370.0;
break;
case 'Testosterone':
// Typical range: 15-70 ng/dL for women, 300-1000 ng/dL for men
value = 15.0 + random.nextDouble() * 500.0;
break;
case 'Progesterone':
// Typical range: 0.1-25 ng/mL
value = 0.1 + random.nextDouble() * 24.9;
break;
case 'Prolactin':
// Typical range: 3-30 ng/mL
value = 3.0 + random.nextDouble() * 27.0;
break;
case 'FSH':
// Typical range: 4-25 mIU/mL
value = 4.0 + random.nextDouble() * 21.0;
break;
case 'LH':
// Typical range: 2-20 mIU/mL
value = 2.0 + random.nextDouble() * 18.0;
break;
case 'TSH':
// Typical range: 0.4-4.0 μIU/mL
value = 0.4 + random.nextDouble() * 3.6;
break;
case 'Free T3':
// Typical range: 2.3-4.2 pg/mL
value = 2.3 + random.nextDouble() * 1.9;
break;
case 'Free T4':
// Typical range: 0.8-1.8 ng/dL
value = 0.8 + random.nextDouble() * 1.0;
break;
case 'SHBG':
// Typical range: 20-130 nmol/L
value = 20.0 + random.nextDouble() * 110.0;
break;
case 'DHT':
// Typical range: 24-65 ng/dL
value = 24.0 + random.nextDouble() * 41.0;
break;
default:
// Default range for other hormones
value = 10.0 + random.nextDouble() * 90.0;
}
// Round to 1 decimal place
value = (value * 10).round() / 10;
return HormoneReading(
name: hormoneName,
value: value,
unit: unit,
);
}
}

View file

@ -0,0 +1,232 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// medication_data_generator.dart
//
import 'dart:math' show Random;
import 'package:flutter/material.dart';
import 'package:nokken/src/core/constants/date_constants.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
import 'package:nokken/src/features/medication_tracker/models/medication.dart';
import 'package:nokken/src/features/medication_tracker/models/medication_dose.dart';
/// A tool class to generate historical medication data
class MedicationDataGenerator {
final DatabaseService databaseService;
MedicationDataGenerator(this.databaseService);
/// Generate sample medications and adherence data for 365 days
Future<void> generateMedicationData() async {
final random = Random();
// Start date: 364 days ago from today
final endDate = DateTime.now();
final startDate = endDate.subtract(const Duration(days: 364));
debugPrint(
'Generating medication data from ${startDate.toIso8601String()} to ${endDate.toIso8601String()}');
// Check existing medications and add more if needed
final existingMeds = await databaseService.getAllMedications();
debugPrint('Found ${existingMeds.length} existing medications');
// We want to have at least 5 medications
if (existingMeds.length < 5) {
debugPrint('Creating additional sample medications...');
final targetCount = 5 - existingMeds.length;
// Get sample medications and only use as many as needed
final allSampleMeds = _createSampleMedications();
final medsToAdd = allSampleMeds.take(targetCount).toList();
for (final med in medsToAdd) {
await databaseService.insertMedication(med);
}
debugPrint('Created ${medsToAdd.length} additional medications.');
}
// Get all medications
final allMeds = await databaseService.getAllMedications();
// Generate taking records for each medication
for (final med in allMeds) {
debugPrint('Generating adherence data for ${med.name}...');
// Set adherence rate based on medication type
double adherenceRate;
if (med.medicationType == MedicationType.injection ||
med.medicationType == MedicationType.patch) {
// Higher adherence for injections and patches (96-98%)
adherenceRate = 0.96 + (random.nextDouble() * 0.02);
} else {
// Lower adherence for pills and topical (95-97%)
adherenceRate = 0.95 + (random.nextDouble() * 0.02);
}
debugPrint(
'Using adherence rate of ${(adherenceRate * 100).toStringAsFixed(1)}% for ${med.name}');
// Generate records for each day
await _generateMedicationRecords(
med, startDate, endDate, adherenceRate, random);
}
debugPrint('Successfully generated medication adherence data.');
}
/// Create 5 sample medications of different types
List<Medication> _createSampleMedications() {
final now = DateTime.now();
final allDays = Set<String>.from(DateConstants.dayMap.values);
// Common morning and evening times
final morning = DateTime(now.year, now.month, now.day, 8, 0);
final evening = DateTime(now.year, now.month, now.day, 20, 0);
final noon = DateTime(now.year, now.month, now.day, 12, 0);
return [
// Pill 1: Daily antidepressant in the morning
Medication(
name: 'Sertraline',
dosage: '50mg',
startDate: DateTime.now().subtract(const Duration(days: 400)),
frequency: 1,
timeOfDay: [morning],
daysOfWeek: allDays,
currentQuantity: 28,
refillThreshold: 7,
medicationType: MedicationType.oral,
oralSubtype: OralSubtype.tablets,
notes: 'SSRI antidepressant',
doctor: 'Dr. Smith',
pharmacy: 'Local Pharmacy',
),
// Pill 2: Multivitamin taken once daily
Medication(
name: 'Multivitamin',
dosage: '1 tablet',
startDate: DateTime.now().subtract(const Duration(days: 380)),
frequency: 1,
timeOfDay: [noon],
daysOfWeek: allDays,
currentQuantity: 60,
refillThreshold: 10,
medicationType: MedicationType.oral,
oralSubtype: OralSubtype.tablets,
),
// Topical: Gel applied twice daily
Medication(
name: 'Topical Treatment',
dosage: 'Thin layer',
startDate: DateTime.now().subtract(const Duration(days: 390)),
frequency: 2,
timeOfDay: [morning, evening],
daysOfWeek: allDays,
currentQuantity: 1,
refillThreshold: 1,
medicationType: MedicationType.topical,
topicalSubtype: TopicalSubtype.gel,
),
// Patch: Applied weekly
Medication(
name: 'Hormone Patch',
dosage: '1 patch',
startDate: DateTime.now().subtract(const Duration(days: 395)),
frequency: 1,
timeOfDay: [morning],
daysOfWeek: {'Su'}, // Applied on Sundays
currentQuantity: 4,
refillThreshold: 2,
medicationType: MedicationType.patch,
),
// Injection: Intramuscular, biweekly
Medication(
name: 'Hormone Injection',
dosage: '0.5ml',
startDate: DateTime.now().subtract(const Duration(days: 392)),
frequency: 1,
timeOfDay: [evening],
daysOfWeek: {'F'}, // Injected on Fridays
currentQuantity: 2,
refillThreshold: 1,
medicationType: MedicationType.injection,
injectionDetails: InjectionDetails(
drawingNeedleType: '18G 1.5"',
drawingNeedleCount: 8,
drawingNeedleRefills: 2,
injectingNeedleType: '23G 1"',
injectingNeedleCount: 8,
injectingNeedleRefills: 2,
syringeType: '3ml Luer Lock',
syringeCount: 8,
syringeRefills: 2,
injectionSiteNotes: 'Rotate between thighs and glutes',
frequency: InjectionFrequency.biweekly,
subtype: InjectionSubtype.intramuscular,
siteRotation: InjectionSiteRotation(
sites: [
InjectionSite(siteNumber: 1, bodyArea: InjectionBodyArea.thigh),
InjectionSite(siteNumber: 2, bodyArea: InjectionBodyArea.thigh),
],
currentSiteIndex: 0,
),
),
doctor: 'Dr. Johnson',
pharmacy: 'Specialty Pharmacy',
),
];
}
/// Generate medication taking records based on specified adherence patterns
Future<void> _generateMedicationRecords(
Medication medication,
DateTime startDate,
DateTime endDate,
double adherenceRate,
Random random) async {
int recordCount = 0;
// For each day in the range
for (int i = 0; i <= 364; i++) {
final date = startDate.add(Duration(days: i));
// Check if this medication is scheduled for this day
if (medication.isDueOnDate(date)) {
// For each time slot
for (int timeIndex = 0; timeIndex < medication.frequency; timeIndex++) {
final timeSlot = timeIndex < medication.timeOfDay.length
? medication.timeOfDay[timeIndex].hour.toString()
: '$timeIndex';
// Create a proper MedicationDose object
final dose = MedicationDose(
medicationId: medication.id,
date: date,
timeSlot: timeSlot,
);
// Generate a custom key from the dose plus a unique identifier
final customKey = '${dose.toKey()}-historical-$i';
// Determine if the dose was taken based on adherence rate
final wasTaken = random.nextDouble() <= adherenceRate;
// Save the taking record
await databaseService.setMedicationTakenWithCustomKey(
medication.id, date, timeSlot, wasTaken, customKey);
if (wasTaken) {
recordCount++;
}
}
}
}
debugPrint('Generated $recordCount taking records for ${medication.name}');
}
}

View file

@ -0,0 +1,226 @@
// SPDX-FileCopyrightText: © 2025 Nøkken.io <nokken.io@proton.me>
// SPDX-License-Identifier: AGPL-3.0
//
// mood_data_generator.dart
//
import 'dart:math' show Random;
import 'package:flutter/material.dart';
import 'package:nokken/src/core/services/database/database_service.dart';
import 'package:nokken/src/core/services/database/database_service_mood.dart';
import 'package:nokken/src/features/mood_tracker/models/mood_entry.dart';
import 'package:uuid/uuid.dart';
/// A tool class to generate historical mood data
class MoodDataGenerator {
final DatabaseService databaseService;
MoodDataGenerator(this.databaseService);
/// Generate 365 days of mood entries
Future<void> generateMoodEntries() async {
final random = Random();
// Start date: 364 days ago from today
final endDate = DateTime.now();
final startDate = endDate.subtract(const Duration(days: 364));
debugPrint(
'Generating mood entries from ${startDate.toIso8601String()} to ${endDate.toIso8601String()}');
// Generate an entry for each day
for (int i = 0; i <= 364; i++) {
final date = startDate.add(Duration(days: i));
// Generate random mood entry
final entry = _generateRandomMoodEntry(date, random);
// Insert into database
await databaseService.insertMoodEntry(entry);
// Print progress
if (i % 30 == 0) {
debugPrint('Generated ${i + 1} entries...');
}
}
debugPrint('Successfully generated 365 mood entries.');
}
/// Generate a random but realistic mood entry for the given date
MoodEntry _generateRandomMoodEntry(DateTime date, Random random) {
// Generate a unique ID
final id = const Uuid().v4();
// Generate a base mood index with some weekly and seasonal patterns
int baseMoodModifier = 0;
// Weekday effects: people often feel better on weekends, worse on Mondays
if (date.weekday == DateTime.saturday || date.weekday == DateTime.sunday) {
baseMoodModifier =
random.nextDouble() < 0.7 ? 1 : 0; // Better mood on weekends
} else if (date.weekday == DateTime.monday) {
baseMoodModifier =
random.nextDouble() < 0.6 ? -1 : 0; // Worse mood on Mondays
}
// Seasonal effects: worse in winter, better in summer
final month = date.month;
int seasonalModifier = 0;
if (month >= 11 || month <= 2) {
// Winter months
seasonalModifier = random.nextDouble() < 0.6 ? -1 : 0;
} else if (month >= 5 && month <= 8) {
// Summer months
seasonalModifier = random.nextDouble() < 0.6 ? 1 : 0;
}
// Calculate final mood index with modifiers and randomness
// Assuming mood from terrible (0) to excellent (4)
int moodIndex =
2 + baseMoodModifier + seasonalModifier; // Start from neutral (2)
moodIndex += (random.nextInt(3) - 1); // Add -1, 0, or 1 for randomness
moodIndex = moodIndex.clamp(0, 4); // Ensure within valid range
// Convert index to actual MoodRating enum
final mood = MoodRating.values[moodIndex];
// Generate 1-6 random emotions
final emotionCount = random.nextInt(6) + 1;
final emotions = <Emotion>{};
while (emotions.length < emotionCount &&
emotions.length < Emotion.values.length) {
final emotionIndex = random.nextInt(Emotion.values.length);
emotions.add(Emotion.values[emotionIndex]);
}
// Decide which health metrics to include (3 to all)
final healthMetricsCount = random.nextInt(5) + 3; // 3 to 7 metrics
final healthMetricsToInclude = List.generate(7, (index) => index)
..shuffle(random);
final selectedHealthMetrics =
healthMetricsToInclude.take(healthMetricsCount).toList();
// Helper function to generate a correlated index
int correlatedIndex(
int baseIndex, double correlationStrength, int maxIndex) {
if (random.nextDouble() < correlationStrength) {
// Correlated value
final variation = random.nextInt(3) - 1; // -1, 0, or 1
return (baseIndex + variation).clamp(0, maxIndex - 1);
} else {
// Random value
return random.nextInt(maxIndex);
}
}
// Generate health metrics
SleepQuality? sleepQuality;
EnergyLevel? energyLevel;
LibidoLevel? libidoLevel;
AppetiteLevel? appetiteLevel;
FocusLevel? focusLevel;
DysphoriaLevel? dysphoriaLevel;
ExerciseLevel? exerciseLevel;
// Sleep quality - somewhat correlated with mood
if (selectedHealthMetrics.contains(0)) {
final sleepIndex =
correlatedIndex(moodIndex, 0.7, SleepQuality.values.length);
sleepQuality = SleepQuality.values[sleepIndex];
}
// Energy level - often follows sleep quality if available
if (selectedHealthMetrics.contains(1)) {
int energyIndex;
if (sleepQuality != null && random.nextDouble() < 0.8) {
// Correlate with sleep
energyIndex = correlatedIndex(SleepQuality.values.indexOf(sleepQuality),
0.8, EnergyLevel.values.length);
} else {
// Correlate with mood
energyIndex =
correlatedIndex(moodIndex, 0.6, EnergyLevel.values.length);
}
energyLevel = EnergyLevel.values[energyIndex];
}
// Libido level - moderately correlated with mood
if (selectedHealthMetrics.contains(2)) {
final libidoIndex =
correlatedIndex(moodIndex, 0.5, LibidoLevel.values.length);
libidoLevel = LibidoLevel.values[libidoIndex];
}
// Appetite level - less correlated with mood
if (selectedHealthMetrics.contains(3)) {
final appetiteIndex =
correlatedIndex(moodIndex, 0.4, AppetiteLevel.values.length);
appetiteLevel = AppetiteLevel.values[appetiteIndex];
}
// Focus level - often correlates with sleep and energy
if (selectedHealthMetrics.contains(4)) {
int focusIndex;
if (sleepQuality != null &&
energyLevel != null &&
random.nextDouble() < 0.7) {
// Derive from average of sleep and energy
final avgIndex = (SleepQuality.values.indexOf(sleepQuality) +
EnergyLevel.values.indexOf(energyLevel)) ~/
2;
focusIndex = correlatedIndex(avgIndex, 0.8, FocusLevel.values.length);
} else {
// Correlate with mood
focusIndex = correlatedIndex(moodIndex, 0.6, FocusLevel.values.length);
}
focusLevel = FocusLevel.values[focusIndex];
}
// Dysphoria level - often inversely correlates with mood
if (selectedHealthMetrics.contains(5)) {
// Invert the mood index for dysphoria correlation (higher mood = lower dysphoria)
final invertedMood = 4 - moodIndex; // Assuming 5 mood levels (0-4)
final dysphoriaIndex =
correlatedIndex(invertedMood, 0.75, DysphoriaLevel.values.length);
dysphoriaLevel = DysphoriaLevel.values[dysphoriaIndex];
}
// Exercise level - less strongly correlated with other metrics
if (selectedHealthMetrics.contains(6)) {
final exerciseIndex = random.nextInt(ExerciseLevel.values.length);
exerciseLevel = ExerciseLevel.values[exerciseIndex];
}
// Generate optional notes (30% chance)
String? notes;
if (random.nextDouble() < 0.3) {
final noteTemplates = [
"Feeling ${moodIndex > 2 ? 'pretty good' : 'a bit down'} today.",
"Today was ${moodIndex > 3 ? 'excellent' : moodIndex > 2 ? 'decent' : 'challenging'}.",
"${moodIndex > 3 ? 'Great' : moodIndex > 2 ? 'Good' : 'Tough'} day overall.",
"Noticed ${sleepQuality != null ? (SleepQuality.values.indexOf(sleepQuality) > 2 ? 'good sleep' : 'poor sleep') : 'fluctuating energy'} today.",
"Mood tracker note for ${date.day}/${date.month}/${date.year}.",
"${energyLevel != null && EnergyLevel.values.indexOf(energyLevel) > 2 ? 'High energy' : 'Low energy'} but ${moodIndex > 2 ? 'good spirits' : 'feeling down'}.",
"Trying to stay ${moodIndex < 2 ? 'positive despite challenges' : 'grateful for the good things'}.",
];
notes = noteTemplates[random.nextInt(noteTemplates.length)];
}
// Create and return the mood entry
return MoodEntry(
id: id,
date: date,
mood: mood,
emotions: emotions,
notes: notes,
sleepQuality: sleepQuality,
energyLevel: energyLevel,
libidoLevel: libidoLevel,
appetiteLevel: appetiteLevel,
focusLevel: focusLevel,
dysphoriaLevel: dysphoriaLevel,
exerciseLevel: exerciseLevel,
);
}
}