first commit - migrated from codeberg
This commit is contained in:
commit
5ead03e1f7
567 changed files with 102721 additions and 0 deletions
31
lib/main.dart
Normal file
31
lib/main.dart
Normal 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
37
lib/src/app.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
73
lib/src/core/constants/date_constants.dart
Normal file
73
lib/src/core/constants/date_constants.dart
Normal 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
|
||||
}
|
||||
}
|
127
lib/src/core/screens/main_screen.dart
Normal file
127
lib/src/core/screens/main_screen.dart
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
767
lib/src/core/services/database/database_service.dart
Normal file
767
lib/src/core/services/database/database_service.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
246
lib/src/core/services/database/database_service_mood.dart
Normal file
246
lib/src/core/services/database/database_service_mood.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
69
lib/src/core/services/database/drugs_repository.dart
Normal file
69
lib/src/core/services/database/drugs_repository.dart
Normal 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();
|
||||
});
|
114
lib/src/core/services/database/medical_providers_repository.dart
Normal file
114
lib/src/core/services/database/medical_providers_repository.dart
Normal 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();
|
||||
});
|
245
lib/src/core/services/error/validation_service.dart
Normal file
245
lib/src/core/services/error/validation_service.dart
Normal 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;
|
||||
}
|
139
lib/src/core/services/navigation/navigation_service.dart
Normal file
139
lib/src/core/services/navigation/navigation_service.dart
Normal 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);
|
||||
}
|
||||
}
|
122
lib/src/core/services/navigation/routes/app_router.dart
Normal file
122
lib/src/core/services/navigation/routes/app_router.dart
Normal 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}'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
57
lib/src/core/services/navigation/routes/route_arguments.dart
Normal file
57
lib/src/core/services/navigation/routes/route_arguments.dart
Normal 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});
|
||||
}
|
24
lib/src/core/services/navigation/routes/route_names.dart
Normal file
24
lib/src/core/services/navigation/routes/route_names.dart
Normal 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';
|
||||
}
|
198
lib/src/core/services/notifications/notification_service.dart
Normal file
198
lib/src/core/services/notifications/notification_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
2424
lib/src/core/services/statistics/health_analytics_service.dart
Normal file
2424
lib/src/core/services/statistics/health_analytics_service.dart
Normal file
File diff suppressed because it is too large
Load diff
106
lib/src/core/ui/theme/app_colors.dart
Normal file
106
lib/src/core/ui/theme/app_colors.dart
Normal 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;
|
||||
}
|
216
lib/src/core/ui/theme/app_icons.dart
Normal file
216
lib/src/core/ui/theme/app_icons.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
102
lib/src/core/ui/theme/app_text_styles.dart
Normal file
102
lib/src/core/ui/theme/app_text_styles.dart
Normal 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;
|
||||
}
|
803
lib/src/core/ui/theme/app_theme.dart
Normal file
803
lib/src/core/ui/theme/app_theme.dart
Normal 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;
|
||||
}
|
||||
}
|
37
lib/src/core/ui/theme/providers/theme_provider.dart
Normal file
37
lib/src/core/ui/theme/providers/theme_provider.dart
Normal 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);
|
||||
}
|
||||
}
|
143
lib/src/core/ui/widgets/autocomplete_text_field.dart
Normal file
143
lib/src/core/ui/widgets/autocomplete_text_field.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
166
lib/src/core/ui/widgets/dialog_service.dart
Normal file
166
lib/src/core/ui/widgets/dialog_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
837
lib/src/core/ui/widgets/dropdown_widgets.dart
Normal file
837
lib/src/core/ui/widgets/dropdown_widgets.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
103
lib/src/core/ui/widgets/shared_widgets.dart
Normal file
103
lib/src/core/ui/widgets/shared_widgets.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
161
lib/src/core/ui/widgets/timeframe_selector.dart
Normal file
161
lib/src/core/ui/widgets/timeframe_selector.dart
Normal 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);
|
||||
}
|
||||
}
|
328
lib/src/core/utils/date_time_formatter.dart
Normal file
328
lib/src/core/utils/date_time_formatter.dart
Normal 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);
|
||||
}
|
||||
}
|
94
lib/src/core/utils/get_icons_colors.dart
Normal file
94
lib/src/core/utils/get_icons_colors.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
212
lib/src/core/utils/get_labels.dart
Normal file
212
lib/src/core/utils/get_labels.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
11
lib/src/core/utils/list_extensions.dart
Normal file
11
lib/src/core/utils/list_extensions.dart
Normal 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;
|
||||
}
|
||||
}
|
8
lib/src/core/utils/string_extensions.dart
Normal file
8
lib/src/core/utils/string_extensions.dart
Normal 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)}";
|
||||
}
|
||||
}
|
269
lib/src/features/bloodwork_tracker/models/bloodwork.dart
Normal file
269
lib/src/features/bloodwork_tracker/models/bloodwork.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
516
lib/src/features/medication_tracker/models/medication.dart
Normal file
516
lib/src/features/medication_tracker/models/medication.dart
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
648
lib/src/features/mood_tracker/models/mood_entry.dart
Normal file
648
lib/src/features/mood_tracker/models/mood_entry.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
244
lib/src/features/mood_tracker/providers/mood_state.dart
Normal file
244
lib/src/features/mood_tracker/providers/mood_state.dart
Normal 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,
|
||||
};
|
||||
});
|
680
lib/src/features/mood_tracker/screens/mood_tracker_screen.dart
Normal file
680
lib/src/features/mood_tracker/screens/mood_tracker_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
298
lib/src/features/mood_tracker/utils/mood_utils.dart
Normal file
298
lib/src/features/mood_tracker/utils/mood_utils.dart
Normal 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;
|
||||
}
|
||||
}
|
130
lib/src/features/mood_tracker/widgets/mood_field_chips.dart
Normal file
130
lib/src/features/mood_tracker/widgets/mood_field_chips.dart
Normal 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;
|
||||
}
|
||||
}
|
269
lib/src/features/mood_tracker/widgets/mood_quick_selector.dart
Normal file
269
lib/src/features/mood_tracker/widgets/mood_quick_selector.dart
Normal 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
252
lib/src/features/scheduler/providers/calendar_event_tracker.dart
Normal file
252
lib/src/features/scheduler/providers/calendar_event_tracker.dart
Normal 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];
|
||||
}
|
||||
}
|
1229
lib/src/features/scheduler/screens/calendar_screen.dart
Normal file
1229
lib/src/features/scheduler/screens/calendar_screen.dart
Normal file
File diff suppressed because it is too large
Load diff
1358
lib/src/features/scheduler/screens/daily_tracker_screen.dart
Normal file
1358
lib/src/features/scheduler/screens/daily_tracker_screen.dart
Normal file
File diff suppressed because it is too large
Load diff
528
lib/src/features/scheduler/screens/year_screen.dart
Normal file
528
lib/src/features/scheduler/screens/year_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
146
lib/src/features/scheduler/widgets/calendar_day_cell.dart
Normal file
146
lib/src/features/scheduler/widgets/calendar_day_cell.dart
Normal 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;
|
||||
}
|
||||
}
|
296
lib/src/features/settings/screens/admin_screen.dart
Normal file
296
lib/src/features/settings/screens/admin_screen.dart
Normal 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 = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
180
lib/src/features/settings/screens/settings_screen.dart
Normal file
180
lib/src/features/settings/screens/settings_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
246
lib/src/features/stats/charts/correlations_chart.dart
Normal file
246
lib/src/features/stats/charts/correlations_chart.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
137
lib/src/features/stats/charts/impact_chart.dart
Normal file
137
lib/src/features/stats/charts/impact_chart.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
98
lib/src/features/stats/charts/strength_guide.dart
Normal file
98
lib/src/features/stats/charts/strength_guide.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
155
lib/src/features/stats/charts/weekday_chart.dart
Normal file
155
lib/src/features/stats/charts/weekday_chart.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
616
lib/src/features/stats/models/stat_analysis.dart
Normal file
616
lib/src/features/stats/models/stat_analysis.dart
Normal 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,
|
||||
});
|
||||
}
|
2038
lib/src/features/stats/providers/statistics_provider.dart
Normal file
2038
lib/src/features/stats/providers/statistics_provider.dart
Normal file
File diff suppressed because it is too large
Load diff
774
lib/src/features/stats/screens/emotion_analysis_screen.dart
Normal file
774
lib/src/features/stats/screens/emotion_analysis_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
362
lib/src/features/stats/screens/insights_screen.dart
Normal file
362
lib/src/features/stats/screens/insights_screen.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
331
lib/src/features/stats/screens/stats_overview_screen.dart
Normal file
331
lib/src/features/stats/screens/stats_overview_screen.dart
Normal 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();
|
||||
});
|
943
lib/src/features/stats/screens/totals_screen.dart
Normal file
943
lib/src/features/stats/screens/totals_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
252
lib/src/features/stats/utils/stat_utils.dart
Normal file
252
lib/src/features/stats/utils/stat_utils.dart
Normal 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();
|
||||
}
|
||||
}
|
122
lib/src/features/stats/utils/ui_helpers.dart
Normal file
122
lib/src/features/stats/utils/ui_helpers.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
142
lib/src/features/stats/widgets/analysis_card.dart
Normal file
142
lib/src/features/stats/widgets/analysis_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
265
lib/src/features/stats/widgets/day_of_week_card.dart
Normal file
265
lib/src/features/stats/widgets/day_of_week_card.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
81
lib/src/features/stats/widgets/empty_state_view.dart
Normal file
81
lib/src/features/stats/widgets/empty_state_view.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
193
lib/src/features/stats/widgets/insights_overview_card.dart
Normal file
193
lib/src/features/stats/widgets/insights_overview_card.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
186
lib/src/features/stats/widgets/key_insights_card.dart
Normal file
186
lib/src/features/stats/widgets/key_insights_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
85
lib/src/features/stats/widgets/loading_error_views.dart
Normal file
85
lib/src/features/stats/widgets/loading_error_views.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
376
lib/src/features/stats/widgets/monthly_trends_card.dart
Normal file
376
lib/src/features/stats/widgets/monthly_trends_card.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
138
lib/src/features/stats/widgets/pattern_explanation.dart
Normal file
138
lib/src/features/stats/widgets/pattern_explanation.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
383
lib/tools/bloodwork_data_generator.dart
Normal file
383
lib/tools/bloodwork_data_generator.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
232
lib/tools/medication_data_generator.dart
Normal file
232
lib/tools/medication_data_generator.dart
Normal 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}');
|
||||
}
|
||||
}
|
226
lib/tools/mood_data_generator.dart
Normal file
226
lib/tools/mood_data_generator.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue