From 6a5669c17f0ae7e2e21a8b6433e5466b0be1fd7f Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Sun, 22 Mar 2026 23:53:52 +0530 Subject: [PATCH 01/20] chore: add google_fonts dependency Required for the Manrope + Inter dual-font system specified in the Koshika design spec. --- pubspec.lock | 8 ++++++++ pubspec.yaml | 3 +++ 2 files changed, 11 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index 80bab49..b56dff0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: 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 From 0a33a0686856be63be6609e89c72749a2353b836 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Sun, 22 Mar 2026 23:55:02 +0530 Subject: [PATCH 02/20] refactor: expand AppColors with semantic, text, and category tokens Add success/warning/info semantic colors, textPrimary/Secondary/Muted aliases, surfaceContainerHighest, onErrorContainer, onTertiaryContainer, and 10 health category colors with a categoryColor() lookup helper. --- lib/theme/app_colors.dart | 53 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/lib/theme/app_colors.dart b/lib/theme/app_colors.dart index 0d693d9..44e1d2d 100644 --- a/lib/theme/app_colors.dart +++ b/lib/theme/app_colors.dart @@ -23,14 +23,67 @@ 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. From 6f4bcdc3bb40b62d693478bd233d1afa59cc5706 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Sun, 22 Mar 2026 23:59:31 +0530 Subject: [PATCH 03/20] feat: create koshika_design_system with typography, spacing, radius, and elevation tokens Adds KoshikaTypography (Manrope headlines + Inter body with 15 Material styles and 6 custom health styles), KoshikaSpacing (8dp grid), KoshikaRadius (sm through pill), KoshikaElevation (tinted BoxShadow presets), and pre-built card decorations and button styles. --- lib/theme/koshika_design_system.dart | 323 +++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 lib/theme/koshika_design_system.dart 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), + ); +} From 42519571fc93537329f29d0a6f36bb9dc38fe5fc Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:04:06 +0530 Subject: [PATCH 04/20] refactor: remove dark theme, replace ColorScheme.fromSeed with explicit scheme, wire fonts Switch to light-only theme (ThemeMode.light), replace the seed-generated ColorScheme with an explicit one mapped to AppColors tokens, wire Manrope + Inter via KoshikaTypography.textTheme, set card elevation to 0, and add pill/outlined button theme defaults. --- lib/main.dart | 72 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 6cb06d8..89cf8ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,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 +36,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 +46,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( + brightness: Brightness.light, + textTheme: KoshikaTypography.textTheme, + appBarTheme: const AppBarTheme( centerTitle: true, 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, ); } } From 92428eaf3f6ff7e66d531963995b4b9a6253f8c3 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:08:19 +0530 Subject: [PATCH 05/20] feat: add KoshikaCard, StatusBadge, and IconContainer widgets KoshikaCard provides the standard card container with 24px radius, asymmetric editorial padding, and no-border design. StatusBadge maps BiomarkerFlag to semantic pill badges with icons. IconContainer wraps icons in colored rounded-rect backgrounds for list items. --- lib/widgets/icon_container.dart | 35 +++++++++++ lib/widgets/koshika_card.dart | 58 ++++++++++++++++++ lib/widgets/status_badge.dart | 100 ++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 lib/widgets/icon_container.dart create mode 100644 lib/widgets/koshika_card.dart create mode 100644 lib/widgets/status_badge.dart 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/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; +} From 7e22612b25a260705408598aa543993c98627e8f Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:08:49 +0530 Subject: [PATCH 06/20] feat: add shimmer loading widgets Adds ShimmerScope (shared AnimationController), ShimmerBox, ShimmerLine, and ShimmerCircle for skeleton loading states. Uses a horizontal gradient sweep on a 1500ms linear repeat cycle. --- lib/widgets/shimmer_loading.dart | 155 +++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 lib/widgets/shimmer_loading.dart 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, + ], + ), + ), + ); + }, + ); + } +} From cd0f20b78e1499e5104a59001ae48e8216dabf05 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:09:04 +0530 Subject: [PATCH 07/20] feat: add trend line sparkline painter and haptic feedback service SimpleTrendLinePainter is a lightweight CustomPainter for compact sparklines in dashboard category cards. Haptics wraps HapticFeedback with light, selection, and heavy static methods. --- lib/services/haptic_feedback_service.dart | 13 ++++ lib/widgets/trend_line_painter.dart | 84 +++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 lib/services/haptic_feedback_service.dart create mode 100644 lib/widgets/trend_line_painter.dart 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/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; +} From 98fea663fcb31f17ab45541a7f92ab3198762fcc Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:20:08 +0530 Subject: [PATCH 08/20] refactor: migrate dashboard to design system tokens and no-line rule Replace hardcoded colors, typography, spacing, and radii with KoshikaTypography, KoshikaSpacing, KoshikaRadius, and KoshikaDecorations. Hero card uses gradient decoration and heroMetric typography. Category trend cards now use health category colors. Insights card uses tertiaryContainer (insight) decoration. Section headers use sectionHeader style. Border.all removed from insights card (no-line rule). --- lib/screens/dashboard_screen.dart | 204 +++++++++++++----------------- 1 file changed, 87 insertions(+), 117 deletions(-) diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 6f8cf19..7979460 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 { @@ -248,7 +249,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 +265,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 +276,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,12 +303,12 @@ 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) @@ -320,7 +322,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 +348,7 @@ class _DashboardScreenState extends State { // ── Clinical Insights ──────────────────────────── if (insights.isNotEmpty) ...[ - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), _InsightsCard(insights: insights), ], ], @@ -360,38 +364,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 +408,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 +457,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 +475,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,30 +507,30 @@ class _HeroCard extends StatelessWidget { height: 1.5, ), ), - const SizedBox(height: 24), + const SizedBox(height: KoshikaSpacing.xl), // Stats row Row( children: [ _StatItem( value: '$totalTracked', - label: 'Biomarkers\nTracked', + label: 'TRACKED', valueColor: Colors.white, ), - const SizedBox(width: 32), + const SizedBox(width: KoshikaSpacing.xxl), _StatItem( value: '$abnormalCount', - label: 'Abnormal\nFlags', + label: 'FLAGGED', valueColor: abnormalCount > 0 ? const Color(0xFFFFB4AB) : Colors.white, ), if (lastReport != null) ...[ - const SizedBox(width: 32), + const SizedBox(width: KoshikaSpacing.xxl), _StatItem( value: DateFormat( 'MMM d', ).format(lastReport!.reportDate), - label: 'Last\nReport', + label: 'LAST REPORT', valueColor: AppColors.onPrimaryContainer, ), ], @@ -572,21 +563,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 +660,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 +691,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 +753,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 +769,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 +784,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 +797,7 @@ class _CategoryTrendCard extends StatelessWidget { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: KoshikaSpacing.md), Text( category, style: const TextStyle( @@ -828,12 +811,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 +835,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 +846,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 +873,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 +904,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 +940,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 +964,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 +973,7 @@ class _InsightsCard extends StatelessWidget { insight, style: const TextStyle( fontSize: 14, - color: AppColors.onSurfaceVariant, + color: AppColors.onTertiaryContainer, height: 1.5, ), ), @@ -1029,12 +1004,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!], ], From dc8052553a52228fec063289fa5db5ea8dfec6ca Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:22:30 +0530 Subject: [PATCH 09/20] refactor: migrate dashboard_summary_card and flag_badge to design system Replace hardcoded Colors.green/amber/orange/red/grey with AppColors semantic tokens. Remove Dividers and Border.all (no-line rule). Use KoshikaTypography, KoshikaDecorations, KoshikaRadius, and KoshikaSpacing. --- lib/widgets/dashboard_summary_card.dart | 230 +++++++++++------------- lib/widgets/flag_badge.dart | 50 ++---- 2 files changed, 119 insertions(+), 161 deletions(-) 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), ), ); } From d34ebe8f6e25e0a7dcc11d6329c49e5b22bb92ad Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:24:20 +0530 Subject: [PATCH 10/20] refactor: migrate biomarker detail screen to design system Replace CircularProgressIndicator with shimmer skeleton loading. Use heroMetric typography for the main biomarker value and StatusBadge pill for flag display. Add LATEST RESULT and REFERENCE RANGE metric labels. History list uses alternating row backgrounds instead of Divider separators. All spacing, radii, and colors use design system tokens. --- lib/screens/biomarker_detail_screen.dart | 338 +++++++++++++---------- 1 file changed, 189 insertions(+), 149 deletions(-) diff --git a/lib/screens/biomarker_detail_screen.dart b/lib/screens/biomarker_detail_screen.dart index d747ec2..4baae50 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,78 @@ 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: Padding( + padding: const EdgeInsets.all(KoshikaSpacing.base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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 +160,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 +168,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 +353,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; - } - } } From 4abdca2938aa3614ee238222e4c37c46610a0033 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:26:04 +0530 Subject: [PATCH 11/20] refactor: migrate biomarker_trend_chart and reference_range_gauge to design system Replace hardcoded Colors.green/red/orange with AppColors.success/error/warning. Use AppColors.secondary for chart line color. Replace Card with Container using KoshikaDecorations.card. Remove dead isDark branch in gauge painter. Zone labels use ALL-CAPS with design system-aligned styling. --- lib/widgets/biomarker_trend_chart.dart | 296 ++++++++++++------------- lib/widgets/reference_range_gauge.dart | 54 ++--- 2 files changed, 161 insertions(+), 189 deletions(-) diff --git a/lib/widgets/biomarker_trend_chart.dart b/lib/widgets/biomarker_trend_chart.dart index f76202a..c343b43 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,127 @@ 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) + ? 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 +279,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/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; } } From 5d2b89dda6453f52faf3971a271fdacd8b8a1b26 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:34:04 +0530 Subject: [PATCH 12/20] refactor: redesign chat UI with glassmorphic input bar and accent-strip bubbles AI bubbles get 4px left accent strip and KOSHIKA INTELLIGENCE label. Chat input bar uses BackdropFilter for glassmorphic effect with privacy label. All state views (download, loading, ready, error, empty) migrated to design system tokens. Removed borders per no-line rule. --- lib/screens/chat_screen.dart | 331 ++++++++++++++------------- lib/widgets/chat_message_bubble.dart | 172 ++++++++------ 2 files changed, 276 insertions(+), 227 deletions(-) diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 2e69ad0..c5b2e4b 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 @@ -180,7 +182,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 +607,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 +624,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 +639,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 +657,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 +680,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 +710,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 +727,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 +751,7 @@ class _DownloadingView extends StatelessWidget { ), ), ), - const SizedBox(height: 8), + const SizedBox(height: KoshikaSpacing.sm), Text( progress > 0 ? '$progress% downloaded' @@ -761,20 +763,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 +798,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 +815,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 +830,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 +856,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 +868,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 +925,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 +943,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 +965,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 +975,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 +1112,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 +1129,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 +1146,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 +1181,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 +1217,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 +1234,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 +1259,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/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index ec4ff72..4b2f5e5 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,65 +42,107 @@ 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), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: borderRadius, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + margin: const EdgeInsets.symmetric(vertical: KoshikaSpacing.xs), + child: Row( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Error icon row - if (isError) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( + // Left accent strip for AI messages + if (!isUser && !isError) + Container( + width: 4, + constraints: const BoxConstraints(minHeight: 40), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + Flexible( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.md, + ), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: KoshikaRadius.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.error_outline, - size: 16, - color: theme.colorScheme.error, - ), - const SizedBox(width: 4), - Text( - 'Error', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: theme.colorScheme.error, + // 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, + ), + ), ), - ), - ], - ), - ), - // Message content — or streaming dots if content is empty - if (message.content.isEmpty && message.isStreaming) - _StreamingDots(color: textColor) - else - SelectableText( - message.content, - style: TextStyle(color: textColor, fontSize: 15, height: 1.4), - ), + // Error icon row + if (isError) + Padding( + padding: const EdgeInsets.only( + bottom: KoshikaSpacing.xs, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + size: 16, + color: AppColors.error, + ), + const SizedBox(width: KoshikaSpacing.xs), + Text( + 'Error', + style: KoshikaTypography.statusText.copyWith( + color: AppColors.error, + ), + ), + ], + ), + ), - // Streaming indicator appended to text - if (message.content.isNotEmpty && message.isStreaming) - Padding( - padding: const EdgeInsets.only(top: 4), - child: _StreamingDots(color: textColor), - ), + // Message content — or streaming dots if content is empty + if (message.content.isEmpty && message.isStreaming) + _StreamingDots(color: textColor) + else + SelectableText( + message.content, + style: TextStyle( + color: textColor, + fontSize: 15, + height: 1.4, + ), + ), - // Timestamp - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - DateFormat('h:mm a').format(message.timestamp), - style: TextStyle( - color: textColor.withValues(alpha: 0.5), - fontSize: 11, + // Streaming indicator appended to text + if (message.content.isNotEmpty && message.isStreaming) + Padding( + padding: const EdgeInsets.only(top: KoshikaSpacing.xs), + child: _StreamingDots(color: textColor), + ), + + // Timestamp + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + DateFormat('h:mm a').format(message.timestamp), + style: TextStyle( + color: textColor.withValues(alpha: 0.5), + fontSize: 11, + ), + ), + ), + ], ), ), ), @@ -154,7 +190,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 +214,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; } From cdb0622754901aa81e9e021b5d596b43aaf873e5 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:39:02 +0530 Subject: [PATCH 13/20] refactor: redesign reports, report detail, and settings screens Reports list uses KoshikaCard containers instead of Card widgets. Report detail uses metricLabel for category headers, removes Dividers. Settings replaces ListTiles with custom SettingsRow using IconContainer, model tiles use KoshikaDecorations.card with switch expressions, and Dividers replaced with spacing per no-line rule. --- lib/screens/report_detail_screen.dart | 140 +++-- lib/screens/reports_screen.dart | 176 ++++--- lib/screens/settings_screen.dart | 704 ++++++++++++++------------ 3 files changed, 552 insertions(+), 468 deletions(-) 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..6a2d530 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 { @@ -204,6 +206,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 +223,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 +334,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 +357,78 @@ 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 GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.md, + ), + margin: const EdgeInsets.only(bottom: KoshikaSpacing.xs), + decoration: BoxDecoration( + color: AppColors.surfaceContainerLowest, + borderRadius: KoshikaRadius.lg, + ), + 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 +449,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, ), - const SizedBox(height: 8), + ), + if (modelInfo.status == ModelStatus.downloading) ...[ + const SizedBox(height: KoshikaSpacing.md), + LinearProgressIndicator( + value: modelInfo.downloadProgress / 100, + borderRadius: KoshikaRadius.sm, + ), + const SizedBox(height: KoshikaSpacing.xs), + Text( + '${modelInfo.downloadProgress}%', + style: theme.textTheme.labelSmall, + ), + ], + if (modelInfo.errorMessage != null) ...[ + const SizedBox(height: KoshikaSpacing.sm), Text( - 'Size: ${modelInfo.formattedSize}', + 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 +600,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, ), - const SizedBox(height: 8), + ), + if (modelInfo.status == ModelStatus.downloading) ...[ + const SizedBox(height: KoshikaSpacing.md), + LinearProgressIndicator( + value: modelInfo.downloadProgress / 100, + borderRadius: KoshikaRadius.sm, + ), + const SizedBox(height: KoshikaSpacing.xs), + Text( + '${modelInfo.downloadProgress}%', + style: theme.textTheme.labelSmall, + ), + ], + if (modelInfo.errorMessage != null) ...[ + const SizedBox(height: KoshikaSpacing.sm), Text( - 'Size: ${modelInfo.formattedSize}', + 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, + }; } // ═══════════════════════════════════════════════════════════════════════ From 4624a81be8ed98f7b5bfd28cf6f62a4a6d7de865 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:40:43 +0530 Subject: [PATCH 14/20] refactor: redesign onboarding with gradient CTA and design system tokens Onboarding pages use design system typography and spacing. Action button gets gradient pill decoration with medium elevation shadow. Page indicator dots use AppColors tokens. Icon circles use primary at 15% opacity for a lighter, editorial feel. --- lib/screens/onboarding_screen.dart | 94 +++++++++++++++++++----------- 1 file changed, 60 insertions(+), 34 deletions(-) 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, From 4af7162816f0518c52f63dd2180cf373d6c35d26 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 00:42:07 +0530 Subject: [PATCH 15/20] feat: redesign bottom navigation with glassmorphic bar and animated icons Custom bottom nav replaces NavigationBar with glassmorphic backdrop blur (white @ 85%), animated icon transitions between outlined and filled states, and primaryContainer pill indicator behind selected item. Uses extendBody for content-behind-nav effect. --- lib/main.dart | 156 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 26 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 89cf8ee..c88580e 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'; @@ -139,34 +141,136 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( + extendBody: true, 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', - ), - 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', + 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), + ), + ], + ), + ), + ), ), - ], + ), + ), + ); + } +} + +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), + ), + ], + ), ), ); } From 34f0d10d7d0d69639e635401981d6ec25c3b6a67 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 22:41:40 +0530 Subject: [PATCH 16/20] fix: resolve model SDK re-registration and clean up error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GemmaService and EmbeddingService now call installModel/installEmbedder in initialize() when model files already exist on disk. This registers the model as "active" with the flutter_gemma SDK so that getActiveModel/ getActiveEmbedder succeeds without requiring the user to re-download. Previously, the services only checked isModelInstalled but never called install(), causing "No active model set" errors on app re-open. Chat screen now auto-loads the model on entry when status is ready, removing the manual "Load Model" tap requirement on every launch. Session restore on initState removed — chat always starts fresh. Raw exception strings replaced with user-friendly messages across SplashScreen and SettingsScreen; raw errors go to debugPrint only. --- lib/screens/chat_screen.dart | 30 ++++------------------------- lib/screens/settings_screen.dart | 8 +++++--- lib/screens/splash_screen.dart | 4 +++- lib/services/embedding_service.dart | 16 +++++++++++++-- lib/services/gemma_service.dart | 14 ++++++++++++++ 5 files changed, 40 insertions(+), 32 deletions(-) diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index c5b2e4b..dbb8b3f 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -50,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 @@ -67,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]; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6a2d530..85237a3 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -144,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.'), + ), + ); } } } diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index fec8321..2c5e757 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -96,9 +96,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( From 7ce97568324be3b0fd6c4ec24feb185e8d3700c0 Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 22:41:53 +0530 Subject: [PATCH 17/20] fix: correct layout overflow and navigation edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove extendBody from HomeScreen — glassmorphic nav bar was obscuring scrollable content on screens that need full height. Set centerTitle: false in AppBarTheme so titles align left per the editorial design spec. Replace Row with Wrap in dashboard hero card stats so the three stat items wrap to a second line on narrow screens instead of overflowing. --- lib/main.dart | 3 +-- lib/screens/dashboard_screen.dart | 9 ++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index c88580e..ea84eca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -84,7 +84,7 @@ class KoshikaApp extends StatelessWidget { brightness: Brightness.light, textTheme: KoshikaTypography.textTheme, appBarTheme: const AppBarTheme( - centerTitle: true, + centerTitle: false, elevation: 0, backgroundColor: AppColors.primary, foregroundColor: Colors.white, @@ -141,7 +141,6 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - extendBody: true, body: _buildCurrentScreen(), bottomNavigationBar: ClipRect( child: BackdropFilter( diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 7979460..019ce8b 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -509,14 +509,15 @@ class _HeroCard extends StatelessWidget { ), const SizedBox(height: KoshikaSpacing.xl), // Stats row - Row( + Wrap( + spacing: KoshikaSpacing.xxl, + runSpacing: KoshikaSpacing.sm, children: [ _StatItem( value: '$totalTracked', label: 'TRACKED', valueColor: Colors.white, ), - const SizedBox(width: KoshikaSpacing.xxl), _StatItem( value: '$abnormalCount', label: 'FLAGGED', @@ -524,8 +525,7 @@ class _HeroCard extends StatelessWidget { ? const Color(0xFFFFB4AB) : Colors.white, ), - if (lastReport != null) ...[ - const SizedBox(width: KoshikaSpacing.xxl), + if (lastReport != null) _StatItem( value: DateFormat( 'MMM d', @@ -533,7 +533,6 @@ class _HeroCard extends StatelessWidget { label: 'LAST REPORT', valueColor: AppColors.onPrimaryContainer, ), - ], ], ), ], From c0230d73044b7b42b0c4f13f6ed53e02ef01b52a Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Mon, 23 Mar 2026 22:46:10 +0530 Subject: [PATCH 18/20] fix: auto-load downloaded models in background at startup After services initialize, kick off loadModel() for both GemmaService and EmbeddingService if their status is ready (i.e. already downloaded). Calls are unawaited so navigation proceeds immediately while models warm up in the background, eliminating the manual Load tap requirement. --- lib/screens/splash_screen.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 2c5e757..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)); From 519dd4cd8605b12673651d9a46e1dfb0ac886e6e Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Thu, 26 Mar 2026 20:04:56 +0530 Subject: [PATCH 19/20] style(lint): add curly braces to if statements - Wrap single-line if bodies in curly braces per lint rules - Resolves curly_braces_in_flow_control_structures warnings in dashboard_screen.dart and app_colors.dart --- lib/screens/dashboard_screen.dart | 11 ++++++++--- lib/theme/app_colors.dart | 33 +++++++++++++++++++++---------- pubspec.lock | 20 +++++++++---------- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 019ce8b..4f2526d 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -220,10 +220,14 @@ 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; }); @@ -313,8 +317,9 @@ class _DashboardScreenState extends State { 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, diff --git a/lib/theme/app_colors.dart b/lib/theme/app_colors.dart index 44e1d2d..b47e2c1 100644 --- a/lib/theme/app_colors.dart +++ b/lib/theme/app_colors.dart @@ -63,25 +63,38 @@ abstract final class AppColors { final key = category.toLowerCase().trim(); if (key.contains('blood') || key.contains('cbc') || - key.contains('hematology')) + key.contains('hematology')) { return categoryCbc; - if (key.contains('thyroid') || key.contains('endocrine')) + } + if (key.contains('thyroid') || key.contains('endocrine')) { return categoryThyroid; - if (key.contains('lipid') || key.contains('cholesterol')) + } + 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')) + } + 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')) + } + 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')) + key.contains('hba1c')) { return categoryDiabetes; - if (key.contains('inflam') || key.contains('crp') || key.contains('esr')) + } + if (key.contains('inflam') || key.contains('crp') || key.contains('esr')) { return categoryInflammation; + } return secondary; // fallback } diff --git a/pubspec.lock b/pubspec.lock index b56dff0..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: @@ -468,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: @@ -865,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: From f5c966f3102aca96f06d077543e3f0555223a30d Mon Sep 17 00:00:00 2001 From: Priyavrat Uniyal Date: Thu, 26 Mar 2026 20:39:56 +0530 Subject: [PATCH 20/20] fix(ui): resolve PR review issues from CodeRabbit - wrap shimmer skeleton in ListView to prevent overflow on landscape/small screens - add secondary name sort to make out-of-range list order deterministic - align unknown flag tooltip color with dot color (both now green) - replace GestureDetector with Material+InkWell in settings rows for ripple and a11y - use left BorderSide on chat bubble instead of separate accent strip widget --- lib/screens/biomarker_detail_screen.dart | 75 ++++++----- lib/screens/dashboard_screen.dart | 2 +- lib/screens/settings_screen.dart | 78 ++++++------ lib/widgets/biomarker_trend_chart.dart | 4 +- lib/widgets/chat_message_bubble.dart | 155 ++++++++++------------- 5 files changed, 146 insertions(+), 168 deletions(-) diff --git a/lib/screens/biomarker_detail_screen.dart b/lib/screens/biomarker_detail_screen.dart index 4baae50..cd50f79 100644 --- a/lib/screens/biomarker_detail_screen.dart +++ b/lib/screens/biomarker_detail_screen.dart @@ -73,46 +73,43 @@ class _BiomarkerDetailScreenState extends State { backgroundColor: AppColors.surface, appBar: AppBar(title: const Text('Loading...')), body: ShimmerScope( - child: Padding( + child: ListView( padding: const EdgeInsets.all(KoshikaSpacing.base), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - 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, - ), - ], - ), + 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, + ), + ], ), ), ); diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 4f2526d..a1e80be 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -228,7 +228,7 @@ class _DashboardScreenState extends State { a.flag != BiomarkerFlag.critical) { return 1; } - return 0; + return a.displayName.compareTo(b.displayName); }); final allHistories = objectbox.getHistoryForBiomarkers( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 85237a3..0e9abd9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -384,47 +384,49 @@ class _SettingsRow extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: KoshikaSpacing.base, - vertical: KoshikaSpacing.md, - ), - margin: const EdgeInsets.only(bottom: KoshikaSpacing.xs), - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, + 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: 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, + 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!, + ], ), - if (trailing != null) trailing!, - ], + ), ), ), ); diff --git a/lib/widgets/biomarker_trend_chart.dart b/lib/widgets/biomarker_trend_chart.dart index c343b43..5a39d7f 100644 --- a/lib/widgets/biomarker_trend_chart.dart +++ b/lib/widgets/biomarker_trend_chart.dart @@ -240,7 +240,9 @@ class BiomarkerTrendChart extends StatelessWidget { 'dd MMM yyyy', ).format(result.testDate); final flagStr = _getFlagCode(result.flag); - final flagColor = (result.flag == BiomarkerFlag.normal) + final flagColor = + (result.flag == BiomarkerFlag.normal || + result.flag == BiomarkerFlag.unknown) ? AppColors.success : AppColors.error; return LineTooltipItem( diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 4b2f5e5..80122d6 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -43,106 +43,83 @@ class ChatMessageBubble extends StatelessWidget { maxWidth: MediaQuery.of(context).size.width * 0.8, ), margin: const EdgeInsets.symmetric(vertical: KoshikaSpacing.xs), - child: Row( - mainAxisSize: MainAxisSize.min, + padding: const EdgeInsets.symmetric( + horizontal: KoshikaSpacing.base, + vertical: KoshikaSpacing.md, + ), + decoration: BoxDecoration( + color: bubbleColor, + 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: [ - // Left accent strip for AI messages + // AI label for assistant messages if (!isUser && !isError) - Container( - width: 4, - constraints: const BoxConstraints(minHeight: 40), - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: BorderRadius.circular(2), + Padding( + padding: const EdgeInsets.only(bottom: KoshikaSpacing.xs), + child: Text( + 'KOSHIKA INTELLIGENCE', + style: KoshikaTypography.metricLabel.copyWith( + color: AppColors.primary, + letterSpacing: 1.0, + ), ), ), - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: KoshikaSpacing.base, - vertical: KoshikaSpacing.md, - ), - decoration: BoxDecoration( - color: bubbleColor, - borderRadius: KoshikaRadius.xxl, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + + // Error icon row + if (isError) + Padding( + padding: const EdgeInsets.only(bottom: KoshikaSpacing.xs), + child: Row( 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: KoshikaSpacing.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.error_outline, - size: 16, - color: AppColors.error, - ), - const SizedBox(width: KoshikaSpacing.xs), - Text( - 'Error', - style: KoshikaTypography.statusText.copyWith( - color: AppColors.error, - ), - ), - ], - ), + const Icon( + Icons.error_outline, + size: 16, + color: AppColors.error, + ), + const SizedBox(width: KoshikaSpacing.xs), + Text( + 'Error', + style: KoshikaTypography.statusText.copyWith( + color: AppColors.error, ), + ), + ], + ), + ), - // Message content — or streaming dots if content is empty - if (message.content.isEmpty && message.isStreaming) - _StreamingDots(color: textColor) - else - SelectableText( - message.content, - style: TextStyle( - color: textColor, - fontSize: 15, - height: 1.4, - ), - ), + // Message content — or streaming dots if content is empty + if (message.content.isEmpty && message.isStreaming) + _StreamingDots(color: textColor) + else + SelectableText( + message.content, + style: TextStyle(color: textColor, fontSize: 15, height: 1.4), + ), - // Streaming indicator appended to text - if (message.content.isNotEmpty && message.isStreaming) - Padding( - padding: const EdgeInsets.only(top: KoshikaSpacing.xs), - child: _StreamingDots(color: textColor), - ), + // Streaming indicator appended to text + if (message.content.isNotEmpty && message.isStreaming) + Padding( + padding: const EdgeInsets.only(top: KoshikaSpacing.xs), + child: _StreamingDots(color: textColor), + ), - // Timestamp - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - DateFormat('h:mm a').format(message.timestamp), - style: TextStyle( - color: textColor.withValues(alpha: 0.5), - fontSize: 11, - ), - ), - ), - ], + // Timestamp + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + DateFormat('h:mm a').format(message.timestamp), + style: TextStyle( + color: textColor.withValues(alpha: 0.5), + fontSize: 11, ), ), ),