diff --git a/lib/main.dart b/lib/main.dart index 6cb06d8..ea84eca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_gemma/flutter_gemma.dart'; @@ -12,6 +14,8 @@ import 'services/biomarker_dictionary.dart'; import 'services/embedding_service.dart'; import 'services/gemma_service.dart'; import 'services/vector_store_service.dart'; +import 'theme/app_colors.dart'; +import 'theme/koshika_design_system.dart'; /// Global references — initialized in SplashScreen before navigation. late ObjectBoxStore objectbox; @@ -34,9 +38,8 @@ class KoshikaApp extends StatelessWidget { return MaterialApp( title: 'Koshika', debugShowCheckedModeBanner: false, - theme: _buildTheme(Brightness.light), - darkTheme: _buildTheme(Brightness.dark), - themeMode: ThemeMode.system, + theme: _buildTheme(), + themeMode: ThemeMode.light, home: const SplashScreen(), routes: { '/home': (_) => const HomeScreen(), @@ -45,43 +48,66 @@ class KoshikaApp extends StatelessWidget { ); } - ThemeData _buildTheme(Brightness brightness) { - final isDark = brightness == Brightness.dark; - - // Health-themed teal/blue palette - const seed = Color(0xFF0D9488); // Teal-600 - - final colorScheme = ColorScheme.fromSeed( - seedColor: seed, - brightness: brightness, + ThemeData _buildTheme() { + const colorScheme = ColorScheme( + brightness: Brightness.light, + primary: AppColors.primary, + onPrimary: Colors.white, + primaryContainer: AppColors.primaryContainer, + onPrimaryContainer: AppColors.onPrimaryContainer, + secondary: AppColors.secondary, + onSecondary: Colors.white, + secondaryContainer: AppColors.info, + onSecondaryContainer: Colors.white, + tertiary: AppColors.tertiary, + onTertiary: Colors.white, + tertiaryContainer: AppColors.tertiaryContainer, + onTertiaryContainer: AppColors.onTertiaryContainer, + error: AppColors.error, + onError: Colors.white, + errorContainer: AppColors.errorContainer, + onErrorContainer: AppColors.onErrorContainer, + surface: AppColors.surface, + onSurface: AppColors.onSurface, + onSurfaceVariant: AppColors.onSurfaceVariant, + outline: AppColors.outlineVariant, + outlineVariant: AppColors.outlineVariant, + surfaceContainerLowest: AppColors.surfaceContainerLowest, + surfaceContainerLow: AppColors.surfaceContainerLow, + surfaceContainerHigh: AppColors.surfaceContainerHigh, + surfaceContainerHighest: AppColors.surfaceContainerHighest, ); return ThemeData( useMaterial3: true, colorScheme: colorScheme, - brightness: brightness, - fontFamily: 'Roboto', - appBarTheme: AppBarTheme( - centerTitle: true, + brightness: Brightness.light, + textTheme: KoshikaTypography.textTheme, + appBarTheme: const AppBarTheme( + centerTitle: false, elevation: 0, - backgroundColor: isDark ? colorScheme.surface : colorScheme.primary, - foregroundColor: isDark ? colorScheme.onSurface : colorScheme.onPrimary, + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, ), cardTheme: CardThemeData( - elevation: isDark ? 1 : 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + color: AppColors.surfaceContainerLowest, + shape: RoundedRectangleBorder(borderRadius: KoshikaRadius.xxl), ), navigationBarTheme: NavigationBarThemeData( elevation: 0, - indicatorColor: colorScheme.primaryContainer, + indicatorColor: AppColors.primaryContainer, labelTextStyle: WidgetStatePropertyAll( - TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: colorScheme.onSurface, + KoshikaTypography.textTheme.labelSmall!.copyWith( + color: AppColors.onSurface, ), ), ), + filledButtonTheme: FilledButtonThemeData(style: KoshikaButtonStyles.pill), + outlinedButtonTheme: OutlinedButtonThemeData( + style: KoshikaButtonStyles.outlinedPill, + ), + scaffoldBackgroundColor: AppColors.surface, ); } } @@ -116,33 +142,134 @@ class _HomeScreenState extends State { Widget build(BuildContext context) { return Scaffold( body: _buildCurrentScreen(), - bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: (index) { - setState(() => _currentIndex = index); - }, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.dashboard_outlined), - selectedIcon: Icon(Icons.dashboard), - label: 'Dashboard', + bottomNavigationBar: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.sm, + vertical: KoshikaSpacing.xs, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _NavItem( + index: 0, + currentIndex: _currentIndex, + icon: Icons.dashboard_outlined, + activeIcon: Icons.dashboard, + label: 'Dashboard', + onTap: () => setState(() => _currentIndex = 0), + ), + _NavItem( + index: 1, + currentIndex: _currentIndex, + icon: Icons.description_outlined, + activeIcon: Icons.description, + label: 'Reports', + onTap: () => setState(() => _currentIndex = 1), + ), + _NavItem( + index: 2, + currentIndex: _currentIndex, + icon: Icons.chat_outlined, + activeIcon: Icons.chat, + label: 'Chat', + onTap: () => setState(() => _currentIndex = 2), + ), + _NavItem( + index: 3, + currentIndex: _currentIndex, + icon: Icons.settings_outlined, + activeIcon: Icons.settings, + label: 'Settings', + onTap: () => setState(() => _currentIndex = 3), + ), + ], + ), + ), + ), ), - NavigationDestination( - icon: Icon(Icons.description_outlined), - selectedIcon: Icon(Icons.description), - label: 'Reports', - ), - NavigationDestination( - icon: Icon(Icons.chat_outlined), - selectedIcon: Icon(Icons.chat), - label: 'Chat', - ), - NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ], + ), + ), + ); + } +} + +class _NavItem extends StatelessWidget { + final int index; + final int currentIndex; + final IconData icon; + final IconData activeIcon; + final String label; + final VoidCallback onTap; + + const _NavItem({ + required this.index, + required this.currentIndex, + required this.icon, + required this.activeIcon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isSelected = index == currentIndex; + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: SizedBox( + width: 72, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.xs, + ), + decoration: BoxDecoration( + color: isSelected + ? AppColors.primaryContainer.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: KoshikaRadius.pill, + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Icon( + isSelected ? activeIcon : icon, + key: ValueKey(isSelected), + size: 24, + color: isSelected + ? AppColors.primary + : AppColors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 2), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected + ? AppColors.primary + : AppColors.onSurfaceVariant, + ), + child: Text(label), + ), + ], + ), ), ); } diff --git a/lib/screens/biomarker_detail_screen.dart b/lib/screens/biomarker_detail_screen.dart index d747ec2..cd50f79 100644 --- a/lib/screens/biomarker_detail_screen.dart +++ b/lib/screens/biomarker_detail_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; + import '../main.dart'; import '../models/models.dart'; +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; import '../widgets/flag_badge.dart'; +import '../widgets/status_badge.dart'; import '../widgets/biomarker_trend_chart.dart'; import '../widgets/reference_range_gauge.dart'; - -import '../theme/app_colors.dart'; +import '../widgets/shimmer_loading.dart'; class BiomarkerDetailScreen extends StatefulWidget { final String biomarkerKey; @@ -65,32 +68,75 @@ class _BiomarkerDetailScreenState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - if (_isLoading) { return Scaffold( + backgroundColor: AppColors.surface, appBar: AppBar(title: const Text('Loading...')), - body: const Center(child: CircularProgressIndicator()), + body: ShimmerScope( + child: ListView( + padding: const EdgeInsets.all(KoshikaSpacing.base), + children: [ + ShimmerBox( + width: double.infinity, + height: 200, + borderRadius: KoshikaRadius.xxl, + ), + const SizedBox(height: KoshikaSpacing.xl), + ShimmerLine(width: 120), + const SizedBox(height: KoshikaSpacing.base), + ShimmerBox( + width: double.infinity, + height: 180, + borderRadius: KoshikaRadius.xxl, + ), + const SizedBox(height: KoshikaSpacing.xl), + ShimmerLine(width: 80), + const SizedBox(height: KoshikaSpacing.md), + ShimmerBox( + width: double.infinity, + height: 60, + borderRadius: KoshikaRadius.lg, + ), + const SizedBox(height: KoshikaSpacing.sm), + ShimmerBox( + width: double.infinity, + height: 60, + borderRadius: KoshikaRadius.lg, + ), + const SizedBox(height: KoshikaSpacing.sm), + ShimmerBox( + width: double.infinity, + height: 60, + borderRadius: KoshikaRadius.lg, + ), + ], + ), + ), ); } if (_errorMessage != null) { return Scaffold( + backgroundColor: AppColors.surface, appBar: AppBar(title: const Text('Error')), body: Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.error_outline, size: 48, color: Colors.red), - const SizedBox(height: 16), + const Icon( + Icons.error_outline, + size: 48, + color: AppColors.error, + ), + const SizedBox(height: KoshikaSpacing.base), Text( _errorMessage!, textAlign: TextAlign.center, - style: theme.textTheme.bodyLarge, + style: Theme.of(context).textTheme.bodyLarge, ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), FilledButton.icon( onPressed: () { setState(() { @@ -111,6 +157,7 @@ class _BiomarkerDetailScreenState extends State { if (history.isEmpty) { return Scaffold( + backgroundColor: AppColors.surface, appBar: AppBar(title: const Text('Biomarker Details')), body: const Center( child: Text('No data available for this biomarker.'), @@ -118,174 +165,182 @@ class _BiomarkerDetailScreenState extends State { ); } - // The most recent result is the first in the list (since it's ordered descending by date) final latestResult = history.first; final hasNumericValues = history.any((r) => r.value != null); return Scaffold( + backgroundColor: AppColors.surface, appBar: AppBar(title: Text(latestResult.displayName)), body: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(KoshikaSpacing.base), children: [ - // Header Card - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - latestResult.displayName, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - if (latestResult.category != null) - Chip( - label: Text( - latestResult.category!, - style: const TextStyle(fontSize: 12), - ), - backgroundColor: theme.colorScheme.primaryContainer, - side: BorderSide.none, - visualDensity: VisualDensity.compact, - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - latestResult.formattedValue, - style: theme.textTheme.displaySmall?.copyWith( - fontWeight: FontWeight.bold, - color: AppColors.primary, - ), - ), - const SizedBox(width: 8), - if (latestResult.unit != null) - Text( - latestResult.unit!, - style: theme.textTheme.titleMedium?.copyWith( - color: AppColors.onSurfaceVariant, - ), - ), - ], + // ── Header Card ───────────────────────────────────────────── + Container( + decoration: KoshikaDecorations.card, + padding: KoshikaSpacing.cardPaddingAsymmetric, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Metric label + Text( + 'LATEST RESULT • ${DateFormat('MMM d').format(latestResult.testDate).toUpperCase()}', + style: KoshikaTypography.metricLabel, + ), + const SizedBox(height: KoshikaSpacing.md), + + // Hero value + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + latestResult.formattedValue, + style: KoshikaTypography.heroMetric.copyWith( + color: AppColors.primary, ), - FlagBadge(flag: latestResult.flag), - ], - ), - const SizedBox(height: 12), - const Divider(), - const SizedBox(height: 12), - ReferenceRangeGauge( - value: latestResult.value, - refLow: latestResult.refLow, - refHigh: latestResult.refHigh, - ), - const SizedBox(height: 8), - Text( - 'Reference Range: ${latestResult.formattedRefRange}', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, ), + const SizedBox(width: KoshikaSpacing.sm), + if (latestResult.unit != null) + Text( + latestResult.unit!, + style: KoshikaTypography.metricUnit, + ), + ], + ), + const SizedBox(height: KoshikaSpacing.md), + + // Status badge + StatusBadge(flag: latestResult.flag), + + const SizedBox(height: KoshikaSpacing.lg), + + // Reference gauge + Text('REFERENCE RANGE', style: KoshikaTypography.metricLabel), + const SizedBox(height: KoshikaSpacing.sm), + ReferenceRangeGauge( + value: latestResult.value, + refLow: latestResult.refLow, + refHigh: latestResult.refHigh, + ), + const SizedBox(height: KoshikaSpacing.xs), + Text( + latestResult.formattedRefRange, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, ), - if (latestResult.loincCode != null) - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text( - 'LOINC: ${latestResult.loincCode}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant.withValues( - alpha: 0.7, - ), - ), + ), + + if (latestResult.loincCode != null) + Padding( + padding: const EdgeInsets.only(top: KoshikaSpacing.xs), + child: Text( + 'LOINC: ${latestResult.loincCode}', + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, ), ), - ], - ), + ), + ], ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), - // Trend Chart Section + // ── Trend Section ─────────────────────────────────────────── Row( children: [ Text( 'Trend', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: KoshikaTypography.sectionHeader.copyWith(fontSize: 20), ), - const SizedBox(width: 8), + const SizedBox(width: KoshikaSpacing.sm), if (!hasNumericValues) Expanded( child: Text( '(No numeric data to chart)', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + style: TextStyle( + fontSize: 14, + color: AppColors.textSecondary, ), ), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), if (hasNumericValues) BiomarkerTrendChart(history: history), + if (hasNumericValues) const SizedBox(height: KoshikaSpacing.xl), - if (hasNumericValues) const SizedBox(height: 24), - - // History List + // ── History Section ───────────────────────────────────────── Text( 'History', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: KoshikaTypography.sectionHeader.copyWith(fontSize: 20), ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), - Card( + // History list with alternating backgrounds + Container( + decoration: BoxDecoration( + color: AppColors.surfaceContainerLowest, + borderRadius: KoshikaRadius.xxl, + ), clipBehavior: Clip.antiAlias, - child: ListView.separated( + child: ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: history.length, - separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final result = history[index]; final report = result.report.target; - final rowColor = _flagRowColor(result.flag); + // Alternating row backgrounds + final bgColor = index.isEven + ? AppColors.surfaceContainerLowest + : AppColors.surfaceContainerLow; return Container( - color: rowColor, - child: ListTile( - title: Row( - children: [ - Text( - '${result.formattedValue} ${result.unit ?? ""}', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + color: bgColor, + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.lg, + vertical: KoshikaSpacing.md, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat.yMMMd().format(result.testDate), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + report?.labName ?? 'Unknown Lab', + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], ), - const SizedBox(width: 12), - FlagBadge(flag: result.flag), - ], - ), - subtitle: Text( - '${DateFormat.yMMMd().format(result.testDate)} • ${report?.labName ?? "Unknown Lab"}', - ), + ), + Text( + '${result.formattedValue} ${result.unit ?? ""}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.onSurface, + ), + ), + const SizedBox(width: KoshikaSpacing.md), + FlagBadge(flag: result.flag), + ], ), ); }, @@ -295,22 +350,4 @@ class _BiomarkerDetailScreenState extends State { ), ); } - - /// Map a [BiomarkerFlag] to a subtle row background color. - Color _flagRowColor(BiomarkerFlag flag) { - switch (flag) { - case BiomarkerFlag.normal: - return AppColors.statusActive.withValues(alpha: 0.06); - case BiomarkerFlag.borderline: - return AppColors.statusBusy.withValues(alpha: 0.08); - case BiomarkerFlag.low: - return AppColors.statusBusy.withValues(alpha: 0.08); - case BiomarkerFlag.high: - return AppColors.error.withValues(alpha: 0.07); - case BiomarkerFlag.critical: - return AppColors.error.withValues(alpha: 0.12); - case BiomarkerFlag.unknown: - return Colors.transparent; - } - } } diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 2e69ad0..dbb8b3f 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui'; import 'package:flutter/material.dart'; @@ -10,6 +11,7 @@ import '../services/chat_context_builder.dart'; import '../services/citation_extractor.dart'; import '../services/embedding_service.dart'; import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; import '../widgets/chat_message_bubble.dart'; /// The AI Chat screen — transforms from download/load prompt into a @@ -48,12 +50,15 @@ class _ChatScreenState extends State { void initState() { super.initState(); _contextBuilder = ChatContextBuilder(vectorStore: vectorStoreService); - _loadMostRecentSession(); _statusSubscription = gemmaService.modelStatusStream.listen((info) { if (mounted) { setState(() => _modelInfo = info); } }); + // Auto-load model if already downloaded + if (_modelInfo.status == ModelStatus.ready) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadModel()); + } } @override @@ -65,31 +70,6 @@ class _ChatScreenState extends State { super.dispose(); } - void _loadMostRecentSession() { - try { - final sessions = objectbox.getAllSessions(); - if (sessions.isEmpty) return; - - final session = sessions.first; - final persisted = objectbox.getMessagesForSession(session.id); - _currentSession = session; - _messages.addAll( - persisted.map( - (m) => ChatMessage( - id: 'db-${m.id}', - content: m.content, - role: _roleFromIndex(m.roleIndex), - timestamp: m.timestamp, - isStreaming: false, - isError: m.isError, - ), - ), - ); - } catch (e, st) { - _logStorageError('load most recent session', e, st); - } - } - ChatRole _roleFromIndex(int index) { if (index >= 0 && index < ChatRole.values.length) { return ChatRole.values[index]; @@ -180,7 +160,7 @@ class _ChatScreenState extends State { onTap: () => Navigator.of(context).pop(_SessionSheetAction.newChat), ), - const Divider(height: 1), + const SizedBox(height: KoshikaSpacing.xs), Expanded( child: sessions.isEmpty ? const Center(child: Text('No previous conversations')) @@ -605,7 +585,7 @@ class _NotDownloadedView extends StatelessWidget { return Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -622,15 +602,14 @@ class _NotDownloadedView extends StatelessWidget { color: AppColors.primary, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), Text( 'AI Assistant', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, + style: KoshikaTypography.sectionHeader.copyWith( color: AppColors.onSurface, ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( 'Download the ${modelInfo.name} model to chat about your health data privately on this device.', style: theme.textTheme.bodyMedium?.copyWith( @@ -638,13 +617,15 @@ class _NotDownloadedView extends StatelessWidget { ), textAlign: TextAlign.center, ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.md, + ), decoration: BoxDecoration( color: AppColors.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.outlineVariant), + borderRadius: KoshikaRadius.lg, ), child: Row( mainAxisSize: MainAxisSize.min, @@ -654,20 +635,20 @@ class _NotDownloadedView extends StatelessWidget { size: 16, color: AppColors.onSurfaceVariant, ), - const SizedBox(width: 8), + const SizedBox(width: KoshikaSpacing.sm), Text( 'Download size: ~${modelInfo.formattedSize}', style: theme.textTheme.bodySmall?.copyWith( color: AppColors.onSurfaceVariant, ), ), - const SizedBox(width: 16), + const SizedBox(width: KoshikaSpacing.base), const Icon( Icons.wifi, size: 16, color: AppColors.onSurfaceVariant, ), - const SizedBox(width: 4), + const SizedBox(width: KoshikaSpacing.xs), Text( 'Wi-Fi recommended', style: theme.textTheme.bodySmall?.copyWith( @@ -677,10 +658,9 @@ class _NotDownloadedView extends StatelessWidget { ], ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), FilledButton.icon( onPressed: onDownload, - style: FilledButton.styleFrom(backgroundColor: AppColors.primary), icon: const Icon(Icons.download), label: const Text('Download AI Model'), ), @@ -708,7 +688,7 @@ class _DownloadingView extends StatelessWidget { return Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -725,21 +705,21 @@ class _DownloadingView extends StatelessWidget { color: AppColors.primary, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), Text( 'Downloading ${modelInfo.name}', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, + style: KoshikaTypography.sectionHeader.copyWith( + fontSize: 20, color: AppColors.onSurface, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), SizedBox( width: 280, child: Column( children: [ ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: KoshikaRadius.md, child: LinearProgressIndicator( value: progress > 0 ? progress / 100.0 : null, minHeight: 8, @@ -749,7 +729,7 @@ class _DownloadingView extends StatelessWidget { ), ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( progress > 0 ? '$progress% downloaded' @@ -761,20 +741,16 @@ class _DownloadingView extends StatelessWidget { ], ), ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), Text( 'Please keep the app open', style: theme.textTheme.bodySmall?.copyWith( color: AppColors.onSurfaceVariant.withValues(alpha: 0.6), ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), OutlinedButton.icon( onPressed: onCancel, - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.primary, - side: const BorderSide(color: AppColors.outlineVariant), - ), icon: const Icon(Icons.close, size: 18), label: const Text('Cancel'), ), @@ -800,7 +776,7 @@ class _ReadyView extends StatelessWidget { return Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -817,15 +793,14 @@ class _ReadyView extends StatelessWidget { color: AppColors.primary, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), Text( 'AI Model Ready', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, + style: KoshikaTypography.sectionHeader.copyWith( color: AppColors.onSurface, ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( 'The model is downloaded. Load it into memory to start chatting about your health data.', style: theme.textTheme.bodyMedium?.copyWith( @@ -833,10 +808,9 @@ class _ReadyView extends StatelessWidget { ), textAlign: TextAlign.center, ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), FilledButton.icon( onPressed: onLoad, - style: FilledButton.styleFrom(backgroundColor: AppColors.primary), icon: const Icon(Icons.play_arrow_rounded), label: const Text('Load Model'), ), @@ -860,7 +834,7 @@ class _LoadingView extends StatelessWidget { return Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -872,15 +846,15 @@ class _LoadingView extends StatelessWidget { color: AppColors.primary, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), Text( 'Loading AI Model...', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, + style: KoshikaTypography.sectionHeader.copyWith( + fontSize: 20, color: AppColors.onSurface, ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( 'This may take a few seconds depending on your device.', style: theme.textTheme.bodyMedium?.copyWith( @@ -929,7 +903,10 @@ class _ChatView extends StatelessWidget { // Search mode indicator Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: 6, + ), color: isSemanticSearchActive ? AppColors.primaryContainer.withValues(alpha: 0.12) : AppColors.surfaceContainerHigh.withValues(alpha: 0.6), @@ -944,7 +921,7 @@ class _ChatView extends StatelessWidget { ? AppColors.primary : AppColors.onSurfaceVariant.withValues(alpha: 0.6), ), - const SizedBox(width: 4), + const SizedBox(width: KoshikaSpacing.xs), Text( isSemanticSearchActive ? 'Semantic search active' @@ -966,8 +943,8 @@ class _ChatView extends StatelessWidget { : ListView.builder( controller: scrollController, padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.sm, ), itemCount: messages.length, itemBuilder: (context, index) { @@ -976,84 +953,120 @@ class _ChatView extends StatelessWidget { ), ), - // Input bar - Container( - decoration: BoxDecoration( - color: AppColors.surface, - boxShadow: [ - BoxShadow( - color: AppColors.onSurface.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, -2), + // Glassmorphic input bar + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.85), ), - ], - ), - padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), - child: SafeArea( - top: false, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - // Text input - Expanded( - child: TextField( - controller: textController, - maxLines: 5, - minLines: 1, - enabled: !isGenerating, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - hintText: 'Ask about your health data...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, + padding: const EdgeInsets.fromLTRB( + KoshikaSpacing.base, + KoshikaSpacing.sm, + KoshikaSpacing.sm, + KoshikaSpacing.xs, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Text input + Expanded( + child: TextField( + controller: textController, + maxLines: 5, + minLines: 1, + enabled: !isGenerating, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: 'Ask about your health data...', + border: OutlineInputBorder( + borderRadius: KoshikaRadius.xxl, + borderSide: BorderSide.none, + ), + filled: true, + fillColor: AppColors.surfaceContainerLow, + contentPadding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.lg, + vertical: KoshikaSpacing.md, + ), + ), + onSubmitted: (_) => onSend(), + ), + ), + const SizedBox(width: KoshikaSpacing.sm), + + // Send / Stop button + if (isGenerating) + IconButton.filledTonal( + onPressed: onStop, + style: IconButton.styleFrom( + backgroundColor: AppColors.primaryContainer + .withValues(alpha: 0.2), + foregroundColor: AppColors.primary, + ), + icon: const Icon(Icons.stop_rounded), + tooltip: 'Stop generation', + ) + else + ValueListenableBuilder( + valueListenable: textController, + builder: (context, value, _) { + final hasText = value.text.trim().isNotEmpty; + return IconButton.filled( + onPressed: hasText ? onSend : null, + style: IconButton.styleFrom( + backgroundColor: hasText + ? AppColors.primary + : AppColors.surfaceContainerHigh, + foregroundColor: hasText + ? Colors.white + : AppColors.onSurfaceVariant, + ), + icon: const Icon(Icons.send_rounded), + tooltip: 'Send message', + ); + }, + ), + ], + ), + // Privacy label + Padding( + padding: const EdgeInsets.only( + top: KoshikaSpacing.xs, + bottom: KoshikaSpacing.xs, ), - filled: true, - fillColor: AppColors.surfaceContainerLow, - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.lock_outline, + size: 11, + color: AppColors.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + const SizedBox(width: 3), + Text( + 'All processing happens on your device', + style: TextStyle( + fontSize: 10, + color: AppColors.onSurfaceVariant.withValues( + alpha: 0.5, + ), + ), + ), + ], ), ), - onSubmitted: (_) => onSend(), - ), + ], ), - const SizedBox(width: 8), - - // Send / Stop button - if (isGenerating) - IconButton.filledTonal( - onPressed: onStop, - style: IconButton.styleFrom( - backgroundColor: AppColors.primaryContainer.withValues( - alpha: 0.2, - ), - foregroundColor: AppColors.primary, - ), - icon: const Icon(Icons.stop_rounded), - tooltip: 'Stop generation', - ) - else - ValueListenableBuilder( - valueListenable: textController, - builder: (context, value, _) { - final hasText = value.text.trim().isNotEmpty; - return IconButton.filled( - onPressed: hasText ? onSend : null, - style: IconButton.styleFrom( - backgroundColor: hasText - ? AppColors.primary - : AppColors.surfaceContainerHigh, - foregroundColor: hasText - ? Colors.white - : AppColors.onSurfaceVariant, - ), - icon: const Icon(Icons.send_rounded), - tooltip: 'Send message', - ); - }, - ), - ], + ), ), ), ), @@ -1077,7 +1090,7 @@ class _EmptyChatView extends StatelessWidget { return Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1094,15 +1107,15 @@ class _EmptyChatView extends StatelessWidget { color: AppColors.primary, ), ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), Text( 'Ask about your health data', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, + style: KoshikaTypography.sectionHeader.copyWith( + fontSize: 18, color: AppColors.onSurface, ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( 'Your lab data is automatically included as context. ' 'Try asking:', @@ -1111,39 +1124,21 @@ class _EmptyChatView extends StatelessWidget { ), textAlign: TextAlign.center, ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), _SuggestionChip( label: 'How is my thyroid?', onTap: onSuggestionTap, ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), _SuggestionChip( label: 'What does my cholesterol mean?', onTap: onSuggestionTap, ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), _SuggestionChip( label: 'Which values are out of range?', onTap: onSuggestionTap, ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.lock_outline, - size: 14, - color: AppColors.onSurfaceVariant.withValues(alpha: 0.6), - ), - const SizedBox(width: 4), - Text( - 'All processing happens on your device', - style: theme.textTheme.bodySmall?.copyWith( - color: AppColors.onSurfaceVariant.withValues(alpha: 0.6), - ), - ), - ], - ), ], ), ), @@ -1164,11 +1159,13 @@ class _SuggestionChip extends StatelessWidget { return GestureDetector( onTap: () => onTap?.call(label), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.md, + ), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), + borderRadius: KoshikaRadius.xl, color: AppColors.surfaceContainerLow, - border: Border.all(color: AppColors.outlineVariant), ), child: Text( '"$label"', @@ -1198,7 +1195,7 @@ class _ErrorView extends StatelessWidget { return Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1215,25 +1212,22 @@ class _ErrorView extends StatelessWidget { color: AppColors.error, ), ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), Text( 'Something went wrong', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, + style: KoshikaTypography.sectionHeader.copyWith( + fontSize: 20, color: AppColors.onSurface, ), ), - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), if (modelInfo.errorMessage != null) Container( width: double.infinity, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(KoshikaSpacing.base), decoration: BoxDecoration( color: AppColors.errorContainer.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.error.withValues(alpha: 0.2), - ), + borderRadius: KoshikaRadius.lg, ), child: Text( modelInfo.errorMessage!, @@ -1243,10 +1237,9 @@ class _ErrorView extends StatelessWidget { textAlign: TextAlign.center, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), FilledButton.icon( onPressed: onRetry, - style: FilledButton.styleFrom(backgroundColor: AppColors.primary), icon: const Icon(Icons.refresh), label: const Text('Retry'), ), diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 6f8cf19..a1e80be 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import '../main.dart'; import '../models/models.dart'; import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; import 'biomarker_detail_screen.dart'; class DashboardScreen extends StatefulWidget { @@ -219,11 +220,15 @@ class _DashboardScreenState extends State { } outOfRangeResults.sort((a, b) { - if (a.flag == BiomarkerFlag.critical && b.flag != BiomarkerFlag.critical) + if (a.flag == BiomarkerFlag.critical && + b.flag != BiomarkerFlag.critical) { return -1; - if (b.flag == BiomarkerFlag.critical && a.flag != BiomarkerFlag.critical) + } + if (b.flag == BiomarkerFlag.critical && + a.flag != BiomarkerFlag.critical) { return 1; - return 0; + } + return a.displayName.compareTo(b.displayName); }); final allHistories = objectbox.getHistoryForBiomarkers( @@ -248,7 +253,12 @@ class _DashboardScreenState extends State { _buildAppBar(context), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + padding: const EdgeInsets.fromLTRB( + KoshikaSpacing.screenHorizontal, + KoshikaSpacing.screenVertical, + KoshikaSpacing.screenHorizontal, + KoshikaSpacing.xxl, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -259,7 +269,7 @@ class _DashboardScreenState extends State { criticalCount: criticalCount, lastReport: lastReport, ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), // ── Attention Needed ───────────────────────────── if (outOfRangeResults.isNotEmpty) ...[ @@ -270,17 +280,13 @@ class _DashboardScreenState extends State { onPressed: () {}, child: Text( 'View All', - style: TextStyle( - color: AppColors.secondary, - fontWeight: FontWeight.bold, - fontSize: 12, - letterSpacing: 0.8, - ), + style: KoshikaTypography.metricLabel + .copyWith(color: AppColors.secondary), ), ) : null, ), - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), ...outOfRangeResults .take(3) .map( @@ -301,18 +307,19 @@ class _DashboardScreenState extends State { ), ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), ], // ── Category Trends ────────────────────────────── _SectionHeader(title: 'Core Category Trends'), - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), ...categories.map((cat) { final catResults = latestResults.values .where((r) => r.category == cat) .toList(); - if (catResults.isEmpty) + if (catResults.isEmpty) { return const SizedBox.shrink(); + } final barHeights = _categoryBarHeights( catResults, allHistories, @@ -320,7 +327,9 @@ class _DashboardScreenState extends State { final badge = _categoryStatusBadge(catResults); final icon = _categoryIcons[cat] ?? Icons.science; return Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.only( + bottom: KoshikaSpacing.md, + ), child: _CategoryTrendCard( category: cat, icon: icon, @@ -344,7 +353,7 @@ class _DashboardScreenState extends State { // ── Clinical Insights ──────────────────────────── if (insights.isNotEmpty) ...[ - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), _InsightsCard(insights: insights), ], ], @@ -360,38 +369,33 @@ class _DashboardScreenState extends State { return SafeArea( child: Center( child: Padding( - padding: const EdgeInsets.all(40), + padding: const EdgeInsets.all(KoshikaSpacing.xxxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 80, - height: 80, + width: 120, + height: 120, decoration: BoxDecoration( - color: AppColors.primaryContainer.withValues(alpha: 0.1), + color: AppColors.primary.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: const Icon( Icons.biotech_outlined, - size: 40, + size: 60, color: AppColors.primary, ), ), - const SizedBox(height: 24), - const Text( + const SizedBox(height: KoshikaSpacing.xl), + Text( 'No lab reports yet', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w800, - color: AppColors.onSurface, - ), + style: KoshikaTypography.sectionHeader, ), - const SizedBox(height: 8), - const Text( + const SizedBox(height: KoshikaSpacing.sm), + Text( 'Import a lab report PDF from the Reports tab to get started.', - style: TextStyle( - fontSize: 14, - color: AppColors.onSurfaceVariant, + style: KoshikaTypography.cardSubtitle.copyWith( + color: AppColors.textSecondary, ), textAlign: TextAlign.center, ), @@ -409,12 +413,10 @@ class _DashboardScreenState extends State { backgroundColor: AppColors.surfaceContainerLowest, surfaceTintColor: Colors.transparent, elevation: 0, - shadowColor: Colors.black12, - titleSpacing: 20, - title: const Text( + titleSpacing: KoshikaSpacing.lg, + title: Text( 'Koshika', - style: TextStyle( - fontWeight: FontWeight.w800, + style: KoshikaTypography.sectionHeader.copyWith( color: AppColors.primary, fontSize: 20, letterSpacing: -0.3, @@ -460,10 +462,7 @@ class _HeroCard extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - decoration: BoxDecoration( - color: AppColors.primaryContainer, - borderRadius: BorderRadius.circular(20), - ), + decoration: KoshikaDecorations.heroCard, clipBehavior: Clip.antiAlias, child: Stack( children: [ @@ -481,32 +480,29 @@ class _HeroCard extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Label Text( 'CLINICAL STATUS', - style: TextStyle( + style: KoshikaTypography.metricLabel.copyWith( color: AppColors.onPrimaryContainer.withValues(alpha: 0.8), - fontSize: 10, - fontWeight: FontWeight.w700, letterSpacing: 1.4, ), ), - const SizedBox(height: 6), + const SizedBox(height: KoshikaSpacing.xs), // Status title Text( _statusTitle, - style: const TextStyle( + style: KoshikaTypography.sectionHeader.copyWith( color: Colors.white, fontSize: 28, - fontWeight: FontWeight.w800, letterSpacing: -0.5, ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), // Description Text( _statusDescription, @@ -516,33 +512,32 @@ class _HeroCard extends StatelessWidget { height: 1.5, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), // Stats row - Row( + Wrap( + spacing: KoshikaSpacing.xxl, + runSpacing: KoshikaSpacing.sm, children: [ _StatItem( value: '$totalTracked', - label: 'Biomarkers\nTracked', + label: 'TRACKED', valueColor: Colors.white, ), - const SizedBox(width: 32), _StatItem( value: '$abnormalCount', - label: 'Abnormal\nFlags', + label: 'FLAGGED', valueColor: abnormalCount > 0 ? const Color(0xFFFFB4AB) : Colors.white, ), - if (lastReport != null) ...[ - const SizedBox(width: 32), + if (lastReport != null) _StatItem( value: DateFormat( 'MMM d', ).format(lastReport!.reportDate), - label: 'Last\nReport', + label: 'LAST REPORT', valueColor: AppColors.onPrimaryContainer, ), - ], ], ), ], @@ -572,21 +567,15 @@ class _StatItem extends StatelessWidget { children: [ Text( value, - style: TextStyle( + style: KoshikaTypography.heroMetric.copyWith( color: valueColor, - fontSize: 26, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, + fontSize: 36, ), ), Text( label, - style: TextStyle( + style: KoshikaTypography.metricLabel.copyWith( color: AppColors.onPrimaryContainer.withValues(alpha: 0.6), - fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - height: 1.4, ), ), ], @@ -675,20 +664,20 @@ class _AttentionCard extends StatelessWidget { child: Container( decoration: BoxDecoration( color: _bgColor, - borderRadius: BorderRadius.circular(16), + borderRadius: KoshikaRadius.xxl, ), - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(KoshikaSpacing.base), child: Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: _iconBgColor, - borderRadius: BorderRadius.circular(12), + borderRadius: KoshikaRadius.lg, ), child: Icon(_icon, color: _iconColor, size: 20), ), - const SizedBox(width: 14), + const SizedBox(width: KoshikaSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -706,30 +695,28 @@ class _AttentionCard extends StatelessWidget { '${result.formattedValue} ${result.unit ?? ''} · Ref: ${result.formattedRefRange}', style: const TextStyle( fontSize: 12, - color: AppColors.onSurfaceVariant, + color: AppColors.textSecondary, ), ), ], ), ), - const SizedBox(width: 8), + const SizedBox(width: KoshikaSpacing.sm), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(20), + color: AppColors.surfaceContainerLowest.withValues(alpha: 0.5), + borderRadius: KoshikaRadius.pill, ), child: Text( _flagLabel.toUpperCase(), - style: TextStyle( + style: KoshikaTypography.metricLabel.copyWith( fontSize: 10, - fontWeight: FontWeight.w700, color: _iconColor, - letterSpacing: 0.5, ), ), ), - const SizedBox(width: 4), + const SizedBox(width: KoshikaSpacing.xs), const Icon( Icons.chevron_right, size: 18, @@ -770,13 +757,14 @@ class _CategoryTrendCard extends StatelessWidget { @override Widget build(BuildContext context) { final biomarkerNames = results.map((r) => r.displayName).take(3).join(', '); + final catColor = AppColors.categoryColor(category); return Container( decoration: BoxDecoration( color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(16), + borderRadius: KoshikaRadius.xxl, ), - padding: const EdgeInsets.all(20), + padding: KoshikaSpacing.cardPaddingAsymmetric, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -785,12 +773,12 @@ class _CategoryTrendCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(KoshikaSpacing.sm), decoration: BoxDecoration( - color: AppColors.surfaceContainerLow, - borderRadius: BorderRadius.circular(10), + color: catColor.withValues(alpha: 0.1), + borderRadius: KoshikaRadius.lg, ), - child: Icon(icon, color: AppColors.primary, size: 20), + child: Icon(icon, color: catColor, size: 20), ), const Spacer(), Container( @@ -800,13 +788,12 @@ class _CategoryTrendCard extends StatelessWidget { ), decoration: BoxDecoration( color: badgeBgColor, - borderRadius: BorderRadius.circular(6), + borderRadius: KoshikaRadius.md, ), child: Text( badgeLabel.toUpperCase(), - style: TextStyle( + style: KoshikaTypography.metricLabel.copyWith( fontSize: 9, - fontWeight: FontWeight.w700, color: badgeColor, letterSpacing: 0.6, ), @@ -814,7 +801,7 @@ class _CategoryTrendCard extends StatelessWidget { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), Text( category, style: const TextStyle( @@ -828,12 +815,12 @@ class _CategoryTrendCard extends StatelessWidget { biomarkerNames, style: const TextStyle( fontSize: 11, - color: AppColors.onSurfaceVariant, + color: AppColors.textSecondary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), // Mini bar chart SizedBox( height: 48, @@ -852,7 +839,7 @@ class _CategoryTrendCard extends StatelessWidget { child: Container( decoration: BoxDecoration( color: isLatest - ? AppColors.primary + ? catColor : AppColors.outlineVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(3), ), @@ -863,12 +850,12 @@ class _CategoryTrendCard extends StatelessWidget { }).toList(), ), ), - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), // Biomarker rows ...results.map( (r) => InkWell( onTap: () => onTap(r), - borderRadius: BorderRadius.circular(8), + borderRadius: KoshikaRadius.md, child: Padding( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), child: Row( @@ -890,9 +877,9 @@ class _CategoryTrendCard extends StatelessWidget { color: AppColors.onSurface, ), ), - const SizedBox(width: 8), + const SizedBox(width: KoshikaSpacing.sm), _FlagDot(flag: r.flag), - const SizedBox(width: 4), + const SizedBox(width: KoshikaSpacing.xs), const Icon( Icons.chevron_right, size: 14, @@ -921,16 +908,16 @@ class _FlagDot extends StatelessWidget { Color get _color { switch (flag) { case BiomarkerFlag.normal: - return const Color(0xFF2D6A4F); + return AppColors.success; case BiomarkerFlag.borderline: - return const Color(0xFFB45309); + return AppColors.warning; case BiomarkerFlag.low: case BiomarkerFlag.high: return AppColors.error; case BiomarkerFlag.critical: return AppColors.error; case BiomarkerFlag.unknown: - return AppColors.onSurfaceVariant; + return AppColors.textMuted; } } @@ -957,30 +944,22 @@ class _InsightsCard extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: AppColors.outlineVariant.withValues(alpha: 0.3), - ), - ), - padding: const EdgeInsets.all(20), + decoration: KoshikaDecorations.insightCard, + padding: KoshikaSpacing.cardPaddingAsymmetric, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + Text( 'Clinical Insights', - style: TextStyle( - fontWeight: FontWeight.w800, + style: KoshikaTypography.sectionHeader.copyWith( fontSize: 18, - color: AppColors.onSurface, - letterSpacing: -0.3, + color: Colors.white, ), ), - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), ...insights.map( (insight) => Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.only(bottom: KoshikaSpacing.sm), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -989,7 +968,7 @@ class _InsightsCard extends StatelessWidget { height: 6, margin: const EdgeInsets.only(top: 6, right: 10), decoration: const BoxDecoration( - color: AppColors.primary, + color: AppColors.onTertiaryContainer, shape: BoxShape.circle, ), ), @@ -998,7 +977,7 @@ class _InsightsCard extends StatelessWidget { insight, style: const TextStyle( fontSize: 14, - color: AppColors.onSurfaceVariant, + color: AppColors.onTertiaryContainer, height: 1.5, ), ), @@ -1029,12 +1008,7 @@ class _SectionHeader extends StatelessWidget { children: [ Text( title, - style: const TextStyle( - fontWeight: FontWeight.w700, - fontSize: 18, - color: AppColors.onSurface, - letterSpacing: -0.2, - ), + style: KoshikaTypography.sectionHeader.copyWith(fontSize: 18), ), if (action != null) ...[const Spacer(), action!], ], diff --git a/lib/screens/onboarding_screen.dart b/lib/screens/onboarding_screen.dart index 63d4ad5..914f20f 100644 --- a/lib/screens/onboarding_screen.dart +++ b/lib/screens/onboarding_screen.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; + /// Key used in SharedPreferences to track onboarding completion. const kOnboardingCompleteKey = 'onboarding_complete'; @@ -83,9 +86,8 @@ class _OnboardingScreenState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return Scaffold( + backgroundColor: AppColors.surface, body: SafeArea( child: Column( children: [ @@ -93,11 +95,14 @@ class _OnboardingScreenState extends State { Align( alignment: Alignment.topRight, child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(KoshikaSpacing.base), child: _currentPage < _pages.length - 1 ? TextButton( onPressed: _completeOnboarding, - child: const Text('Skip'), + child: Text( + 'Skip', + style: TextStyle(color: AppColors.textSecondary), + ), ) : const SizedBox(height: 40), ), @@ -119,42 +124,66 @@ class _OnboardingScreenState extends State { // Page indicator dots Padding( - padding: const EdgeInsets.symmetric(vertical: 24), + padding: const EdgeInsets.symmetric(vertical: KoshikaSpacing.xl), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(_pages.length, (index) { final isActive = index == _currentPage; return AnimatedContainer( duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.symmetric(horizontal: 4), + margin: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.xs, + ), width: isActive ? 24 : 8, height: 8, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), + borderRadius: KoshikaRadius.sm, color: isActive - ? theme.colorScheme.primary - : theme.colorScheme.outlineVariant, + ? AppColors.primary + : AppColors.outlineVariant, ), ); }), ), ), - // Action button + // Action button — gradient pill CTA Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 40), + padding: const EdgeInsets.fromLTRB( + KoshikaSpacing.xl, + 0, + KoshikaSpacing.xl, + KoshikaSpacing.xxxl, + ), child: SizedBox( width: double.infinity, height: 52, - child: FilledButton( - onPressed: _currentPage == _pages.length - 1 - ? _completeOnboarding - : _goToNextPage, - child: Text( - _currentPage == _pages.length - 1 ? 'Get Started' : 'Next', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.primary, Color(0xFF0D9488)], + ), + borderRadius: KoshikaRadius.pill, + boxShadow: KoshikaElevation.medium, + ), + child: FilledButton( + onPressed: _currentPage == _pages.length - 1 + ? _completeOnboarding + : _goToNextPage, + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: const StadiumBorder(), + ), + child: Text( + _currentPage == _pages.length - 1 + ? 'Get Started' + : 'Next', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), ), ), ), @@ -195,7 +224,7 @@ class _OnboardingPage extends StatelessWidget { final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), + padding: const EdgeInsets.symmetric(horizontal: KoshikaSpacing.xxxl), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -204,36 +233,33 @@ class _OnboardingPage extends StatelessWidget { height: 120, decoration: BoxDecoration( shape: BoxShape.circle, - color: theme.colorScheme.primaryContainer, - ), - child: Icon( - data.icon, - size: 56, - color: theme.colorScheme.onPrimaryContainer, + color: AppColors.primaryContainer.withValues(alpha: 0.15), ), + child: Icon(data.icon, size: 56, color: AppColors.primary), ), - const SizedBox(height: 40), + const SizedBox(height: KoshikaSpacing.xxxl), Text( data.title, - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, + style: KoshikaTypography.sectionHeader.copyWith( + fontSize: 28, + color: AppColors.onSurface, ), textAlign: TextAlign.center, ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( data.subtitle, style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.primary, + color: AppColors.primary, fontWeight: FontWeight.w500, ), textAlign: TextAlign.center, ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), Text( data.description, style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), + color: AppColors.textSecondary, height: 1.6, ), textAlign: TextAlign.center, diff --git a/lib/screens/report_detail_screen.dart b/lib/screens/report_detail_screen.dart index b49c311..f09353e 100644 --- a/lib/screens/report_detail_screen.dart +++ b/lib/screens/report_detail_screen.dart @@ -6,6 +6,8 @@ import 'package:path/path.dart' as p; import '../main.dart'; import '../models/models.dart'; +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; import 'biomarker_detail_screen.dart'; import '../widgets/flag_badge.dart'; import '../services/fhir_export_service.dart'; @@ -103,27 +105,35 @@ class ReportDetailScreen extends StatelessWidget { body: results.isEmpty ? Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.error_outline, - size: 80, - color: theme.colorScheme.error.withValues(alpha: 0.5), + Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: AppColors.errorContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.error_outline, + size: 48, + color: AppColors.error, + ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), Text( 'No biomarkers extracted', - style: theme.textTheme.headlineSmall, + style: KoshikaTypography.sectionHeader.copyWith( + color: AppColors.onSurface, + ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( 'We could not parse any structured data from this report. This may be an unsupported lab format.', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.6, - ), + color: AppColors.onSurfaceVariant, ), textAlign: TextAlign.center, ), @@ -132,59 +142,63 @@ class ReportDetailScreen extends StatelessWidget { ), ) : ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(KoshikaSpacing.base), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${results.length} Biomarkers extracted', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + Container( + decoration: KoshikaDecorations.card, + padding: KoshikaSpacing.cardPaddingAsymmetric, + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${results.length} Biomarkers extracted', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.onSurface, ), - const SizedBox(height: 4), - Text( - outOfRangeCount > 0 - ? '$outOfRangeCount out of range' - : 'All within range ✓', - style: theme.textTheme.bodyMedium?.copyWith( - color: outOfRangeCount > 0 - ? theme.colorScheme.error - : Colors.green, - fontWeight: FontWeight.w500, - ), + ), + const SizedBox(height: KoshikaSpacing.xs), + Text( + outOfRangeCount > 0 + ? '$outOfRangeCount out of range' + : 'All within range', + style: KoshikaTypography.statusText.copyWith( + color: outOfRangeCount > 0 + ? AppColors.error + : AppColors.success, ), - ], - ), - ], - ), + ), + ], + ), + ], ), ), - const SizedBox(height: 16), + const SizedBox(height: KoshikaSpacing.base), ...groupedResults.entries.map((entry) { return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Card( + padding: const EdgeInsets.only(bottom: KoshikaSpacing.base), + child: Container( + decoration: KoshikaDecorations.card, + clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: const EdgeInsets.fromLTRB( + KoshikaSpacing.base, + KoshikaSpacing.base, + KoshikaSpacing.base, + KoshikaSpacing.sm, + ), child: Text( - entry.key, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, + entry.key.toUpperCase(), + style: KoshikaTypography.metricLabel.copyWith( + color: AppColors.primary, ), ), ), - const Divider(height: 1), ...entry.value.map( (r) => ListTile( onTap: () => Navigator.of(context).push( @@ -194,8 +208,19 @@ class ReportDetailScreen extends StatelessWidget { ), ), ), - title: Text(r.displayName), - subtitle: Text('Range: ${r.formattedRefRange}'), + title: Text( + r.displayName, + style: const TextStyle( + color: AppColors.onSurface, + ), + ), + subtitle: Text( + 'Range: ${r.formattedRefRange}', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -204,14 +229,19 @@ class ReportDetailScreen extends StatelessWidget { style: theme.textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), - Text(r.unit ?? ''), - const SizedBox(width: 8), + Text( + r.unit ?? '', + style: const TextStyle( + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: KoshikaSpacing.sm), FlagBadge(flag: r.flag), - const SizedBox(width: 4), + const SizedBox(width: KoshikaSpacing.xs), const Icon( Icons.chevron_right, size: 20, - color: Colors.grey, + color: AppColors.onSurfaceVariant, ), ], ), diff --git a/lib/screens/reports_screen.dart b/lib/screens/reports_screen.dart index 4547b41..301a231 100644 --- a/lib/screens/reports_screen.dart +++ b/lib/screens/reports_screen.dart @@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart'; import '../main.dart'; import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; import '../models/models.dart'; import '../services/extraction_diagnostics.dart'; import '../services/fhir_export_service.dart'; @@ -295,7 +296,7 @@ class _ReportsScreenState extends State { body: reports.isEmpty ? Center( child: Padding( - padding: const EdgeInsets.all(32), + padding: const EdgeInsets.all(KoshikaSpacing.xxl), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -314,14 +315,14 @@ class _ReportsScreenState extends State { color: AppColors.primary, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), Text( 'No reports imported', - style: theme.textTheme.headlineSmall?.copyWith( + style: KoshikaTypography.sectionHeader.copyWith( color: AppColors.onSurface, ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( 'Tap the button below to import your first lab report PDF.', style: theme.textTheme.bodyMedium?.copyWith( @@ -334,60 +335,60 @@ class _ReportsScreenState extends State { ), ) : ListView.builder( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(KoshikaSpacing.base), itemCount: reports.length, itemBuilder: (context, index) { final report = reports[index]; - return Dismissible( - key: Key('report-${report.id}'), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 24), - decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(16), + return Padding( + padding: const EdgeInsets.only(bottom: KoshikaSpacing.md), + child: Dismissible( + key: Key('report-${report.id}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: KoshikaSpacing.xl), + decoration: BoxDecoration( + color: AppColors.error, + borderRadius: KoshikaRadius.xxl, + ), + child: const Icon(Icons.delete, color: Colors.white), ), - child: const Icon(Icons.delete, color: Colors.white), - ), - confirmDismiss: (_) async { - return await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Delete Report?'), - content: Text( - 'Delete "${report.labName ?? report.originalFileName ?? "Lab Report"}" and all its biomarker results?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Cancel'), + confirmDismiss: (_) async { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Report?'), + content: Text( + 'Delete "${report.labName ?? report.originalFileName ?? "Lab Report"}" and all its biomarker results?', ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text( - 'Delete', - style: TextStyle(color: Colors.red), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), ), - ), - ], - ), - ) ?? - false; - }, - onDismissed: (_) { - objectbox.deleteReport(report.id); - try { - final file = File(report.pdfPath); - if (file.existsSync()) { - file.deleteSync(); - } - } catch (_) {} - setState(() {}); - }, - child: Card( - color: Colors.white, - child: ListTile( + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text( + 'Delete', + style: TextStyle(color: AppColors.error), + ), + ), + ], + ), + ) ?? + false; + }, + onDismissed: (_) { + objectbox.deleteReport(report.id); + try { + final file = File(report.pdfPath); + if (file.existsSync()) { + file.deleteSync(); + } + } catch (_) {} + setState(() {}); + }, + child: GestureDetector( onTap: () { Navigator.of(context) .push( @@ -398,35 +399,52 @@ class _ReportsScreenState extends State { ) .then((_) => setState(() {})); }, - leading: CircleAvatar( - backgroundColor: AppColors.primaryContainer.withValues( - alpha: 0.15, - ), - child: const Icon( - Icons.description_outlined, - color: AppColors.primary, - ), - ), - title: Text( - report.labName ?? - report.originalFileName ?? - 'Lab Report', - style: const TextStyle( - fontWeight: FontWeight.w600, - color: AppColors.onSurface, - ), - ), - subtitle: Text( - '${report.reportDate.day}/${report.reportDate.month}/${report.reportDate.year}' - ' • ${report.extractedCount} biomarkers', - style: const TextStyle( - color: AppColors.onSurfaceVariant, + child: Container( + decoration: KoshikaDecorations.card, + padding: KoshikaSpacing.cardPaddingAsymmetric, + child: Row( + children: [ + CircleAvatar( + backgroundColor: AppColors.primaryContainer + .withValues(alpha: 0.15), + child: const Icon( + Icons.description_outlined, + color: AppColors.primary, + ), + ), + const SizedBox(width: KoshikaSpacing.base), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + report.labName ?? + report.originalFileName ?? + 'Lab Report', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.onSurface, + ), + ), + const SizedBox(height: KoshikaSpacing.xs), + Text( + '${report.reportDate.day}/${report.reportDate.month}/${report.reportDate.year}' + ' • ${report.extractedCount} biomarkers', + style: const TextStyle( + fontSize: 13, + color: AppColors.onSurfaceVariant, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: AppColors.onSurfaceVariant, + ), + ], ), ), - trailing: const Icon( - Icons.chevron_right, - color: AppColors.onSurfaceVariant, - ), ), ), ); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c6c416d..0e9abd9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,9 +9,11 @@ import 'package:share_plus/share_plus.dart'; import '../main.dart'; import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; import '../models/models.dart'; import '../services/embedding_service.dart'; import '../services/fhir_export_service.dart'; +import '../widgets/icon_container.dart'; /// Settings screen with AI model management, data controls, and about section. class SettingsScreen extends StatefulWidget { @@ -142,9 +144,11 @@ class _SettingsScreenState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Failed to delete data: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to delete data. Please try again.'), + ), + ); } } } @@ -204,6 +208,7 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar(title: const Text('Settings')), body: ListView( + padding: const EdgeInsets.symmetric(horizontal: KoshikaSpacing.base), children: [ // ── AI Model Section ── _SectionHeader(title: 'AI Models', icon: Icons.smart_toy_outlined), @@ -220,87 +225,96 @@ class _SettingsScreenState extends State { onUnload: () => embeddingService.unloadModel(), ), - const Divider(height: 1), + const SizedBox(height: KoshikaSpacing.sm), // ── Data Section ── _SectionHeader( title: 'Data Management', icon: Icons.storage_outlined, ), - ListTile( - leading: const Icon(Icons.description_outlined), - title: const Text('Reports imported'), + _SettingsRow( + icon: Icons.description_outlined, + iconColor: AppColors.primary, + title: 'Reports imported', trailing: Text( '${reports.length}', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, + style: KoshikaTypography.statusText.copyWith( + color: AppColors.primary, ), ), ), - ListTile( - leading: const Icon(Icons.biotech_outlined), - title: const Text('Biomarkers tracked'), + _SettingsRow( + icon: Icons.biotech_outlined, + iconColor: AppColors.primary, + title: 'Biomarkers tracked', trailing: Text( '$biomarkerCount', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, + style: KoshikaTypography.statusText.copyWith( + color: AppColors.primary, ), ), ), - ListTile( - leading: Icon(Icons.ios_share, color: theme.colorScheme.primary), - title: const Text('Export All Data (FHIR R4)'), - subtitle: const Text('Share as interoperable health bundle'), + _SettingsRow( + icon: Icons.ios_share, + iconColor: AppColors.primary, + title: 'Export All Data (FHIR R4)', + subtitle: 'Share as interoperable health bundle', onTap: _exportAllFhir, ), - ListTile( - leading: Icon(Icons.delete_forever, color: theme.colorScheme.error), - title: Text( - 'Delete All Data', - style: TextStyle(color: theme.colorScheme.error), - ), - subtitle: const Text('Remove all reports and biomarkers'), + _SettingsRow( + icon: Icons.delete_forever, + iconColor: AppColors.error, + title: 'Delete All Data', + titleColor: AppColors.error, + subtitle: 'Remove all reports and biomarkers', onTap: _deleteAllData, ), - const Divider(height: 1), + const SizedBox(height: KoshikaSpacing.sm), // ── About Section ── _SectionHeader(title: 'About', icon: Icons.info_outline), - ListTile( - leading: const Icon(Icons.local_hospital), - title: const Text('Koshika — कोशिका'), - subtitle: const Text( - 'Offline-first health data tracker with on-device AI', - ), + _SettingsRow( + icon: Icons.local_hospital, + iconColor: AppColors.primary, + title: 'Koshika', + subtitle: 'Offline-first health data tracker with on-device AI', ), - ListTile( - leading: const Icon(Icons.tag), - title: const Text('Version'), + _SettingsRow( + icon: Icons.tag, + iconColor: AppColors.onSurfaceVariant, + title: 'Version', trailing: Text( _appVersion.isEmpty ? '...' : _appVersion, - style: theme.textTheme.bodyMedium, + style: theme.textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), ), ), - ListTile( - leading: const Icon(Icons.code), - title: const Text('Source Code'), - subtitle: const Text('github.com/priyavratuniyal/koshika'), + _SettingsRow( + icon: Icons.code, + iconColor: AppColors.onSurfaceVariant, + title: 'Source Code', + subtitle: 'github.com/priyavratuniyal/koshika', ), - ListTile( - leading: const Icon(Icons.balance), - title: const Text('License'), - trailing: Text('Apache 2.0', style: theme.textTheme.bodyMedium), + _SettingsRow( + icon: Icons.balance, + iconColor: AppColors.onSurfaceVariant, + title: 'License', + trailing: Text( + 'Apache 2.0', + style: theme.textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + ), ), Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(KoshikaSpacing.xl), child: Text( - 'Built for FOSS Hack 2026 🇮🇳', + 'Built for FOSS Hack 2026', textAlign: TextAlign.center, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + color: AppColors.textMuted, ), ), ), @@ -322,19 +336,21 @@ class _SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + padding: const EdgeInsets.fromLTRB( + 0, + KoshikaSpacing.xl, + 0, + KoshikaSpacing.sm, + ), child: Row( children: [ Icon(icon, size: 18, color: AppColors.primary), - const SizedBox(width: 8), + const SizedBox(width: KoshikaSpacing.sm), Text( title.toUpperCase(), - style: theme.textTheme.labelMedium?.copyWith( + style: KoshikaTypography.metricLabel.copyWith( color: AppColors.primary, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, ), ), ], @@ -343,6 +359,80 @@ class _SectionHeader extends StatelessWidget { } } +// ═══════════════════════════════════════════════════════════════════════ +// Settings Row (replaces ListTile) +// ═══════════════════════════════════════════════════════════════════════ + +class _SettingsRow extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String title; + final Color? titleColor; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + + const _SettingsRow({ + required this.icon, + required this.iconColor, + required this.title, + this.titleColor, + this.subtitle, + this.trailing, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: KoshikaSpacing.xs), + child: Material( + color: AppColors.surfaceContainerLowest, + borderRadius: KoshikaRadius.lg, + child: InkWell( + onTap: onTap, + borderRadius: KoshikaRadius.lg, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.md, + ), + child: Row( + children: [ + IconContainer(icon: icon, color: iconColor), + const SizedBox(width: KoshikaSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w500, + color: titleColor ?? AppColors.onSurface, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + if (trailing != null) trailing!, + ], + ), + ), + ), + ), + ); + } +} + // ═══════════════════════════════════════════════════════════════════════ // Model Status Tile // ═══════════════════════════════════════════════════════════════════════ @@ -363,159 +453,135 @@ class _ModelStatusTile extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final statusColor = _statusColor; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(_statusIcon, color: _statusColor(theme), size: 20), - const SizedBox(width: 8), - Expanded( - child: Text( - modelInfo.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + padding: const EdgeInsets.only(bottom: KoshikaSpacing.sm), + child: Container( + decoration: KoshikaDecorations.card, + padding: KoshikaSpacing.cardPaddingAsymmetric, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_statusIcon, color: statusColor, size: 20), + const SizedBox(width: KoshikaSpacing.sm), + Expanded( + child: Text( + modelInfo.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.onSurface, ), ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: _statusColor(theme).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _statusLabel, - style: theme.textTheme.labelSmall?.copyWith( - color: _statusColor(theme), - fontWeight: FontWeight.w600, - ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.sm, + vertical: KoshikaSpacing.xs, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: KoshikaRadius.md, + ), + child: Text( + _statusLabel, + style: KoshikaTypography.statusText.copyWith( + color: statusColor, ), ), - ], + ), + ], + ), + const SizedBox(height: KoshikaSpacing.sm), + Text( + 'Size: ${modelInfo.formattedSize}', + style: theme.textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + if (modelInfo.status == ModelStatus.downloading) ...[ + const SizedBox(height: KoshikaSpacing.md), + LinearProgressIndicator( + value: modelInfo.downloadProgress / 100, + borderRadius: KoshikaRadius.sm, ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.xs), Text( - 'Size: ${modelInfo.formattedSize}', + '${modelInfo.downloadProgress}%', + style: theme.textTheme.labelSmall, + ), + ], + if (modelInfo.errorMessage != null) ...[ + const SizedBox(height: KoshikaSpacing.sm), + Text( + modelInfo.errorMessage!, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + color: AppColors.error, ), ), - if (modelInfo.status == ModelStatus.downloading) ...[ - const SizedBox(height: 12), - LinearProgressIndicator( - value: modelInfo.downloadProgress / 100, - borderRadius: BorderRadius.circular(4), - ), - const SizedBox(height: 4), - Text( - '${modelInfo.downloadProgress}%', - style: theme.textTheme.labelSmall, - ), - ], - if (modelInfo.errorMessage != null) ...[ - const SizedBox(height: 8), - Text( - modelInfo.errorMessage!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, + ], + const SizedBox(height: KoshikaSpacing.md), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (modelInfo.canDownload) + FilledButton.icon( + onPressed: onDownload, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + if (modelInfo.canLoad) + FilledButton.tonal( + onPressed: onLoad, + child: const Text('Load Model'), + ), + if (modelInfo.isUsable) + OutlinedButton( + onPressed: onUnload, + child: const Text('Unload'), + ), + if (modelInfo.status == ModelStatus.downloading || + modelInfo.status == ModelStatus.loading) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), ), - ), ], - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (modelInfo.canDownload) - FilledButton.icon( - onPressed: onDownload, - icon: const Icon(Icons.download, size: 18), - label: const Text('Download'), - ), - if (modelInfo.canLoad) - FilledButton.tonal( - onPressed: onLoad, - child: const Text('Load Model'), - ), - if (modelInfo.isUsable) - OutlinedButton( - onPressed: onUnload, - child: const Text('Unload'), - ), - if (modelInfo.status == ModelStatus.downloading || - modelInfo.status == ModelStatus.loading) - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - ], - ), + ), + ], ), ), ); } - IconData get _statusIcon { - switch (modelInfo.status) { - case ModelStatus.notDownloaded: - return Icons.cloud_download_outlined; - case ModelStatus.downloading: - return Icons.downloading; - case ModelStatus.ready: - return Icons.check_circle_outline; - case ModelStatus.loading: - return Icons.hourglass_top; - case ModelStatus.loaded: - return Icons.check_circle; - case ModelStatus.error: - return Icons.error_outline; - } - } - - String get _statusLabel { - switch (modelInfo.status) { - case ModelStatus.notDownloaded: - return 'Not Downloaded'; - case ModelStatus.downloading: - return 'Downloading...'; - case ModelStatus.ready: - return 'Ready'; - case ModelStatus.loading: - return 'Loading...'; - case ModelStatus.loaded: - return 'Active'; - case ModelStatus.error: - return 'Error'; - } - } - - Color _statusColor(ThemeData theme) { - switch (modelInfo.status) { - case ModelStatus.notDownloaded: - return AppColors.onSurfaceVariant; - case ModelStatus.downloading: - case ModelStatus.loading: - return AppColors.statusBusy; - case ModelStatus.ready: - return AppColors.statusReady; - case ModelStatus.loaded: - return AppColors.statusActive; - case ModelStatus.error: - return AppColors.error; - } - } + IconData get _statusIcon => switch (modelInfo.status) { + ModelStatus.notDownloaded => Icons.cloud_download_outlined, + ModelStatus.downloading => Icons.downloading, + ModelStatus.ready => Icons.check_circle_outline, + ModelStatus.loading => Icons.hourglass_top, + ModelStatus.loaded => Icons.check_circle, + ModelStatus.error => Icons.error_outline, + }; + + String get _statusLabel => switch (modelInfo.status) { + ModelStatus.notDownloaded => 'Not Downloaded', + ModelStatus.downloading => 'Downloading...', + ModelStatus.ready => 'Ready', + ModelStatus.loading => 'Loading...', + ModelStatus.loaded => 'Active', + ModelStatus.error => 'Error', + }; + + Color get _statusColor => switch (modelInfo.status) { + ModelStatus.notDownloaded => AppColors.onSurfaceVariant, + ModelStatus.downloading || ModelStatus.loading => AppColors.statusBusy, + ModelStatus.ready => AppColors.statusReady, + ModelStatus.loaded => AppColors.statusActive, + ModelStatus.error => AppColors.error, + }; } // ═══════════════════════════════════════════════════════════════════════ @@ -538,172 +604,146 @@ class _EmbeddingModelTile extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final statusColor = _statusColor; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(_statusIcon, color: _statusColor(theme), size: 20), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - modelInfo.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + padding: const EdgeInsets.only(bottom: KoshikaSpacing.sm), + child: Container( + decoration: KoshikaDecorations.card, + padding: KoshikaSpacing.cardPaddingAsymmetric, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(_statusIcon, color: statusColor, size: 20), + const SizedBox(width: KoshikaSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + modelInfo.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.onSurface, ), - Text( - 'Enables semantic search for AI chat', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.6, - ), - ), + ), + Text( + 'Enables semantic search for AI chat', + style: theme.textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: _statusColor(theme).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _statusLabel, - style: theme.textTheme.labelSmall?.copyWith( - color: _statusColor(theme), - fontWeight: FontWeight.w600, ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.sm, + vertical: KoshikaSpacing.xs, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: KoshikaRadius.md, + ), + child: Text( + _statusLabel, + style: KoshikaTypography.statusText.copyWith( + color: statusColor, ), ), - ], + ), + ], + ), + const SizedBox(height: KoshikaSpacing.sm), + Text( + 'Size: ${modelInfo.formattedSize}', + style: theme.textTheme.bodySmall?.copyWith( + color: AppColors.textSecondary, + ), + ), + if (modelInfo.status == ModelStatus.downloading) ...[ + const SizedBox(height: KoshikaSpacing.md), + LinearProgressIndicator( + value: modelInfo.downloadProgress / 100, + borderRadius: KoshikaRadius.sm, ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.xs), Text( - 'Size: ${modelInfo.formattedSize}', + '${modelInfo.downloadProgress}%', + style: theme.textTheme.labelSmall, + ), + ], + if (modelInfo.errorMessage != null) ...[ + const SizedBox(height: KoshikaSpacing.sm), + Text( + modelInfo.errorMessage!, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + color: AppColors.error, ), ), - if (modelInfo.status == ModelStatus.downloading) ...[ - const SizedBox(height: 12), - LinearProgressIndicator( - value: modelInfo.downloadProgress / 100, - borderRadius: BorderRadius.circular(4), - ), - const SizedBox(height: 4), - Text( - '${modelInfo.downloadProgress}%', - style: theme.textTheme.labelSmall, - ), - ], - if (modelInfo.errorMessage != null) ...[ - const SizedBox(height: 8), - Text( - modelInfo.errorMessage!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, + ], + const SizedBox(height: KoshikaSpacing.md), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (modelInfo.canDownload) + FilledButton.icon( + onPressed: onDownload, + icon: const Icon(Icons.download, size: 18), + label: const Text('Download'), + ), + if (modelInfo.canLoad) + FilledButton.tonal( + onPressed: onLoad, + child: const Text('Load'), + ), + if (modelInfo.isUsable) + OutlinedButton( + onPressed: onUnload, + child: const Text('Unload'), + ), + if (modelInfo.status == ModelStatus.downloading || + modelInfo.status == ModelStatus.loading) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), ), - ), ], - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (modelInfo.canDownload) - FilledButton.icon( - onPressed: onDownload, - icon: const Icon(Icons.download, size: 18), - label: const Text('Download'), - ), - if (modelInfo.canLoad) - FilledButton.tonal( - onPressed: onLoad, - child: const Text('Load'), - ), - if (modelInfo.isUsable) - OutlinedButton( - onPressed: onUnload, - child: const Text('Unload'), - ), - if (modelInfo.status == ModelStatus.downloading || - modelInfo.status == ModelStatus.loading) - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - ], - ), + ), + ], ), ), ); } - IconData get _statusIcon { - switch (modelInfo.status) { - case ModelStatus.notDownloaded: - return Icons.cloud_download_outlined; - case ModelStatus.downloading: - return Icons.downloading; - case ModelStatus.ready: - return Icons.check_circle_outline; - case ModelStatus.loading: - return Icons.hourglass_top; - case ModelStatus.loaded: - return Icons.check_circle; - case ModelStatus.error: - return Icons.error_outline; - } - } - - String get _statusLabel { - switch (modelInfo.status) { - case ModelStatus.notDownloaded: - return 'Not Downloaded'; - case ModelStatus.downloading: - return 'Downloading...'; - case ModelStatus.ready: - return 'Ready'; - case ModelStatus.loading: - return 'Loading...'; - case ModelStatus.loaded: - return 'Active'; - case ModelStatus.error: - return 'Error'; - } - } - - Color _statusColor(ThemeData theme) { - switch (modelInfo.status) { - case ModelStatus.notDownloaded: - return AppColors.onSurfaceVariant; - case ModelStatus.downloading: - case ModelStatus.loading: - return AppColors.statusBusy; - case ModelStatus.ready: - return AppColors.statusReady; - case ModelStatus.loaded: - return AppColors.statusActive; - case ModelStatus.error: - return AppColors.error; - } - } + IconData get _statusIcon => switch (modelInfo.status) { + ModelStatus.notDownloaded => Icons.cloud_download_outlined, + ModelStatus.downloading => Icons.downloading, + ModelStatus.ready => Icons.check_circle_outline, + ModelStatus.loading => Icons.hourglass_top, + ModelStatus.loaded => Icons.check_circle, + ModelStatus.error => Icons.error_outline, + }; + + String get _statusLabel => switch (modelInfo.status) { + ModelStatus.notDownloaded => 'Not Downloaded', + ModelStatus.downloading => 'Downloading...', + ModelStatus.ready => 'Ready', + ModelStatus.loading => 'Loading...', + ModelStatus.loaded => 'Active', + ModelStatus.error => 'Error', + }; + + Color get _statusColor => switch (modelInfo.status) { + ModelStatus.notDownloaded => AppColors.onSurfaceVariant, + ModelStatus.downloading || ModelStatus.loading => AppColors.statusBusy, + ModelStatus.ready => AppColors.statusReady, + ModelStatus.loaded => AppColors.statusActive, + ModelStatus.error => AppColors.error, + }; } // ═══════════════════════════════════════════════════════════════════════ diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index fec8321..47b136a 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../models/model_info.dart'; import '../services/objectbox_store.dart'; import '../services/biomarker_dictionary.dart'; import '../services/embedding_service.dart'; @@ -80,6 +81,19 @@ class _SplashScreenState extends State ); await app_main.vectorStoreService.initialize(); + // Kick off model loading in the background if already downloaded. + // These are unawaited — navigation proceeds immediately, models finish + // loading while the user is on the home screen. + if (app_main.gemmaService.currentModelInfo.status == ModelStatus.ready) { + // ignore: unawaited_futures + app_main.gemmaService.loadModel(); + } + if (app_main.embeddingService.currentModelInfo.status == + ModelStatus.ready) { + // ignore: unawaited_futures + app_main.embeddingService.loadModel(); + } + // Ensure animation has played for at least 1.5 seconds await Future.delayed(const Duration(milliseconds: 1500)); @@ -96,9 +110,11 @@ class _SplashScreenState extends State Navigator.of(context).pushReplacementNamed('/onboarding'); } } catch (e) { + debugPrint('SplashScreen initialization error: $e'); if (mounted) { setState(() { - _errorMessage = 'Failed to initialize: $e'; + _errorMessage = + 'Something went wrong during startup. Please restart the app.'; }); } } diff --git a/lib/services/embedding_service.dart b/lib/services/embedding_service.dart index 9bf6b22..1675dde 100644 --- a/lib/services/embedding_service.dart +++ b/lib/services/embedding_service.dart @@ -69,6 +69,18 @@ class EmbeddingService { _modelUrlToFilename(_modelUrl), ); if (isInstalled) { + // Re-register with the SDK so getActiveEmbedder() works without + // needing a redundant "download" tap. The SDK detects the files + // already exist and skips the actual download. + try { + await FlutterGemma.installEmbedder() + .modelFromNetwork(_modelUrl) + .tokenizerFromNetwork(_tokenizerUrl, iosPath: _iosTokenizerUrl) + .install(); + } catch (e) { + debugPrint('EmbeddingService: SDK re-registration skipped ($e)'); + } + _updateStatus( _modelInfo.copyWith(status: ModelStatus.ready, downloadProgress: 100), ); @@ -204,12 +216,12 @@ class EmbeddingService { ); _updateStatus(_modelInfo.copyWith(status: ModelStatus.loaded)); } catch (cpuError) { + debugPrint('EmbeddingService.loadModel CPU fallback failed: $cpuError'); _embedder = null; _updateStatus( _modelInfo.copyWith( status: ModelStatus.error, - errorMessage: - 'Failed to load embedding model: ${cpuError.toString().length > 100 ? '${cpuError.toString().substring(0, 100)}...' : cpuError}', + errorMessage: ErrorClassifier.load(cpuError), ), ); } diff --git a/lib/services/gemma_service.dart b/lib/services/gemma_service.dart index 2e7f474..a39f77e 100644 --- a/lib/services/gemma_service.dart +++ b/lib/services/gemma_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter_gemma/flutter_gemma.dart'; import '../constants/ai_prompts.dart'; @@ -62,6 +63,7 @@ class GemmaService { // ═══════════════════════════════════════════════════════════════════ /// Check if the model is already installed on disk. + /// If found, re-registers it with the SDK so [loadModel] works immediately. /// Called once during app startup. Must never throw. Future initialize() async { try { @@ -74,12 +76,24 @@ class GemmaService { ); if (isInstalled) { + // Re-register with the SDK so getActiveModel() works without + // needing a redundant "download" tap. The SDK detects the file + // already exists and skips the actual download. + try { + await FlutterGemma.installModel( + modelType: ModelType.gemmaIt, + ).fromNetwork(_modelUrl).install(); + } catch (e) { + debugPrint('GemmaService: SDK re-registration skipped ($e)'); + } + _updateStatus( _modelInfo.copyWith(status: ModelStatus.ready, downloadProgress: 100), ); } // else: stays at notDownloaded (default) } catch (e) { + debugPrint('GemmaService.initialize: $e'); // Non-fatal — default to notDownloaded so the user can still try _updateStatus( _modelInfo.copyWith( diff --git a/lib/services/haptic_feedback_service.dart b/lib/services/haptic_feedback_service.dart new file mode 100644 index 0000000..088c63f --- /dev/null +++ b/lib/services/haptic_feedback_service.dart @@ -0,0 +1,13 @@ +import 'package:flutter/services.dart'; + +/// Centralized haptic feedback wrapper. +/// +/// Every user tap should produce haptic feedback: +/// - [light]: Card taps, nav taps, toggle switches +/// - [selection]: List item selection, chip toggles +/// - [heavy]: Destructive actions (delete confirmation), import complete +abstract final class Haptics { + static void light() => HapticFeedback.lightImpact(); + static void selection() => HapticFeedback.selectionClick(); + static void heavy() => HapticFeedback.heavyImpact(); +} diff --git a/lib/theme/app_colors.dart b/lib/theme/app_colors.dart index 0d693d9..b47e2c1 100644 --- a/lib/theme/app_colors.dart +++ b/lib/theme/app_colors.dart @@ -23,14 +23,80 @@ abstract final class AppColors { static const onSurfaceVariant = Color(0xFF3F4945); static const outlineVariant = Color(0xFFBFC9C4); + static const surfaceContainerHighest = Color(0xFFDEE3DF); + // ── Error ─────────────────────────────────────────────────────────── static const error = Color(0xFFBA1A1A); static const errorContainer = Color(0xFFFFDAD6); + static const onErrorContainer = Color(0xFF93000A); // ── Accent ────────────────────────────────────────────────────────── static const secondary = Color(0xFF006399); static const tertiary = Color(0xFF003422); static const tertiaryContainer = Color(0xFF074D34); + static const onTertiaryContainer = Color(0xFF7FBD9D); + + // ── Semantic status ──────────────────────────────────────────────── + static const success = Color(0xFF2E7D32); + static const warning = Color(0xFFF9A825); + static const info = Color(0xFF0277BD); + + // ── Text hierarchy aliases ───────────────────────────────────────── + static const textPrimary = Color(0xFF191C1E); // same as onSurface + static const textSecondary = Color(0xFF3F4945); // same as onSurfaceVariant + static const textMuted = Color(0xFF9E9E9E); + + // ── Health category colors ───────────────────────────────────────── + static const categoryCbc = Color(0xFF1565C0); + static const categoryThyroid = Color(0xFF7B1FA2); + static const categoryLipid = Color(0xFFE65100); + static const categoryLiver = Color(0xFF2E7D32); + static const categoryKidney = Color(0xFF0097A7); + static const categoryVitamins = Color(0xFF558B2F); + static const categoryIron = Color(0xFFBF360C); + static const categoryElectrolytes = Color(0xFFFF8F00); + static const categoryDiabetes = Color(0xFF4527A0); + static const categoryInflammation = Color(0xFFC62828); + + /// Returns the category color for a given category name, with fallback. + static Color categoryColor(String category) { + final key = category.toLowerCase().trim(); + if (key.contains('blood') || + key.contains('cbc') || + key.contains('hematology')) { + return categoryCbc; + } + if (key.contains('thyroid') || key.contains('endocrine')) { + return categoryThyroid; + } + if (key.contains('lipid') || key.contains('cholesterol')) { + return categoryLipid; + } + if (key.contains('liver') || key.contains('hepatic')) { + return categoryLiver; + } + if (key.contains('kidney') || key.contains('renal')) { + return categoryKidney; + } + if (key.contains('vitamin') || key.contains('nutrient')) { + return categoryVitamins; + } + if (key.contains('iron') || key.contains('ferritin')) { + return categoryIron; + } + if (key.contains('electrolyte') || key.contains('mineral')) { + return categoryElectrolytes; + } + if (key.contains('diabetes') || + key.contains('glucose') || + key.contains('hba1c')) { + return categoryDiabetes; + } + if (key.contains('inflam') || key.contains('crp') || key.contains('esr')) { + return categoryInflammation; + } + return secondary; // fallback + } // ── Model-status palette ───────────────────────────────────────────── /// Model is actively loaded in memory. diff --git a/lib/theme/koshika_design_system.dart b/lib/theme/koshika_design_system.dart new file mode 100644 index 0000000..def6131 --- /dev/null +++ b/lib/theme/koshika_design_system.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'app_colors.dart'; + +// ═══════════════════════════════════════════════════════════════════════ +// Typography +// ═══════════════════════════════════════════════════════════════════════ + +abstract final class KoshikaTypography { + // ── Material text theme (Manrope headlines + Inter body) ─────────── + + static TextTheme get textTheme { + final manrope = GoogleFonts.manropeTextTheme(); + final inter = GoogleFonts.interTextTheme(); + + return TextTheme( + displayLarge: manrope.displayLarge!.copyWith( + fontSize: 57, + fontWeight: FontWeight.w400, + letterSpacing: -0.25, + height: 1.12, + ), + displayMedium: manrope.displayMedium!.copyWith( + fontSize: 45, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.16, + ), + displaySmall: manrope.displaySmall!.copyWith( + fontSize: 36, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.22, + ), + headlineLarge: manrope.headlineLarge!.copyWith( + fontSize: 32, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.25, + ), + headlineMedium: manrope.headlineMedium!.copyWith( + fontSize: 28, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.29, + ), + headlineSmall: manrope.headlineSmall!.copyWith( + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: 0, + height: 1.33, + ), + titleLarge: manrope.titleLarge!.copyWith( + fontSize: 22, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1.27, + ), + titleMedium: inter.titleMedium!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + letterSpacing: 0.15, + height: 1.5, + ), + titleSmall: inter.titleSmall!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + height: 1.43, + ), + bodyLarge: inter.bodyLarge!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0.5, + height: 1.5, + ), + bodyMedium: inter.bodyMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.25, + height: 1.43, + ), + bodySmall: inter.bodySmall!.copyWith( + fontSize: 12, + fontWeight: FontWeight.w400, + letterSpacing: 0.4, + height: 1.33, + ), + labelLarge: inter.labelLarge!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + letterSpacing: 0.1, + height: 1.43, + ), + labelMedium: inter.labelMedium!.copyWith( + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + height: 1.33, + ), + labelSmall: inter.labelSmall!.copyWith( + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + height: 1.45, + ), + ); + } + + // ── Custom health-specific styles ────────────────────────────────── + + /// Dashboard hero biomarker values (48px/700 Manrope). + static TextStyle get heroMetric => GoogleFonts.manrope( + fontSize: 48, + fontWeight: FontWeight.w700, + height: 1.1, + color: AppColors.textPrimary, + ); + + /// Units next to hero values — e.g. "mg/dL" (14px/500 Inter). + static TextStyle get metricUnit => GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.43, + color: AppColors.textMuted, + ); + + /// ALL-CAPS metric labels (12px/700 Inter). + static TextStyle get metricLabel => GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + height: 1.33, + color: AppColors.textSecondary, + ); + + /// Section headers with editorial weight (24px/600 Manrope). + static TextStyle get sectionHeader => GoogleFonts.manrope( + fontSize: 24, + fontWeight: FontWeight.w600, + height: 1.33, + color: AppColors.textPrimary, + ); + + /// Subtitles within cards (16px/500 Inter). + static TextStyle get cardSubtitle => GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.5, + color: AppColors.textSecondary, + ); + + /// Status badge text (12px/600 Inter). + static TextStyle get statusText => GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + height: 1.33, + ); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Spacing (8dp base grid) +// ═══════════════════════════════════════════════════════════════════════ + +abstract final class KoshikaSpacing { + static const double xs = 4; + static const double sm = 8; + static const double md = 12; + static const double base = 16; + static const double lg = 20; + static const double xl = 24; + static const double xxl = 32; + static const double xxxl = 40; + + // Screen-level constants + static const double screenHorizontal = 16; + static const double screenVertical = 8; + static const double contentPadding = 20; + static const double sectionGap = 24; + static const double cardPadding = 20; + + // Asymmetric editorial card padding (more top than bottom) + static const EdgeInsets cardPaddingAsymmetric = EdgeInsets.fromLTRB( + 20, + 24, + 20, + 16, + ); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Border Radius +// ═══════════════════════════════════════════════════════════════════════ + +abstract final class KoshikaRadius { + static const double smValue = 4; + static const double mdValue = 8; + static const double lgValue = 12; + static const double xlValue = 16; + static const double xxlValue = 24; + static const double pillValue = 9999; + + static final BorderRadius sm = BorderRadius.circular(smValue); + static final BorderRadius md = BorderRadius.circular(mdValue); + static final BorderRadius lg = BorderRadius.circular(lgValue); + static final BorderRadius xl = BorderRadius.circular(xlValue); + static final BorderRadius xxl = BorderRadius.circular(xxlValue); + static final BorderRadius pill = BorderRadius.circular(pillValue); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Elevation (BoxShadow — NOT Material elevation) +// ═══════════════════════════════════════════════════════════════════════ + +abstract final class KoshikaElevation { + static const _shadowColor = Color(0xFF191C1E); + + /// Default cards (optional — prefer tonal lift instead). + static final List subtle = [ + BoxShadow( + color: _shadowColor.withValues(alpha: 0.05), + blurRadius: 1, + offset: const Offset(0, 1), + ), + ]; + + /// Slightly elevated interactive cards. + static final List medium = [ + BoxShadow( + color: _shadowColor.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ]; + + /// Floating elements, FABs, modals. + static final List elevated = [ + BoxShadow( + color: _shadowColor.withValues(alpha: 0.06), + blurRadius: 24, + offset: const Offset(0, 12), + ), + ]; +} + +// ═══════════════════════════════════════════════════════════════════════ +// Pre-built Decorations +// ═══════════════════════════════════════════════════════════════════════ + +abstract final class KoshikaDecorations { + /// Standard card: white, 24px radius, no border. + static BoxDecoration get card => BoxDecoration( + color: AppColors.surfaceContainerLowest, + borderRadius: KoshikaRadius.xxl, + ); + + /// Standard card on a non-tinted background (with subtle shadow). + static BoxDecoration get cardElevated => BoxDecoration( + color: AppColors.surfaceContainerLowest, + borderRadius: KoshikaRadius.xxl, + boxShadow: KoshikaElevation.subtle, + ); + + /// Hero card: primary gradient, 24px radius. + static BoxDecoration get heroCard => BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.primary, AppColors.primaryContainer], + ), + borderRadius: KoshikaRadius.xxl, + boxShadow: KoshikaElevation.medium, + ); + + /// Insight card: tertiaryContainer background. + static BoxDecoration get insightCard => BoxDecoration( + color: AppColors.tertiaryContainer, + borderRadius: KoshikaRadius.xxl, + ); + + /// Attention card: errorContainer background. + static BoxDecoration get attentionCard => BoxDecoration( + color: AppColors.errorContainer, + borderRadius: KoshikaRadius.xxl, + ); +} + +// ═══════════════════════════════════════════════════════════════════════ +// Pre-built Button Styles +// ═══════════════════════════════════════════════════════════════════════ + +abstract final class KoshikaButtonStyles { + /// Primary pill button — deep teal, white text. + static ButtonStyle get pill => FilledButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ); + + /// Outlined pill button — ghost border, primary text. + static ButtonStyle get outlinedPill => OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: const StadiumBorder(), + side: BorderSide(color: AppColors.primary.withValues(alpha: 0.3)), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ); + + /// Outlined pill on dark background — white text/border. + static ButtonStyle get outlinedPillLight => OutlinedButton.styleFrom( + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: const StadiumBorder(), + side: BorderSide(color: Colors.white.withValues(alpha: 0.3)), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ); +} diff --git a/lib/widgets/biomarker_trend_chart.dart b/lib/widgets/biomarker_trend_chart.dart index f76202a..5a39d7f 100644 --- a/lib/widgets/biomarker_trend_chart.dart +++ b/lib/widgets/biomarker_trend_chart.dart @@ -2,7 +2,10 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; import 'package:intl/intl.dart'; + import '../models/models.dart'; +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; class BiomarkerTrendChart extends StatelessWidget { final List history; @@ -18,12 +21,11 @@ class BiomarkerTrendChart extends StatelessWidget { ..sort((a, b) => a.testDate.compareTo(b.testDate)); if (numericResults.isEmpty) { - return Card( - child: Container( - height: 250, - alignment: Alignment.center, - child: const Text('No numeric data to chart.'), - ), + return Container( + height: 250, + decoration: KoshikaDecorations.card, + alignment: Alignment.center, + child: const Text('No numeric data to chart.'), ); } @@ -61,7 +63,6 @@ class BiomarkerTrendChart extends StatelessWidget { double maxX = spots.last.x; if (minX == maxX) { - // Buffer by +/- 1 day final oneDayMs = const Duration(days: 1).inMilliseconds.toDouble(); minX -= oneDayMs; maxX += oneDayMs; @@ -78,29 +79,27 @@ class BiomarkerTrendChart extends StatelessWidget { LineChartBarData? highLine; LineChartBarData? lowLine; - // We can use ExtraLinesData for the shaded region, it's simpler final extraLines = []; if (refHigh != null || refLow != null) { - // Shade from refLow to refHigh final shadeTop = refHigh ?? maxY; final shadeBottom = refLow ?? 0.0; extraLines.add( - HorizontalLine(y: shadeTop, color: Colors.green.withValues(alpha: 0.3)), + HorizontalLine( + y: shadeTop, + color: AppColors.success.withValues(alpha: 0.3), + ), ); if (refLow != null) { extraLines.add( HorizontalLine( y: shadeBottom, - color: Colors.green.withValues(alpha: 0.3), + color: AppColors.success.withValues(alpha: 0.3), ), ); } - // fl_chart doesn't support shaded regions between ExtraLinesData lines directly, - // so we use BetweenBarsData with hidden lines to create the reference band. - highLine = LineChartBarData( spots: [FlSpot(minX, shadeTop), FlSpot(maxX, shadeTop)], show: false, @@ -113,36 +112,34 @@ class BiomarkerTrendChart extends StatelessWidget { barDataList.add(lowLine); } - // Add main data line — store its index for tooltip filtering + // Add main data line final mainDataLineIndex = barDataList.length; final mainDataLine = LineChartBarData( spots: spots, isCurved: false, - color: theme.colorScheme.primary, + color: AppColors.secondary, barWidth: 3, isStrokeCapRound: true, dotData: FlDotData( show: true, getDotPainter: (spot, percent, barData, index) { final result = numericResults[index]; - Color dotColor = Colors.green; - if (result.flag == BiomarkerFlag.high || - result.flag == BiomarkerFlag.low || - result.flag == BiomarkerFlag.critical || - result.flag == BiomarkerFlag.borderline) { - dotColor = Colors.red; - } + final dotColor = + (result.flag == BiomarkerFlag.normal || + result.flag == BiomarkerFlag.unknown) + ? AppColors.success + : AppColors.error; return FlDotCirclePainter( radius: 5, color: dotColor, strokeWidth: 2, - strokeColor: theme.colorScheme.surface, + strokeColor: AppColors.surface, ); }, ), belowBarData: BarAreaData( show: true, - color: theme.colorScheme.primary.withValues(alpha: 0.1), + color: AppColors.secondary.withValues(alpha: 0.1), ), ); @@ -154,138 +151,129 @@ class BiomarkerTrendChart extends StatelessWidget { BetweenBarsData( fromIndex: 0, toIndex: 1, - color: Colors.green.withValues(alpha: 0.1), + color: AppColors.success.withValues(alpha: 0.1), ), ); } - return Card( - child: Padding( - padding: const EdgeInsets.only( - right: 18, - left: 12, - top: 24, - bottom: 12, - ), - child: SizedBox( - height: 250, - child: LineChart( - LineChartData( - minX: minX, - maxX: maxX, - minY: minY, - maxY: maxY, - gridData: const FlGridData(show: true, drawVerticalLine: false), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, - interval: xInterval, - getTitlesWidget: (value, meta) { - if (value == maxX || value == minX) { - return const SizedBox.shrink(); // avoid edge cut-offs or handle with padding - } - final date = DateTime.fromMillisecondsSinceEpoch( - value.toInt(), - ); - return Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - DateFormat('MMM d').format(date), - style: theme.textTheme.bodySmall?.copyWith( - fontSize: 10, - ), - ), - ); - }, - ), - ), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - getTitlesWidget: (value, meta) { - String formattedValue; - if (value.abs() >= 10000) { - formattedValue = - '${(value / 1000).toStringAsFixed(0)}k'; - } else if (value.abs() >= 100) { - formattedValue = value.toStringAsFixed(0); - } else if (value.abs() >= 1) { - formattedValue = value.toStringAsFixed(1); - } else { - formattedValue = value.toStringAsFixed(2); - } - return Text( - formattedValue, + return Container( + decoration: KoshikaDecorations.card, + padding: const EdgeInsets.only(right: 18, left: 12, top: 24, bottom: 12), + child: SizedBox( + height: 250, + child: LineChart( + LineChartData( + minX: minX, + maxX: maxX, + minY: minY, + maxY: maxY, + gridData: const FlGridData(show: true, drawVerticalLine: false), + titlesData: FlTitlesData( + show: true, + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + interval: xInterval, + getTitlesWidget: (value, meta) { + if (value == maxX || value == minX) { + return const SizedBox.shrink(); + } + final date = DateTime.fromMillisecondsSinceEpoch( + value.toInt(), + ); + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + DateFormat('MMM d').format(date), style: theme.textTheme.bodySmall?.copyWith( fontSize: 10, ), - ); - }, - ), + ), + ); + }, ), ), - borderData: FlBorderData(show: false), - lineBarsData: barDataList, - betweenBarsData: betweenBarsData, - extraLinesData: ExtraLinesData(horizontalLines: extraLines), - lineTouchData: LineTouchData( - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (_) => - theme.colorScheme.surfaceContainerHighest, - getTooltipItems: (touchedSpots) { - return touchedSpots.map((spot) { - // Filter out touches on the reference band lines - if (spot.barIndex != mainDataLineIndex) { - return null; - } - final result = numericResults[spot.spotIndex]; - final dateStr = DateFormat( - 'dd MMM yyyy', - ).format(result.testDate); - final flagStr = _getFlagCode(result.flag); - return LineTooltipItem( - '$dateStr\n', - TextStyle( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.normal, - fontSize: 12, - ), - children: [ - TextSpan( - text: - '${result.formattedValue} ${result.unit ?? ""}', - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - TextSpan( - text: ' ($flagStr)', - style: TextStyle( - color: result.flag == BiomarkerFlag.normal - ? Colors.green - : Colors.red, - fontWeight: FontWeight.bold, - ), - ), - ], - ); - }).toList(); + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + String formattedValue; + if (value.abs() >= 10000) { + formattedValue = '${(value / 1000).toStringAsFixed(0)}k'; + } else if (value.abs() >= 100) { + formattedValue = value.toStringAsFixed(0); + } else if (value.abs() >= 1) { + formattedValue = value.toStringAsFixed(1); + } else { + formattedValue = value.toStringAsFixed(2); + } + return Text( + formattedValue, + style: theme.textTheme.bodySmall?.copyWith(fontSize: 10), + ); }, ), ), ), + borderData: FlBorderData(show: false), + lineBarsData: barDataList, + betweenBarsData: betweenBarsData, + extraLinesData: ExtraLinesData(horizontalLines: extraLines), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => AppColors.surfaceContainerHighest, + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + if (spot.barIndex != mainDataLineIndex) { + return null; + } + final result = numericResults[spot.spotIndex]; + final dateStr = DateFormat( + 'dd MMM yyyy', + ).format(result.testDate); + final flagStr = _getFlagCode(result.flag); + final flagColor = + (result.flag == BiomarkerFlag.normal || + result.flag == BiomarkerFlag.unknown) + ? AppColors.success + : AppColors.error; + return LineTooltipItem( + '$dateStr\n', + TextStyle( + color: AppColors.onSurface, + fontWeight: FontWeight.normal, + fontSize: 12, + ), + children: [ + TextSpan( + text: '${result.formattedValue} ${result.unit ?? ""}', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + TextSpan( + text: ' ($flagStr)', + style: TextStyle( + color: flagColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + }).toList(); + }, + ), + ), ), ), ), @@ -293,19 +281,13 @@ class BiomarkerTrendChart extends StatelessWidget { } String _getFlagCode(BiomarkerFlag flag) { - switch (flag) { - case BiomarkerFlag.normal: - return 'N'; - case BiomarkerFlag.borderline: - return 'B'; - case BiomarkerFlag.low: - return 'L'; - case BiomarkerFlag.high: - return 'H'; - case BiomarkerFlag.critical: - return 'C'; - case BiomarkerFlag.unknown: - return '-'; - } + return switch (flag) { + BiomarkerFlag.normal => 'N', + BiomarkerFlag.borderline => 'B', + BiomarkerFlag.low => 'L', + BiomarkerFlag.high => 'H', + BiomarkerFlag.critical => 'C', + BiomarkerFlag.unknown => '-', + }; } } diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index ec4ff72..80122d6 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -2,11 +2,13 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../models/chat_message.dart'; +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; /// Renders a single chat message with role-based styling. /// -/// - User messages: right-aligned, primary-color bubble -/// - Assistant messages: left-aligned, surface-variant bubble +/// - User messages: right-aligned, primary-color bubble, 24px radius +/// - Assistant messages: left-aligned, surface bubble with 4px left accent strip /// - Error messages: left-aligned, error-container bubble /// - Streaming messages: show animated "..." indicator at the end class ChatMessageBubble extends StatelessWidget { @@ -16,30 +18,22 @@ class ChatMessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final isUser = message.role == ChatRole.user; final isError = message.isError; final alignment = isUser ? Alignment.centerRight : Alignment.centerLeft; - final borderRadius = BorderRadius.only( - topLeft: const Radius.circular(16), - topRight: const Radius.circular(16), - bottomLeft: isUser ? const Radius.circular(16) : Radius.zero, - bottomRight: isUser ? Radius.zero : const Radius.circular(16), - ); - Color bubbleColor; Color textColor; if (isError) { - bubbleColor = theme.colorScheme.errorContainer; - textColor = theme.colorScheme.onErrorContainer; + bubbleColor = AppColors.errorContainer; + textColor = AppColors.onErrorContainer; } else if (isUser) { - bubbleColor = theme.colorScheme.primary; - textColor = theme.colorScheme.onPrimary; + bubbleColor = AppColors.primary; + textColor = Colors.white; } else { - bubbleColor = theme.colorScheme.surfaceContainerHighest; - textColor = theme.colorScheme.onSurface; + bubbleColor = AppColors.surfaceContainerLowest; + textColor = AppColors.onSurface; } return Align( @@ -48,35 +42,54 @@ class ChatMessageBubble extends StatelessWidget { constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.8, ), - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + margin: const EdgeInsets.symmetric(vertical: KoshikaSpacing.xs), + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.md, + ), decoration: BoxDecoration( color: bubbleColor, - borderRadius: borderRadius, + borderRadius: KoshikaRadius.xxl, + border: (!isUser && !isError) + ? const Border( + left: BorderSide(color: AppColors.primary, width: 4), + ) + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // AI label for assistant messages + if (!isUser && !isError) + Padding( + padding: const EdgeInsets.only(bottom: KoshikaSpacing.xs), + child: Text( + 'KOSHIKA INTELLIGENCE', + style: KoshikaTypography.metricLabel.copyWith( + color: AppColors.primary, + letterSpacing: 1.0, + ), + ), + ), + // Error icon row if (isError) Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.only(bottom: KoshikaSpacing.xs), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( + const Icon( Icons.error_outline, size: 16, - color: theme.colorScheme.error, + color: AppColors.error, ), - const SizedBox(width: 4), + const SizedBox(width: KoshikaSpacing.xs), Text( 'Error', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: theme.colorScheme.error, + style: KoshikaTypography.statusText.copyWith( + color: AppColors.error, ), ), ], @@ -95,7 +108,7 @@ class ChatMessageBubble extends StatelessWidget { // Streaming indicator appended to text if (message.content.isNotEmpty && message.isStreaming) Padding( - padding: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.only(top: KoshikaSpacing.xs), child: _StreamingDots(color: textColor), ), @@ -154,7 +167,6 @@ class _StreamingDotsState extends State<_StreamingDots> return Row( mainAxisSize: MainAxisSize.min, children: List.generate(3, (index) { - // Each dot pulses with a phase offset final delay = index * 0.2; final t = (_controller.value - delay).clamp(0.0, 1.0); final opacity = (0.3 + 0.7 * _pulseValue(t)); @@ -179,7 +191,6 @@ class _StreamingDotsState extends State<_StreamingDots> } double _pulseValue(double t) { - // Simple sine-like pulse: goes 0 → 1 → 0 over a cycle if (t < 0.5) return t * 2; return (1 - t) * 2; } diff --git a/lib/widgets/dashboard_summary_card.dart b/lib/widgets/dashboard_summary_card.dart index 8ae6255..59787e8 100644 --- a/lib/widgets/dashboard_summary_card.dart +++ b/lib/widgets/dashboard_summary_card.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; + import '../models/models.dart'; +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; class DashboardSummaryCard extends StatelessWidget { final int totalTracked; @@ -30,159 +33,136 @@ class DashboardSummaryCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Card( - elevation: 2, + return Container( + decoration: KoshikaDecorations.heroCard, clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - theme.colorScheme.primaryContainer.withValues(alpha: 0.6), - theme.colorScheme.surface, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.health_and_safety, color: theme.colorScheme.primary), - const SizedBox(width: 8), - Text( - 'Health Overview', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), + padding: KoshikaSpacing.cardPaddingAsymmetric, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.health_and_safety, + color: AppColors.onPrimaryContainer, + ), + const SizedBox(width: KoshikaSpacing.sm), + Text( + 'Health Overview', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, ), - ], - ), - const SizedBox(height: 16), - Text( - '$totalTracked Biomarkers Tracked', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, ), + ], + ), + const SizedBox(height: KoshikaSpacing.base), + Text( + '$totalTracked Biomarkers Tracked', + style: KoshikaTypography.sectionHeader.copyWith( + color: Colors.white, ), - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (normalCount > 0) - _buildStatChip(context, 'Normal', normalCount, Colors.green), - if (borderlineCount > 0) - _buildStatChip( - context, - 'Borderline', - borderlineCount, - Colors.amber, - ), - if (lowCount > 0) - _buildStatChip(context, 'Low', lowCount, Colors.orange), - if (highCount > 0) - _buildStatChip(context, 'High', highCount, Colors.red), - if (criticalCount > 0) - _buildStatChip( - context, - 'Critical', - criticalCount, - Colors.red[900]!, - ), - if (unknownCount > 0) - _buildStatChip(context, 'Unknown', unknownCount, Colors.grey), - ], - ), - // ── Synthesized Insights ── - if (insights.isNotEmpty) ...[ - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - ...insights.map( - (insight) => Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '• ', - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), + ), + const SizedBox(height: KoshikaSpacing.base), + Wrap( + spacing: KoshikaSpacing.sm, + runSpacing: KoshikaSpacing.sm, + children: [ + if (normalCount > 0) + _buildStatChip('Normal', normalCount, AppColors.success), + if (borderlineCount > 0) + _buildStatChip( + 'Borderline', + borderlineCount, + AppColors.warning, + ), + if (lowCount > 0) + _buildStatChip('Low', lowCount, AppColors.error), + if (highCount > 0) + _buildStatChip('High', highCount, AppColors.error), + if (criticalCount > 0) + _buildStatChip('Critical', criticalCount, AppColors.error), + if (unknownCount > 0) + _buildStatChip('Unknown', unknownCount, AppColors.textMuted), + ], + ), + // ── Synthesized Insights ── + if (insights.isNotEmpty) ...[ + const SizedBox(height: KoshikaSpacing.base), + ...insights.map( + (insight) => Padding( + padding: const EdgeInsets.only(bottom: KoshikaSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• ', + style: TextStyle( + color: AppColors.onTertiaryContainer, + fontWeight: FontWeight.bold, ), - Expanded( - child: Text( - insight, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.8, - ), + ), + Expanded( + child: Text( + insight, + style: theme.textTheme.bodyMedium?.copyWith( + color: AppColors.onPrimaryContainer.withValues( + alpha: 0.85, ), ), ), - ], - ), + ), + ], ), ), - ], - if (lastReport != null) ...[ - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.update, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Expanded( - child: Text( - 'Last Import: ${DateFormat('MMM d').format(lastReport!.reportDate)} • ${lastReport!.labName ?? 'Unknown Lab'}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + ), + ], + if (lastReport != null) ...[ + const SizedBox(height: KoshikaSpacing.base), + Row( + children: [ + Icon( + Icons.update, + size: 16, + color: AppColors.onPrimaryContainer.withValues(alpha: 0.6), + ), + const SizedBox(width: KoshikaSpacing.xs), + Expanded( + child: Text( + 'Last Import: ${DateFormat('MMM d').format(lastReport!.reportDate)} • ${lastReport!.labName ?? 'Unknown Lab'}', + style: theme.textTheme.bodySmall?.copyWith( + color: AppColors.onPrimaryContainer.withValues( + alpha: 0.6, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], - ), - ], + ), + ], + ), ], - ), + ], ), ); } - Widget _buildStatChip( - BuildContext context, - String label, - int count, - Color color, - ) { + Widget _buildStatChip(String label, int count, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withValues(alpha: 0.3)), + color: color.withValues(alpha: 0.15), + borderRadius: KoshikaRadius.md, + // No border — no-line rule ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( '$count', - style: TextStyle(fontWeight: FontWeight.bold, color: color), + style: KoshikaTypography.statusText.copyWith(color: color), ), - const SizedBox(width: 4), + const SizedBox(width: KoshikaSpacing.xs), Text(label, style: TextStyle(fontSize: 12, color: color)), ], ), diff --git a/lib/widgets/flag_badge.dart b/lib/widgets/flag_badge.dart index f20d677..57b47e5 100644 --- a/lib/widgets/flag_badge.dart +++ b/lib/widgets/flag_badge.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; + import '../models/models.dart'; +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; class FlagBadge extends StatelessWidget { final BiomarkerFlag flag; @@ -8,49 +11,24 @@ class FlagBadge extends StatelessWidget { @override Widget build(BuildContext context) { - Color color; - String text; - - switch (flag) { - case BiomarkerFlag.normal: - color = Colors.green; - text = 'N'; - break; - case BiomarkerFlag.borderline: - color = Colors.amber; - text = 'B'; - break; - case BiomarkerFlag.low: - color = Colors.orange; - text = 'L'; - break; - case BiomarkerFlag.high: - color = Colors.red; - text = 'H'; - break; - case BiomarkerFlag.critical: - color = Colors.red[900]!; - text = 'C'; - break; - case BiomarkerFlag.unknown: - color = Colors.grey; - text = '-'; - break; - } + final (color, text) = switch (flag) { + BiomarkerFlag.normal => (AppColors.success, 'N'), + BiomarkerFlag.borderline => (AppColors.warning, 'B'), + BiomarkerFlag.low => (AppColors.error, 'L'), + BiomarkerFlag.high => (AppColors.error, 'H'), + BiomarkerFlag.critical => (AppColors.error, 'C'), + BiomarkerFlag.unknown => (AppColors.textMuted, '-'), + }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: color.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), + color: color.withValues(alpha: 0.15), + borderRadius: KoshikaRadius.lg, ), child: Text( text, - style: TextStyle( - color: color, - fontWeight: FontWeight.bold, - fontSize: 12, - ), + style: KoshikaTypography.statusText.copyWith(color: color), ), ); } diff --git a/lib/widgets/icon_container.dart b/lib/widgets/icon_container.dart new file mode 100644 index 0000000..0a339db --- /dev/null +++ b/lib/widgets/icon_container.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../theme/koshika_design_system.dart'; + +/// Icon wrapped in a colored rounded-rect background. +/// +/// Used as the leading element in list items, feature cards, and settings rows. +/// The accent color is applied at 10% opacity for the background and full +/// opacity for the icon. +class IconContainer extends StatelessWidget { + const IconContainer({ + super.key, + required this.icon, + required this.color, + this.size = 24, + this.padding = 12, + }); + + final IconData icon; + final Color color; + final double size; + final double padding; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(padding), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: KoshikaRadius.lg, + ), + child: Icon(icon, color: color, size: size), + ); + } +} diff --git a/lib/widgets/koshika_card.dart b/lib/widgets/koshika_card.dart new file mode 100644 index 0000000..34eef03 --- /dev/null +++ b/lib/widgets/koshika_card.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; + +/// Standard card container used throughout the app. +/// +/// White card with 24px radius, asymmetric editorial padding, and no border. +/// Place on a tinted background (surfaceContainerLow) for tonal lift instead +/// of shadows. +class KoshikaCard extends StatelessWidget { + const KoshikaCard({ + super.key, + required this.child, + this.onTap, + this.padding, + this.decoration, + this.margin, + }); + + final Widget child; + final VoidCallback? onTap; + final EdgeInsetsGeometry? padding; + final BoxDecoration? decoration; + final EdgeInsetsGeometry? margin; + + @override + Widget build(BuildContext context) { + final content = Container( + padding: padding ?? KoshikaSpacing.cardPaddingAsymmetric, + margin: margin, + decoration: decoration ?? KoshikaDecorations.card, + child: child, + ); + + if (onTap == null) return content; + + return GestureDetector(onTap: onTap, child: content); + } + + /// Hero card variant: primary gradient background, white text. + static BoxDecoration get heroDecoration => KoshikaDecorations.heroCard; + + /// Insight card variant: dark green (tertiaryContainer) background. + static BoxDecoration get insightDecoration => KoshikaDecorations.insightCard; + + /// Attention card variant: light red (errorContainer) background. + static BoxDecoration get attentionDecoration => + KoshikaDecorations.attentionCard; + + /// Card on a non-tinted background (with subtle shadow for lift). + static BoxDecoration get elevatedDecoration => + KoshikaDecorations.cardElevated; + + /// Section background band for visual separation. + static BoxDecoration get sectionBackground => + const BoxDecoration(color: AppColors.surfaceContainerLow); +} diff --git a/lib/widgets/reference_range_gauge.dart b/lib/widgets/reference_range_gauge.dart index 393fc20..a952ef3 100644 --- a/lib/widgets/reference_range_gauge.dart +++ b/lib/widgets/reference_range_gauge.dart @@ -1,6 +1,8 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; + class ReferenceRangeGauge extends StatelessWidget { final double? value; final double? refLow; @@ -31,35 +33,39 @@ class ReferenceRangeGauge extends StatelessWidget { value: value!, refLow: refLow, refHigh: refHigh, - theme: Theme.of(context), ), ), ), - // Adding small labels below the gauge for context (optional, but requested in plan) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (refLow != null) Text( - 'Low', + 'LOW', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontSize: 10, - color: Colors.orange, + color: AppColors.warning, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, ), ), Text( - 'Normal', + 'NORMAL', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontSize: 10, - color: Colors.green, + color: AppColors.success, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, ), ), if (refHigh != null) Text( - 'High', + 'HIGH', style: Theme.of(context).textTheme.bodySmall?.copyWith( fontSize: 10, - color: Colors.red, + color: AppColors.error, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, ), ), ], @@ -74,26 +80,17 @@ class _GaugePainter extends CustomPainter { final double value; final double? refLow; final double? refHigh; - final ThemeData theme; - _GaugePainter({ - required this.value, - this.refLow, - this.refHigh, - required this.theme, - }); + _GaugePainter({required this.value, this.refLow, this.refHigh}); @override void paint(Canvas canvas, Size size) { - // Use actual bounds for display range; only synthesize for display padding final effectiveLow = refLow ?? 0.0; final effectiveHigh = refHigh ?? (refLow != null ? refLow! * 2.0 : 100.0); - // Calculate display range to ensure everything fits (pad by 20%) double displayMin = min(value, effectiveLow); double displayMax = max(value, effectiveHigh); - // Add 20% padding to display bounds, except don't go below 0 if all values are positive final rangePad = (displayMax - displayMin) * 0.2; displayMin = displayMin - rangePad; displayMax = displayMax + rangePad; @@ -115,15 +112,14 @@ class _GaugePainter extends CustomPainter { final paint = Paint()..style = PaintingStyle.fill; final radius = const Radius.circular(6); - final isDark = theme.brightness == Brightness.dark; - final lowColor = Colors.orange.withValues(alpha: isDark ? 0.6 : 0.3); - final normalColor = Colors.green.withValues(alpha: isDark ? 0.6 : 0.3); - final highColor = Colors.red.withValues(alpha: isDark ? 0.6 : 0.3); + // Light mode only — use consistent alpha + final lowColor = AppColors.warning.withValues(alpha: 0.3); + final normalColor = AppColors.success.withValues(alpha: 0.3); + final highColor = AppColors.error.withValues(alpha: 0.3); // Draw the background bar based on available reference limits if (refLow != null && refHigh != null) { - // 3 zones (Low, Normal, High) // Low Zone paint.color = lowColor; canvas.drawRRect( @@ -148,7 +144,6 @@ class _GaugePainter extends CustomPainter { paint, ); } else if (refLow != null) { - // 2 zones (Low, Normal) paint.color = lowColor; canvas.drawRRect( RRect.fromRectAndCorners( @@ -168,7 +163,6 @@ class _GaugePainter extends CustomPainter { paint, ); } else if (refHigh != null) { - // 2 zones (Normal, High) paint.color = normalColor; canvas.drawRRect( RRect.fromRectAndCorners( @@ -191,7 +185,7 @@ class _GaugePainter extends CustomPainter { // Draw reference limit ticks final limitTickPaint = Paint() - ..color = theme.colorScheme.onSurface.withValues(alpha: 0.5) + ..color = AppColors.onSurface.withValues(alpha: 0.5) ..strokeWidth = 1 ..style = PaintingStyle.stroke; @@ -205,12 +199,11 @@ class _GaugePainter extends CustomPainter { // Draw current value marker final valX = xPos(value).clamp(4.0, width - 4.0); - // Check if it's out of bounds and we clamped it heavily (unlikely due to displayMin/Max scaling, but safe) bool outOfBoundsRight = value > displayMax; bool outOfBoundsLeft = value < displayMin; final markerPaint = Paint() - ..color = theme.colorScheme.onSurface + ..color = AppColors.onSurface ..style = PaintingStyle.fill; // Triangle marker pointing down @@ -236,7 +229,7 @@ class _GaugePainter extends CustomPainter { } final tickPaint = Paint() - ..color = theme.colorScheme.onSurface + ..color = AppColors.onSurface ..strokeWidth = 2 ..style = PaintingStyle.stroke; @@ -251,7 +244,6 @@ class _GaugePainter extends CustomPainter { bool shouldRepaint(covariant _GaugePainter oldDelegate) { return oldDelegate.value != value || oldDelegate.refLow != refLow || - oldDelegate.refHigh != refHigh || - oldDelegate.theme != theme; + oldDelegate.refHigh != refHigh; } } diff --git a/lib/widgets/shimmer_loading.dart b/lib/widgets/shimmer_loading.dart new file mode 100644 index 0000000..67a62de --- /dev/null +++ b/lib/widgets/shimmer_loading.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; + +/// Provides a shared [AnimationController] for all shimmer children. +/// +/// Wrap a subtree with [ShimmerScope] so that all [ShimmerBox], [ShimmerLine], +/// and [ShimmerCircle] widgets animate in sync. +class ShimmerScope extends StatefulWidget { + const ShimmerScope({super.key, required this.child}); + final Widget child; + + @override + State createState() => _ShimmerScopeState(); +} + +class _ShimmerScopeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _ShimmerInherited(controller: _controller, child: widget.child); + } +} + +class _ShimmerInherited extends InheritedWidget { + const _ShimmerInherited({required this.controller, required super.child}); + + final AnimationController controller; + + static AnimationController of(BuildContext context) { + final inherited = context + .dependOnInheritedWidgetOfExactType<_ShimmerInherited>(); + assert(inherited != null, 'ShimmerScope not found in widget tree'); + return inherited!.controller; + } + + @override + bool updateShouldNotify(_ShimmerInherited oldWidget) => + controller != oldWidget.controller; +} + +/// Rounded rectangle shimmer placeholder. +class ShimmerBox extends StatelessWidget { + const ShimmerBox({ + super.key, + required this.width, + required this.height, + this.borderRadius, + }); + + final double width; + final double height; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + return _ShimmerBase( + width: width, + height: height, + borderRadius: borderRadius ?? KoshikaRadius.md, + ); + } +} + +/// Thin rectangle simulating a line of text. +class ShimmerLine extends StatelessWidget { + const ShimmerLine({ + super.key, + this.width = double.infinity, + this.height = 14, + }); + + final double width; + final double height; + + @override + Widget build(BuildContext context) { + return _ShimmerBase( + width: width, + height: height, + borderRadius: KoshikaRadius.sm, + ); + } +} + +/// Circle shimmer placeholder. +class ShimmerCircle extends StatelessWidget { + const ShimmerCircle({super.key, required this.diameter}); + final double diameter; + + @override + Widget build(BuildContext context) { + return _ShimmerBase( + width: diameter, + height: diameter, + borderRadius: BorderRadius.circular(diameter / 2), + ); + } +} + +class _ShimmerBase extends StatelessWidget { + const _ShimmerBase({ + required this.width, + required this.height, + required this.borderRadius, + }); + + final double width; + final double height; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + final controller = _ShimmerInherited.of(context); + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: borderRadius, + gradient: LinearGradient( + begin: Alignment(-1.0 + 2.0 * controller.value, 0), + end: Alignment(-1.0 + 2.0 * controller.value + 1.0, 0), + colors: const [ + AppColors.surfaceContainerLow, + AppColors.surfaceContainerHigh, + AppColors.surfaceContainerLow, + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/status_badge.dart b/lib/widgets/status_badge.dart new file mode 100644 index 0000000..9e8fa57 --- /dev/null +++ b/lib/widgets/status_badge.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +import '../models/biomarker_result.dart'; +import '../theme/app_colors.dart'; +import '../theme/koshika_design_system.dart'; + +/// Pill-shaped status badge with icon and label. +/// +/// Maps [BiomarkerFlag] to the appropriate semantic color and icon per the +/// design spec. +class StatusBadge extends StatelessWidget { + const StatusBadge({super.key, required this.flag, this.label}); + + final BiomarkerFlag flag; + + /// Optional override label. If null, derives from [flag]. + final String? label; + + @override + Widget build(BuildContext context) { + final status = _statusFor(flag); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: status.color.withValues(alpha: 0.1), + borderRadius: KoshikaRadius.pill, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(status.icon, size: 14, color: status.color), + const SizedBox(width: 6), + Text( + label ?? status.label, + style: KoshikaTypography.statusText.copyWith(color: status.color), + ), + ], + ), + ); + } + + /// Convenience constructor from a raw string flag value. + factory StatusBadge.fromString(String flag, {String? label}) { + final mapped = switch (flag.toLowerCase().trim()) { + 'normal' || 'n' => BiomarkerFlag.normal, + 'low' || 'l' => BiomarkerFlag.low, + 'high' || 'h' => BiomarkerFlag.high, + 'critical' || 'c' => BiomarkerFlag.critical, + 'borderline' || 'b' => BiomarkerFlag.borderline, + _ => BiomarkerFlag.unknown, + }; + return StatusBadge(flag: mapped, label: label); + } + + static _StatusInfo _statusFor(BiomarkerFlag flag) => switch (flag) { + BiomarkerFlag.normal => const _StatusInfo( + color: AppColors.success, + icon: Icons.check_circle_outline, + label: 'NORMAL', + ), + BiomarkerFlag.borderline => const _StatusInfo( + color: AppColors.warning, + icon: Icons.warning_amber_rounded, + label: 'BORDERLINE', + ), + BiomarkerFlag.low => const _StatusInfo( + color: AppColors.error, + icon: Icons.arrow_downward, + label: 'LOW', + ), + BiomarkerFlag.high => const _StatusInfo( + color: AppColors.error, + icon: Icons.arrow_upward, + label: 'HIGH', + ), + BiomarkerFlag.critical => const _StatusInfo( + color: AppColors.error, + icon: Icons.error_outline, + label: 'CRITICAL', + ), + BiomarkerFlag.unknown => const _StatusInfo( + color: AppColors.textMuted, + icon: Icons.help_outline, + label: 'UNKNOWN', + ), + }; +} + +class _StatusInfo { + const _StatusInfo({ + required this.color, + required this.icon, + required this.label, + }); + + final Color color; + final IconData icon; + final String label; +} diff --git a/lib/widgets/trend_line_painter.dart b/lib/widgets/trend_line_painter.dart new file mode 100644 index 0000000..66d914a --- /dev/null +++ b/lib/widgets/trend_line_painter.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +/// Lightweight [CustomPainter] that draws a simple sparkline from data points. +/// +/// Designed for compact trend previews inside dashboard category cards. +/// No axes, no labels — pure sparkline. +class SimpleTrendLinePainter extends CustomPainter { + SimpleTrendLinePainter({ + required this.values, + this.lineColor = AppColors.secondary, + this.lineWidth = 2.0, + this.showFill = true, + }); + + final List values; + final Color lineColor; + final double lineWidth; + final bool showFill; + + @override + void paint(Canvas canvas, Size size) { + if (values.length < 2) return; + + final minVal = values.reduce((a, b) => a < b ? a : b); + final maxVal = values.reduce((a, b) => a > b ? a : b); + final range = maxVal - minVal; + final effectiveRange = range == 0 ? 1.0 : range; + + // Add vertical padding so the line doesn't touch edges. + const verticalPadding = 4.0; + final drawHeight = size.height - verticalPadding * 2; + + final points = []; + for (var i = 0; i < values.length; i++) { + final x = i / (values.length - 1) * size.width; + final normalized = (values[i] - minVal) / effectiveRange; + final y = verticalPadding + drawHeight * (1 - normalized); + points.add(Offset(x, y)); + } + + // Draw fill area. + if (showFill) { + final fillPath = Path() + ..moveTo(points.first.dx, size.height) + ..lineTo(points.first.dx, points.first.dy); + for (final p in points.skip(1)) { + fillPath.lineTo(p.dx, p.dy); + } + fillPath + ..lineTo(points.last.dx, size.height) + ..close(); + + canvas.drawPath( + fillPath, + Paint()..color = lineColor.withValues(alpha: 0.1), + ); + } + + // Draw line. + final linePath = Path()..moveTo(points.first.dx, points.first.dy); + for (final p in points.skip(1)) { + linePath.lineTo(p.dx, p.dy); + } + + canvas.drawPath( + linePath, + Paint() + ..color = lineColor + ..strokeWidth = lineWidth + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(SimpleTrendLinePainter oldDelegate) => + values != oldDelegate.values || + lineColor != oldDelegate.lineColor || + lineWidth != oldDelegate.lineWidth || + showFill != oldDelegate.showFill; +} diff --git a/pubspec.lock b/pubspec.lock index 80bab49..97569b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -296,6 +296,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" google_identity_services_web: dependency: transitive description: @@ -460,26 +468,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -857,10 +865,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1fbe8cb..500e751 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,9 @@ dependencies: share_plus: ^10.1.4 path: ^1.9.1 + # Typography + google_fonts: ^6.2.1 + # Charts & visualization fl_chart: ^0.70.2