From e8ffbc7632d9efcbe78eb04304d608678ef7ff6b Mon Sep 17 00:00:00 2001 From: Syed Salman Reza Date: Wed, 27 May 2026 06:52:25 +0600 Subject: [PATCH 1/4] Switch app accent to tangerine and update UI palette Replace legacy blue/coral/apricot/mulberry accents with new design tokens (tangerine, poppy, mint, lilac, sunny) across multiple screens and widgets to align with updated brand palette. Add per-destination color to bottom/rail navigation and improve accessibility (Semantics, InkWell, ripple/highlight behavior) so colors remain tied to routes and interactions. Adjust spacing (add xxl) and muted text contrast, update theme/text styles to new values. Remove obsolete care repository export and update imports to pet_care_repository and new pet model paths. Remove a now-unused buyer order detail route. Add Supabase function and several DB migration files, update local .claude settings for edge function deployment and a file removal helper. Miscellaneous UI tweaks (badges, buttons, shop/vendor screens, forms, and social species palettes) to use the new tokens. --- .claude/settings.local.json | 5 +- lib/core/router.dart | 127 +++++++-------- lib/core/theme/app_colors.dart | 35 ---- lib/core/theme/app_theme.dart | 13 +- lib/core/widgets/app_header.dart | 4 +- .../presentation/screens/admin_layout.dart | 12 +- .../widgets/admin_dashboard_tab.dart | 10 +- .../widgets/financial_ledger_tab.dart | 4 +- .../widgets/kyc_approvals_tab.dart | 4 +- .../presentation/widgets/orders_tab.dart | 4 +- .../widgets/secure_doc_button.dart | 2 +- .../presentation/widgets/auth_widgets.dart | 4 +- .../data/repositories/care_repository.dart | 1 - .../services/care_recommendation_service.dart | 2 +- .../care_dashboard_controller.dart | 2 +- .../presentation/screens/care_screen.dart | 2 +- .../screens/medical_vault_screen.dart | 4 +- .../widgets/routine_recommendation_sheet.dart | 2 +- .../presentation/screens/cart_screen.dart | 8 +- .../customer/buyer_order_detail_screen.dart | 4 +- .../customer/buyer_order_list_screen.dart | 4 +- .../customer/shop_storefront_screen.dart | 8 +- .../screens/product_detail_screen.dart | 2 +- .../vendor/add_edit_product_screen.dart | 6 +- .../screens/vendor/edit_shop_screen.dart | 22 +-- .../screens/vendor/manual_kyc_screen.dart | 12 +- .../vendor/seller_dashboard_screen.dart | 6 +- .../screens/vendor/shop_setup_screen.dart | 12 +- .../vendor/vendor_order_detail_screen.dart | 2 +- .../vendor/vendor_order_queue_screen.dart | 4 +- .../widgets/subscription_toggle.dart | 2 +- .../presentation/screens/chat_screen.dart | 4 +- .../screens/matches_inbox_screen.dart | 4 +- .../widgets/match_preferences_sheet.dart | 2 +- .../screens/edit_profile_screen.dart | 2 +- .../screens/manage_pets_screen.dart | 10 +- .../widgets/pet_switcher_sheet.dart | 12 +- .../data/repositories/social_repository.dart | 12 +- .../screens/create_post_screen.dart | 32 ++-- .../screens/create_story_screen.dart | 12 +- .../screens/notifications_screen.dart | 8 +- .../screens/post_detail_screen.dart | 20 +-- .../presentation/screens/social_screen.dart | 6 +- progress.md | 31 ++++ supabase/functions/cleanup-stories/index.ts | 37 +++++ supabase/functions/stripe-webhook/index.ts | 47 +++--- ...527000000_follows_not_null_constraints.sql | 11 ++ ...0260527010000_shops_bank_account_token.sql | 11 ++ .../20260527020000_vendor_ledger_checkout.sql | 151 ++++++++++++++++++ ...00_products_inventory_and_stories_cron.sql | 29 ++++ 50 files changed, 503 insertions(+), 267 deletions(-) delete mode 100644 lib/features/care/data/repositories/care_repository.dart create mode 100644 supabase/functions/cleanup-stories/index.ts create mode 100644 supabase/migrations/20260527000000_follows_not_null_constraints.sql create mode 100644 supabase/migrations/20260527010000_shops_bank_account_token.sql create mode 100644 supabase/migrations/20260527020000_vendor_ledger_checkout.sql create mode 100644 supabase/migrations/20260527030000_products_inventory_and_stories_cron.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6ecf6c6..4cd063f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -89,7 +89,10 @@ "mcp__Windows-MCP__Snapshot", "mcp__Claude_in_Chrome__find", "Bash(dir \"G:\\\\GitHub\\\\petfolio\\\\google_fonts\" /b)", - "mcp__5d4f3e29-8e00-4c0a-a2e0-3f9d9a2735f2__list_projects" + "mcp__5d4f3e29-8e00-4c0a-a2e0-3f9d9a2735f2__list_projects", + "mcp__5d4f3e29-8e00-4c0a-a2e0-3f9d9a2735f2__deploy_edge_function", + "Bash(Remove-Item \"G:\\\\GitHub\\\\petfolio\\\\lib\\\\features\\\\care\\\\data\\\\repositories\\\\care_repository.dart\")", + "PowerShell(Remove-Item \"G:\\\\GitHub\\\\petfolio\\\\lib\\\\features\\\\care\\\\data\\\\repositories\\\\care_repository.dart\" -Confirm:$false)" ] } } diff --git a/lib/core/router.dart b/lib/core/router.dart index e64379b..0e32668 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -165,14 +165,6 @@ final routerProvider = Provider((ref) { path: '/profile/orders', builder: (context, state) => const BuyerOrderListScreen(), ), - GoRoute( - parentNavigatorKey: _rootNavigatorKey, - path: '/profile/orders/:id', - builder: (context, state) => BuyerOrderDetailScreen( - orderId: state.pathParameters['id']!, - order: state.extra as MarketplaceOrder?, - ), - ), GoRoute( parentNavigatorKey: _rootNavigatorKey, path: '/shop/:id', @@ -399,11 +391,11 @@ class AppShell extends StatelessWidget { final Widget child; static const _destinations = [ - _NavDestination(icon: Icons.pets_outlined, activeIcon: Icons.pets, label: 'Pets', path: '/home'), - _NavDestination(icon: Icons.local_fire_department_outlined, activeIcon: Icons.local_fire_department, label: 'Care', path: '/care'), - _NavDestination(icon: Icons.favorite_border, activeIcon: Icons.favorite, label: 'Social', path: '/social'), - _NavDestination(icon: Icons.auto_awesome_outlined, activeIcon: Icons.auto_awesome, label: 'Match', path: '/matching'), - _NavDestination(icon: Icons.storefront_outlined, activeIcon: Icons.storefront, label: 'Market', path: '/marketplace'), + _NavDestination(icon: Icons.pets_outlined, activeIcon: Icons.pets, label: 'Pets', path: '/home', color: AppColors.tangerine), + _NavDestination(icon: Icons.local_fire_department_outlined, activeIcon: Icons.local_fire_department, label: 'Care', path: '/care', color: AppColors.sunny), + _NavDestination(icon: Icons.favorite_border, activeIcon: Icons.favorite, label: 'Social', path: '/social', color: AppColors.poppy), + _NavDestination(icon: Icons.auto_awesome_outlined, activeIcon: Icons.auto_awesome, label: 'Match', path: '/matching', color: AppColors.lilac), + _NavDestination(icon: Icons.storefront_outlined, activeIcon: Icons.storefront, label: 'Market', path: '/marketplace', color: AppColors.mint), ]; int _selectedIndex(BuildContext context) { @@ -477,7 +469,7 @@ class _PetEditMissingScreen extends StatelessWidget { ), const SizedBox(height: 16), FilledButton( - onPressed: () => context.go('/home'), + onPressed: () => context.go('/pets/manage'), child: const Text('Back to Pets'), ), ], @@ -493,23 +485,18 @@ class _NavDestination { required this.activeIcon, required this.label, required this.path, + required this.color, }); final IconData icon; final IconData activeIcon; final String label; final String path; + /// Tab accent color — must stay co-located with its destination so a reorder + /// never silently mismatches color and route. + final Color color; } -// ─── Tab accent colors (matches design system pillar colors) ───────────────── -const _tabColors = [ - AppColors.tangerine, // Pets - AppColors.sunny, // Care - AppColors.poppy, // Social - AppColors.lilac, // Match - AppColors.mint, // Market -]; - // ─── Floating pill bottom nav ───────────────────────────────────────────────── class _FloatingNav extends StatelessWidget { @@ -547,7 +534,6 @@ class _FloatingNav extends StatelessWidget { child: _NavTab( destination: destinations[i], isSelected: i == selectedIndex, - accentColor: _tabColors[i], isDark: isDark, onTap: () => onSelect(i), ), @@ -562,53 +548,59 @@ class _NavTab extends StatelessWidget { const _NavTab({ required this.destination, required this.isSelected, - required this.accentColor, required this.isDark, required this.onTap, }); final _NavDestination destination; final bool isSelected; - final Color accentColor; final bool isDark; final VoidCallback onTap; @override Widget build(BuildContext context) { - final unselectedColor = isDark ? AppColors.ink500D : AppColors.ink500; - final iconColor = isSelected ? accentColor : unselectedColor; - final softColor = Color.alphaBlend(accentColor.withAlpha(36), Colors.transparent); - - return GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), - decoration: BoxDecoration( - color: isSelected ? softColor : Colors.transparent, - borderRadius: BorderRadius.circular(999), - ), - child: Icon( - isSelected ? destination.activeIcon : destination.icon, - color: iconColor, - size: 22, + final accentColor = destination.color; + final unselectedColor = isDark ? AppColors.ink500D : AppColors.ink700; + final iconColor = isSelected ? accentColor : unselectedColor; + final softColor = Color.alphaBlend(accentColor.withAlpha(36), Colors.transparent); + + return Semantics( + label: destination.label, + selected: isSelected, + button: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(999), + splashColor: accentColor.withAlpha(30), + highlightColor: Colors.transparent, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + decoration: BoxDecoration( + color: isSelected ? softColor : Colors.transparent, + borderRadius: BorderRadius.circular(999), + ), + child: Icon( + isSelected ? destination.activeIcon : destination.icon, + color: iconColor, + size: 22, + ), ), - ), - const SizedBox(height: 2), - Text( - destination.label, - style: GoogleFonts.inter( - fontSize: 11, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, - color: iconColor, - height: 1.0, + const SizedBox(height: 2), + Text( + destination.label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: iconColor, + height: 1.0, + ), ), - ), - ], + ], + ), ), ); } @@ -635,21 +627,30 @@ class _WideNavRail extends StatelessWidget { selectedIndex: selectedIndex, labelType: NavigationRailLabelType.all, backgroundColor: isDark ? AppColors.surface0D : AppColors.surface0, - indicatorColor: Colors.transparent, onDestinationSelected: onSelect, destinations: [ for (var i = 0; i < destinations.length; i++) NavigationRailDestination( padding: EdgeInsets.zero, - icon: Icon(destinations[i].icon, - color: selectedIndex == i ? _tabColors[i] : (isDark ? AppColors.ink500D : AppColors.ink500)), - selectedIcon: Icon(destinations[i].activeIcon, color: _tabColors[i]), + icon: Semantics( + label: destinations[i].label, + selected: selectedIndex == i, + child: Icon( + destinations[i].icon, + color: selectedIndex == i + ? destinations[i].color + : (isDark ? AppColors.ink500D : AppColors.ink700), + ), + ), + selectedIcon: Icon(destinations[i].activeIcon, color: destinations[i].color), label: Text( destinations[i].label, style: GoogleFonts.inter( fontSize: 12, fontWeight: selectedIndex == i ? FontWeight.w700 : FontWeight.w500, - color: selectedIndex == i ? _tabColors[i] : (isDark ? AppColors.ink500D : AppColors.ink500), + color: selectedIndex == i + ? destinations[i].color + : (isDark ? AppColors.ink500D : AppColors.ink700), ), ), ), diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index af07b83..42ec12d 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -110,39 +110,4 @@ abstract final class AppColors { static const shadowE3D = Color(0x8C000000); static const shadowE4D = Color(0xA6000000); static const shadowGlassD = Color(0x8C000000); - - // ── Backward-compat aliases (used by existing screens) ────────────────────── - static const blue50 = Color(0xFFFFE0CB); // → tangerineSoft - static const blue100 = tangerineSoft; - static const blue200 = tangerineSoft; - static const blue300 = tangerine; - static const blue400 = tangerine; - static const blue500 = tangerine; - static const blue600 = tangerine700; - static const blue700 = tangerine700; - static const blue100D = tangerineSoftD; - static const blue200D = tangerineSoftD; - static const blue300D = tangerineD; - static const blue400D = tangerineD; - static const blue500D = tangerineD; - static const blue600D = tangerine700D; - static const blue700D = tangerine700D; - static const blue800D = tangerine700D; - static const blue900D = tangerine700D; - - static const sunset500 = tangerine; - static const sunset500D = tangerineD; - static const coral500 = poppy; - static const coral500D = poppyD; - static const meadow500 = mint; - static const meadow500D = mintD; - static const apricot500 = sunny; - static const apricot500D = sunnyD; - static const mulberry500 = lilac; - static const mulberry500D = lilacD; - - static const line200 = line; - static const line200D = lineD; - static const line100 = line2; - static const line100D = line2D; } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index dd173ef..b437fc2 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -289,11 +289,12 @@ class PetfolioThemeExtension extends ThemeExtension { // ───────────────────────────────────────────────────────────────────────────── final class AppThemeSpacing { const AppThemeSpacing(); - double get xs => 4; - double get sm => 8; - double get md => 12; - double get lg => 16; - double get xl => 24; + double get xs => 4; + double get sm => 8; + double get md => 12; + double get lg => 16; + double get xxl => 20; + double get xl => 24; } // ───────────────────────────────────────────────────────────────────────────── @@ -517,7 +518,7 @@ abstract final class AppTheme { static TextTheme _textTheme(bool isDark) { final headColor = isDark ? AppColors.ink950D : AppColors.ink950; final bodyColor = isDark ? AppColors.ink700D : AppColors.ink700; - final mutedColor = isDark ? AppColors.ink500D : AppColors.ink500; + final mutedColor = isDark ? AppColors.ink700D : AppColors.ink700; // Bundled weights: Sora → w600 (SemiBold), w700 (Bold) // Inter → w400 (Regular), w500 (Medium), w600 (SemiBold), w700 (Bold) diff --git a/lib/core/widgets/app_header.dart b/lib/core/widgets/app_header.dart index 075331c..b448460 100644 --- a/lib/core/widgets/app_header.dart +++ b/lib/core/widgets/app_header.dart @@ -339,7 +339,7 @@ class _BadgePill extends StatelessWidget { constraints: const BoxConstraints(minWidth: 16, minHeight: 16), padding: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( - color: AppColors.coral500, + color: AppColors.poppy, borderRadius: BorderRadius.circular(999), border: Border.all(color: pt.surface1, width: 1.5), ), @@ -359,7 +359,7 @@ class _BadgePill extends StatelessWidget { width: 9, height: 9, decoration: BoxDecoration( - color: AppColors.coral500, + color: AppColors.poppy, shape: BoxShape.circle, border: Border.all(color: pt.surface1, width: 1.5), ), diff --git a/lib/features/admin/presentation/screens/admin_layout.dart b/lib/features/admin/presentation/screens/admin_layout.dart index 9c48e84..53f0113 100644 --- a/lib/features/admin/presentation/screens/admin_layout.dart +++ b/lib/features/admin/presentation/screens/admin_layout.dart @@ -174,7 +174,7 @@ class _AdminLayoutState extends ConsumerState { leading: Icon( _tab == d.tab ? d.activeIcon : d.icon, color: _tab == d.tab - ? AppColors.blue500 + ? AppColors.tangerine : AppColors.ink500, ), title: Text( @@ -184,12 +184,12 @@ class _AdminLayoutState extends ConsumerState { ? FontWeight.w600 : FontWeight.w400, color: _tab == d.tab - ? AppColors.blue500 + ? AppColors.tangerine : AppColors.ink700, ), ), selected: _tab == d.tab, - selectedTileColor: AppColors.blue500.withAlpha(12), + selectedTileColor: AppColors.tangerine.withAlpha(12), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular( PetfolioThemeExtension.radiusMd, @@ -223,16 +223,16 @@ class _AdminBadge extends StatelessWidget { height: 36, decoration: BoxDecoration( shape: BoxShape.circle, - color: AppColors.blue500.withAlpha(20), + color: AppColors.tangerine.withAlpha(20), ), child: const Icon(Icons.admin_panel_settings_rounded, - size: 20, color: AppColors.blue500), + size: 20, color: AppColors.tangerine), ), const SizedBox(height: 4), Text( 'Admin', style: Theme.of(context).textTheme.labelSmall!.copyWith( - color: AppColors.blue500, + color: AppColors.tangerine, ), ), ], diff --git a/lib/features/admin/presentation/widgets/admin_dashboard_tab.dart b/lib/features/admin/presentation/widgets/admin_dashboard_tab.dart index d3c6355..981816e 100644 --- a/lib/features/admin/presentation/widgets/admin_dashboard_tab.dart +++ b/lib/features/admin/presentation/widgets/admin_dashboard_tab.dart @@ -69,7 +69,7 @@ class _MetricsBanner extends StatelessWidget { begin: Alignment.topLeft, end: Alignment.bottomRight, // ink950 → blue600 — both from AppColors - colors: [AppColors.ink950, AppColors.blue600], + colors: [AppColors.ink950, AppColors.tangerine700], ), boxShadow: pt.shadowE2, ), @@ -100,7 +100,7 @@ class _MetricsBanner extends StatelessWidget { icon: Icons.storefront_rounded, label: 'Active Shops', value: data.activeShopCount.toString(), - iconColor: AppColors.blue300, + iconColor: AppColors.tangerine, ), _MetricGlassCard( icon: Icons.assignment_outlined, @@ -231,11 +231,11 @@ class _RecentActivitySection extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(PetfolioThemeExtension.radiusPill), - color: AppColors.blue500.withAlpha(15), + color: AppColors.tangerine.withAlpha(15), ), child: Text( '${items.length}', - style: tt.labelSmall!.copyWith(color: AppColors.blue500), + style: tt.labelSmall!.copyWith(color: AppColors.tangerine), ), ), ], @@ -285,7 +285,7 @@ class _ActivityTile extends StatelessWidget { final (icon, color) = switch (item.type) { ActivityType.shopJoined => ( Icons.storefront_outlined, - AppColors.blue500, + AppColors.tangerine, ), ActivityType.orderDelivered => ( Icons.check_circle_outline_rounded, diff --git a/lib/features/admin/presentation/widgets/financial_ledger_tab.dart b/lib/features/admin/presentation/widgets/financial_ledger_tab.dart index e87462e..c311570 100644 --- a/lib/features/admin/presentation/widgets/financial_ledger_tab.dart +++ b/lib/features/admin/presentation/widgets/financial_ledger_tab.dart @@ -106,7 +106,7 @@ class _PayoutCardState extends ConsumerState<_PayoutCard> { children: [ Text( 'Bank details', - style: tt.labelMedium!.copyWith(color: AppColors.blue500), + style: tt.labelMedium!.copyWith(color: AppColors.tangerine), ), const SizedBox(width: 4), Icon( @@ -114,7 +114,7 @@ class _PayoutCardState extends ConsumerState<_PayoutCard> { ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, size: 16, - color: AppColors.blue500, + color: AppColors.tangerine, ), ], ), diff --git a/lib/features/admin/presentation/widgets/kyc_approvals_tab.dart b/lib/features/admin/presentation/widgets/kyc_approvals_tab.dart index 82ecdec..f99cc85 100644 --- a/lib/features/admin/presentation/widgets/kyc_approvals_tab.dart +++ b/lib/features/admin/presentation/widgets/kyc_approvals_tab.dart @@ -398,7 +398,7 @@ class _ShopAvatar extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(PetfolioThemeExtension.radiusMd), - color: AppColors.blue500.withAlpha(15), + color: AppColors.tangerine.withAlpha(15), border: Border.all(color: AppColors.line), ), clipBehavior: Clip.antiAlias, @@ -414,7 +414,7 @@ class _ShopAvatar extends StatelessWidget { Widget get _fallback => const Center( child: Icon(Icons.storefront_outlined, - size: 22, color: AppColors.blue500), + size: 22, color: AppColors.tangerine), ); } diff --git a/lib/features/admin/presentation/widgets/orders_tab.dart b/lib/features/admin/presentation/widgets/orders_tab.dart index a287847..de18cd5 100644 --- a/lib/features/admin/presentation/widgets/orders_tab.dart +++ b/lib/features/admin/presentation/widgets/orders_tab.dart @@ -92,10 +92,10 @@ class _CodOrderCardState extends State<_CodOrderCard> { decoration: BoxDecoration( borderRadius: BorderRadius.circular(PetfolioThemeExtension.radiusMd), - color: AppColors.blue500.withAlpha(15), + color: AppColors.tangerine.withAlpha(15), ), child: const Icon(Icons.payments_outlined, - size: 22, color: AppColors.blue500), + size: 22, color: AppColors.tangerine), ), const SizedBox(width: 14), Expanded( diff --git a/lib/features/admin/presentation/widgets/secure_doc_button.dart b/lib/features/admin/presentation/widgets/secure_doc_button.dart index 62a0f86..6a0f9db 100644 --- a/lib/features/admin/presentation/widgets/secure_doc_button.dart +++ b/lib/features/admin/presentation/widgets/secure_doc_button.dart @@ -65,7 +65,7 @@ class _SecureDocButtonState extends ConsumerState { style: OutlinedButton.styleFrom( visualDensity: VisualDensity.compact, side: const BorderSide(color: AppColors.line), - foregroundColor: AppColors.blue500, + foregroundColor: AppColors.tangerine, ), ); } diff --git a/lib/features/auth/presentation/widgets/auth_widgets.dart b/lib/features/auth/presentation/widgets/auth_widgets.dart index 866208f..43441ec 100644 --- a/lib/features/auth/presentation/widgets/auth_widgets.dart +++ b/lib/features/auth/presentation/widgets/auth_widgets.dart @@ -24,13 +24,13 @@ class AuthBrand extends StatelessWidget { gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [AppColors.blue400, AppColors.blue600], + colors: [AppColors.tangerine, AppColors.tangerine700], ), borderRadius: BorderRadius.circular(PetfolioThemeExtension.radiusXl), boxShadow: [ BoxShadow( - color: AppColors.blue500.withAlpha(80), + color: AppColors.tangerine.withAlpha(80), blurRadius: 24, offset: const Offset(0, 8), ), diff --git a/lib/features/care/data/repositories/care_repository.dart b/lib/features/care/data/repositories/care_repository.dart deleted file mode 100644 index 2be1b13..0000000 --- a/lib/features/care/data/repositories/care_repository.dart +++ /dev/null @@ -1 +0,0 @@ -export 'pet_care_repository.dart'; diff --git a/lib/features/care/domain/services/care_recommendation_service.dart b/lib/features/care/domain/services/care_recommendation_service.dart index 6f25f84..063c740 100644 --- a/lib/features/care/domain/services/care_recommendation_service.dart +++ b/lib/features/care/domain/services/care_recommendation_service.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; -import 'package:petfolio/core/models/pet.dart'; +import 'package:petfolio/features/pet_profile/data/models/pet.dart'; import 'package:petfolio/features/care/data/models/care_task.dart'; final careRecommendationServiceProvider = Provider( diff --git a/lib/features/care/presentation/controllers/care_dashboard_controller.dart b/lib/features/care/presentation/controllers/care_dashboard_controller.dart index ae6bff0..1bc22cb 100644 --- a/lib/features/care/presentation/controllers/care_dashboard_controller.dart +++ b/lib/features/care/presentation/controllers/care_dashboard_controller.dart @@ -5,7 +5,7 @@ import '../../../../core/widgets/app_snack_bar.dart'; import '../../../pet_profile/presentation/controllers/active_pet_controller.dart'; import '../../data/models/care_streak.dart'; import '../../data/models/care_task.dart'; -import '../../data/repositories/care_repository.dart'; +import '../../data/repositories/pet_care_repository.dart'; import 'care_streak_stream_provider.dart'; part 'care_dashboard_controller.g.dart'; diff --git a/lib/features/care/presentation/screens/care_screen.dart b/lib/features/care/presentation/screens/care_screen.dart index e36d5f6..0db400e 100644 --- a/lib/features/care/presentation/screens/care_screen.dart +++ b/lib/features/care/presentation/screens/care_screen.dart @@ -11,7 +11,7 @@ import 'package:petfolio/features/pet_profile/presentation/controllers/pet_list_ import 'package:petfolio/features/pet_profile/presentation/widgets/pet_switcher_sheet.dart'; import 'package:petfolio/core/errors/app_exception.dart'; -import 'package:petfolio/core/models/pet.dart' show Pet; +import 'package:petfolio/features/pet_profile/data/models/pet.dart' show Pet; import 'package:petfolio/features/care/data/models/care_task.dart' as dbtask; import 'package:petfolio/features/care/data/models/care_task_log.dart'; diff --git a/lib/features/care/presentation/screens/medical_vault_screen.dart b/lib/features/care/presentation/screens/medical_vault_screen.dart index 2be15e2..5863ba2 100644 --- a/lib/features/care/presentation/screens/medical_vault_screen.dart +++ b/lib/features/care/presentation/screens/medical_vault_screen.dart @@ -541,7 +541,7 @@ class _MedicalRecordCard extends ConsumerWidget { Icon( Icons.attach_file_rounded, size: 14, - color: AppColors.blue500, + color: AppColors.tangerine, ), SizedBox(width: 4), Text( @@ -549,7 +549,7 @@ class _MedicalRecordCard extends ConsumerWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: AppColors.blue500, + color: AppColors.tangerine, ), ), ], diff --git a/lib/features/care/presentation/widgets/routine_recommendation_sheet.dart b/lib/features/care/presentation/widgets/routine_recommendation_sheet.dart index 074e546..720c6cd 100644 --- a/lib/features/care/presentation/widgets/routine_recommendation_sheet.dart +++ b/lib/features/care/presentation/widgets/routine_recommendation_sheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:petfolio/core/models/pet.dart'; +import 'package:petfolio/features/pet_profile/data/models/pet.dart'; import 'package:petfolio/features/care/data/models/care_task.dart'; import 'package:petfolio/features/care/presentation/controllers/care_dashboard_controller.dart'; import 'package:petfolio/core/widgets/app_snack_bar.dart'; diff --git a/lib/features/marketplace/presentation/screens/cart_screen.dart b/lib/features/marketplace/presentation/screens/cart_screen.dart index 6ea893c..c39f6f0 100644 --- a/lib/features/marketplace/presentation/screens/cart_screen.dart +++ b/lib/features/marketplace/presentation/screens/cart_screen.dart @@ -368,10 +368,10 @@ class _PaymentChip extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), color: selected - ? AppColors.blue500.withAlpha(15) + ? AppColors.tangerine.withAlpha(15) : AppColors.surface1, border: Border.all( - color: selected ? AppColors.blue500 : AppColors.line, + color: selected ? AppColors.tangerine : AppColors.line, width: selected ? 1.5 : 1, ), ), @@ -381,7 +381,7 @@ class _PaymentChip extends StatelessWidget { Icon( icon, size: 16, - color: selected ? AppColors.blue500 : AppColors.ink500, + color: selected ? AppColors.tangerine : AppColors.ink500, ), const SizedBox(width: 6), Flexible( @@ -390,7 +390,7 @@ class _PaymentChip extends StatelessWidget { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: selected ? AppColors.blue500 : AppColors.ink700, + color: selected ? AppColors.tangerine : AppColors.ink700, ), overflow: TextOverflow.ellipsis, ), diff --git a/lib/features/marketplace/presentation/screens/customer/buyer_order_detail_screen.dart b/lib/features/marketplace/presentation/screens/customer/buyer_order_detail_screen.dart index 2ce656a..4ca677d 100644 --- a/lib/features/marketplace/presentation/screens/customer/buyer_order_detail_screen.dart +++ b/lib/features/marketplace/presentation/screens/customer/buyer_order_detail_screen.dart @@ -111,7 +111,7 @@ class _StatusCard extends StatelessWidget { final color = switch (order.status) { OrderStatus.pending => AppColors.warning, OrderStatus.processing => AppColors.info, - OrderStatus.shipped => AppColors.blue500, + OrderStatus.shipped => AppColors.tangerine, OrderStatus.delivered => AppColors.success, OrderStatus.cancelled => AppColors.danger, }; @@ -296,7 +296,7 @@ class _LineItemsCard extends StatelessWidget { 'Subscribe · every ${item.frequencyWeeks}w', style: const TextStyle( fontSize: 11, - color: AppColors.meadow500), + color: AppColors.mint), ), ], ), diff --git a/lib/features/marketplace/presentation/screens/customer/buyer_order_list_screen.dart b/lib/features/marketplace/presentation/screens/customer/buyer_order_list_screen.dart index 832d8fd..46db756 100644 --- a/lib/features/marketplace/presentation/screens/customer/buyer_order_list_screen.dart +++ b/lib/features/marketplace/presentation/screens/customer/buyer_order_list_screen.dart @@ -156,7 +156,7 @@ class _OrderTile extends StatelessWidget { Color _statusColor(OrderStatus s) => switch (s) { OrderStatus.pending => AppColors.warning, OrderStatus.processing => AppColors.info, - OrderStatus.shipped => AppColors.blue500, + OrderStatus.shipped => AppColors.tangerine, OrderStatus.delivered => AppColors.success, OrderStatus.cancelled => AppColors.danger, }; @@ -186,7 +186,7 @@ class _StatusChip extends StatelessWidget { Color get _color => switch (status) { OrderStatus.pending => AppColors.warning, OrderStatus.processing => AppColors.info, - OrderStatus.shipped => AppColors.blue500, + OrderStatus.shipped => AppColors.tangerine, OrderStatus.delivered => AppColors.success, OrderStatus.cancelled => AppColors.danger, }; diff --git a/lib/features/marketplace/presentation/screens/customer/shop_storefront_screen.dart b/lib/features/marketplace/presentation/screens/customer/shop_storefront_screen.dart index ee456be..f36c1ae 100644 --- a/lib/features/marketplace/presentation/screens/customer/shop_storefront_screen.dart +++ b/lib/features/marketplace/presentation/screens/customer/shop_storefront_screen.dart @@ -112,7 +112,7 @@ class ShopStorefrontScreen extends ConsumerWidget { height: 16, decoration: const BoxDecoration( shape: BoxShape.circle, - color: AppColors.coral500, + color: AppColors.poppy, ), child: Center( child: Text( @@ -141,7 +141,7 @@ class ShopStorefrontScreen extends ConsumerWidget { gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [AppColors.apricot500, AppColors.coral500], + colors: [AppColors.sunny, AppColors.poppy], ), ), ), @@ -406,9 +406,9 @@ class _SocialBtn extends StatelessWidget { margin: const EdgeInsets.only(right: 8), decoration: BoxDecoration( shape: BoxShape.circle, - color: AppColors.blue500.withAlpha(20), + color: AppColors.tangerine.withAlpha(20), ), - child: Icon(icon, size: 18, color: AppColors.blue500), + child: Icon(icon, size: 18, color: AppColors.tangerine), ), ), ), diff --git a/lib/features/marketplace/presentation/screens/product_detail_screen.dart b/lib/features/marketplace/presentation/screens/product_detail_screen.dart index 57c077d..c4ca0e0 100644 --- a/lib/features/marketplace/presentation/screens/product_detail_screen.dart +++ b/lib/features/marketplace/presentation/screens/product_detail_screen.dart @@ -519,7 +519,7 @@ class _SubscribeCard extends StatelessWidget { height: 44, decoration: BoxDecoration( borderRadius: BorderRadius.circular(14), - color: subscribe ? AppColors.meadow500 : AppColors.surface2, + color: subscribe ? AppColors.mint : AppColors.surface2, ), child: Icon( Icons.autorenew_rounded, diff --git a/lib/features/marketplace/presentation/screens/vendor/add_edit_product_screen.dart b/lib/features/marketplace/presentation/screens/vendor/add_edit_product_screen.dart index 010ce24..f3fc920 100644 --- a/lib/features/marketplace/presentation/screens/vendor/add_edit_product_screen.dart +++ b/lib/features/marketplace/presentation/screens/vendor/add_edit_product_screen.dart @@ -260,7 +260,7 @@ class _AddEditProductScreenState value: _subscribable, onChanged: (v) => setState(() => _subscribable = v), - activeTrackColor: AppColors.blue500, + activeTrackColor: AppColors.tangerine, ), const SizedBox(width: 10), const Column( @@ -322,7 +322,7 @@ class _AddEditProductScreenState ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.blue500, width: 1.5), + borderSide: const BorderSide(color: AppColors.tangerine, width: 1.5), ), ); } @@ -385,7 +385,7 @@ class _Field extends StatelessWidget { focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: - const BorderSide(color: AppColors.blue500, width: 1.5), + const BorderSide(color: AppColors.tangerine, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), diff --git a/lib/features/marketplace/presentation/screens/vendor/edit_shop_screen.dart b/lib/features/marketplace/presentation/screens/vendor/edit_shop_screen.dart index 5c971f5..677744f 100644 --- a/lib/features/marketplace/presentation/screens/vendor/edit_shop_screen.dart +++ b/lib/features/marketplace/presentation/screens/vendor/edit_shop_screen.dart @@ -200,8 +200,8 @@ class _EditShopScreenState extends ConsumerState controller: _tabController, labelStyle: tt.labelMedium!.copyWith(fontWeight: FontWeight.w600), unselectedLabelStyle: tt.labelMedium, - indicatorColor: AppColors.blue500, - labelColor: AppColors.blue500, + indicatorColor: AppColors.tangerine, + labelColor: AppColors.tangerine, unselectedLabelColor: AppColors.ink500, tabs: const [ Tab(text: 'Branding'), @@ -378,12 +378,12 @@ class _BannerPicker extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.photo_camera_outlined, size: 16, color: AppColors.blue500), + Icon(Icons.photo_camera_outlined, size: 16, color: AppColors.tangerine), const SizedBox(width: 6), Text( 'Change Banner', style: Theme.of(context).textTheme.labelMedium! - .copyWith(color: AppColors.blue500, fontWeight: FontWeight.w600), + .copyWith(color: AppColors.tangerine, fontWeight: FontWeight.w600), ), ], ), @@ -396,8 +396,8 @@ class _BannerPicker extends StatelessWidget { } Widget _bannerPlaceholder(BuildContext context) => Container( - color: AppColors.blue500.withAlpha(15), - child: const Icon(Icons.storefront_outlined, size: 40, color: AppColors.blue500), + color: AppColors.tangerine.withAlpha(15), + child: const Icon(Icons.storefront_outlined, size: 40, color: AppColors.tangerine), ); } @@ -422,9 +422,9 @@ class _LogoPicker extends StatelessWidget { } else if (existingUrl != null) { image = Image.network(existingUrl!, fit: BoxFit.cover, errorBuilder: (context2, error, stack) => const Icon( - Icons.storefront_outlined, size: 28, color: AppColors.blue500)); + Icons.storefront_outlined, size: 28, color: AppColors.tangerine)); } else { - image = const Icon(Icons.storefront_outlined, size: 28, color: AppColors.blue500); + image = const Icon(Icons.storefront_outlined, size: 28, color: AppColors.tangerine); } return GestureDetector( @@ -435,7 +435,7 @@ class _LogoPicker extends StatelessWidget { clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: BorderRadius.circular(PetfolioThemeExtension.radiusLg), - color: AppColors.blue500.withAlpha(15), + color: AppColors.tangerine.withAlpha(15), border: Border.all(color: AppColors.line), boxShadow: pt.shadowE1, ), @@ -450,7 +450,7 @@ class _LogoPicker extends StatelessWidget { width: 22, height: 22, decoration: const BoxDecoration( - color: AppColors.blue500, + color: AppColors.tangerine, shape: BoxShape.circle, ), child: const Icon(Icons.edit, size: 12, color: Colors.white), @@ -700,7 +700,7 @@ class _FormField extends StatelessWidget { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(PetfolioThemeExtension.radiusMd), - borderSide: BorderSide(color: AppColors.blue500, width: 1.5), + borderSide: BorderSide(color: AppColors.tangerine, width: 1.5), ), ), ), diff --git a/lib/features/marketplace/presentation/screens/vendor/manual_kyc_screen.dart b/lib/features/marketplace/presentation/screens/vendor/manual_kyc_screen.dart index 49b0e54..1aef2ed 100644 --- a/lib/features/marketplace/presentation/screens/vendor/manual_kyc_screen.dart +++ b/lib/features/marketplace/presentation/screens/vendor/manual_kyc_screen.dart @@ -163,7 +163,7 @@ class _StepIndicator extends StatelessWidget { height: 4, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), - color: active ? AppColors.blue500 : AppColors.line, + color: active ? AppColors.tangerine : AppColors.line, ), ), ); @@ -292,7 +292,7 @@ class _DocPicker extends StatelessWidget { borderRadius: BorderRadius.circular(12), color: AppColors.surface0, border: Border.all( - color: hasFile ? AppColors.blue500 : AppColors.line, + color: hasFile ? AppColors.tangerine : AppColors.line, width: hasFile ? 1.5 : 1, ), ), @@ -304,13 +304,13 @@ class _DocPicker extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), color: hasFile - ? AppColors.blue500.withAlpha(20) + ? AppColors.tangerine.withAlpha(20) : AppColors.surface2, ), child: Icon( hasFile ? Icons.check_circle_outline_rounded : icon, size: 20, - color: hasFile ? AppColors.blue500 : AppColors.ink300, + color: hasFile ? AppColors.tangerine : AppColors.ink300, ), ), const SizedBox(width: 14), @@ -336,7 +336,7 @@ class _DocPicker extends StatelessWidget { Icon( Icons.upload_rounded, size: 18, - color: hasFile ? AppColors.blue500 : AppColors.ink300, + color: hasFile ? AppColors.tangerine : AppColors.ink300, ), ], ), @@ -445,7 +445,7 @@ class _Field extends StatelessWidget { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.blue500, width: 1.5), + borderSide: const BorderSide(color: AppColors.tangerine, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), diff --git a/lib/features/marketplace/presentation/screens/vendor/seller_dashboard_screen.dart b/lib/features/marketplace/presentation/screens/vendor/seller_dashboard_screen.dart index 570115a..7fb34d3 100644 --- a/lib/features/marketplace/presentation/screens/vendor/seller_dashboard_screen.dart +++ b/lib/features/marketplace/presentation/screens/vendor/seller_dashboard_screen.dart @@ -302,7 +302,7 @@ class _DashboardBody extends ConsumerWidget { icon: Icons.inventory_2_outlined, label: 'Products', value: '$productCount', - color: AppColors.apricot500, + color: AppColors.sunny, onTap: () => context.push('/seller/products'), ), ), @@ -312,7 +312,7 @@ class _DashboardBody extends ConsumerWidget { icon: Icons.receipt_long_outlined, label: 'Pending orders', value: '$pendingOrders', - color: AppColors.coral500, + color: AppColors.poppy, onTap: () => context.push('/seller/orders'), ), ), @@ -527,7 +527,7 @@ class _ShopStatusChip extends StatelessWidget { return switch (shop.kycStatus) { KycStatus.submitted => ( 'Under Review', - AppColors.blue500, + AppColors.tangerine, Icons.hourglass_top_rounded, false, ), diff --git a/lib/features/marketplace/presentation/screens/vendor/shop_setup_screen.dart b/lib/features/marketplace/presentation/screens/vendor/shop_setup_screen.dart index d20667d..8b006d1 100644 --- a/lib/features/marketplace/presentation/screens/vendor/shop_setup_screen.dart +++ b/lib/features/marketplace/presentation/screens/vendor/shop_setup_screen.dart @@ -222,7 +222,7 @@ class _LocationTile extends StatelessWidget { borderRadius: BorderRadius.circular(12), color: AppColors.surface0, border: Border.all( - color: selected ? AppColors.blue500 : AppColors.line, + color: selected ? AppColors.tangerine : AppColors.line, width: selected ? 1.5 : 1, ), ), @@ -234,13 +234,13 @@ class _LocationTile extends StatelessWidget { decoration: BoxDecoration( shape: BoxShape.circle, color: selected - ? AppColors.blue500.withAlpha(20) + ? AppColors.tangerine.withAlpha(20) : AppColors.surface2, ), child: Icon( icon, size: 18, - color: selected ? AppColors.blue500 : AppColors.ink300, + color: selected ? AppColors.tangerine : AppColors.ink300, ), ), const SizedBox(width: 12), @@ -253,7 +253,7 @@ class _LocationTile extends StatelessWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: selected ? AppColors.blue500 : AppColors.ink950, + color: selected ? AppColors.tangerine : AppColors.ink950, ), ), const SizedBox(height: 2), @@ -270,7 +270,7 @@ class _LocationTile extends StatelessWidget { ? Icons.radio_button_checked_rounded : Icons.radio_button_unchecked_rounded, size: 20, - color: selected ? AppColors.blue500 : AppColors.ink300, + color: selected ? AppColors.tangerine : AppColors.ink300, ), ], ), @@ -332,7 +332,7 @@ class _Field extends StatelessWidget { focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: - const BorderSide(color: AppColors.blue500, width: 1.5), + const BorderSide(color: AppColors.tangerine, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), diff --git a/lib/features/marketplace/presentation/screens/vendor/vendor_order_detail_screen.dart b/lib/features/marketplace/presentation/screens/vendor/vendor_order_detail_screen.dart index b32add8..69f2a3d 100644 --- a/lib/features/marketplace/presentation/screens/vendor/vendor_order_detail_screen.dart +++ b/lib/features/marketplace/presentation/screens/vendor/vendor_order_detail_screen.dart @@ -410,7 +410,7 @@ class _StatusChip extends StatelessWidget { Color get _color => switch (status) { OrderStatus.pending => AppColors.warning, OrderStatus.processing => AppColors.info, - OrderStatus.shipped => AppColors.blue500, + OrderStatus.shipped => AppColors.tangerine, OrderStatus.delivered => AppColors.success, OrderStatus.cancelled => AppColors.danger, }; diff --git a/lib/features/marketplace/presentation/screens/vendor/vendor_order_queue_screen.dart b/lib/features/marketplace/presentation/screens/vendor/vendor_order_queue_screen.dart index f095c57..bdb9728 100644 --- a/lib/features/marketplace/presentation/screens/vendor/vendor_order_queue_screen.dart +++ b/lib/features/marketplace/presentation/screens/vendor/vendor_order_queue_screen.dart @@ -159,7 +159,7 @@ class _OrderTile extends StatelessWidget { return switch (s) { OrderStatus.pending => AppColors.warning, OrderStatus.processing => AppColors.info, - OrderStatus.shipped => AppColors.blue500, + OrderStatus.shipped => AppColors.tangerine, OrderStatus.delivered => AppColors.success, OrderStatus.cancelled => AppColors.danger, }; @@ -184,7 +184,7 @@ class _StatusChip extends StatelessWidget { Color get _color => switch (status) { OrderStatus.pending => AppColors.warning, OrderStatus.processing => AppColors.info, - OrderStatus.shipped => AppColors.blue500, + OrderStatus.shipped => AppColors.tangerine, OrderStatus.delivered => AppColors.success, OrderStatus.cancelled => AppColors.danger, }; diff --git a/lib/features/marketplace/presentation/widgets/subscription_toggle.dart b/lib/features/marketplace/presentation/widgets/subscription_toggle.dart index f565ad7..c18c563 100644 --- a/lib/features/marketplace/presentation/widgets/subscription_toggle.dart +++ b/lib/features/marketplace/presentation/widgets/subscription_toggle.dart @@ -27,7 +27,7 @@ class SubscriptionToggle extends StatelessWidget { height: 26, decoration: BoxDecoration( borderRadius: BorderRadius.circular(13), - color: value ? AppColors.meadow500 : AppColors.line, + color: value ? AppColors.mint : AppColors.line, ), child: AnimatedAlign( duration: const Duration(milliseconds: 200), diff --git a/lib/features/matching/presentation/screens/chat_screen.dart b/lib/features/matching/presentation/screens/chat_screen.dart index 75f3185..b470f0e 100644 --- a/lib/features/matching/presentation/screens/chat_screen.dart +++ b/lib/features/matching/presentation/screens/chat_screen.dart @@ -184,7 +184,7 @@ class _MessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { final pt = Theme.of(context).extension()!; - final bg = isMine ? AppColors.coral500 : pt.surface2; + final bg = isMine ? AppColors.poppy : pt.surface2; final fg = isMine ? Colors.white : pt.ink500; final align = isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start; final radius = BorderRadius.only( @@ -287,7 +287,7 @@ class _Composer extends StatelessWidget { key: const ValueKey('chat_send_button'), onPressed: sending ? null : onSend, style: IconButton.styleFrom( - backgroundColor: AppColors.coral500, + backgroundColor: AppColors.poppy, foregroundColor: Colors.white, ), icon: sending diff --git a/lib/features/matching/presentation/screens/matches_inbox_screen.dart b/lib/features/matching/presentation/screens/matches_inbox_screen.dart index 43cbdec..c57c3a1 100644 --- a/lib/features/matching/presentation/screens/matches_inbox_screen.dart +++ b/lib/features/matching/presentation/screens/matches_inbox_screen.dart @@ -223,8 +223,8 @@ class _NewMatchAvatar extends StatelessWidget { shape: BoxShape.circle, gradient: LinearGradient( colors: [ - AppColors.coral500, - AppColors.sunset500.withValues(alpha: 0.85), + AppColors.poppy, + AppColors.tangerine.withValues(alpha: 0.85), ], ), ), diff --git a/lib/features/matching/presentation/widgets/match_preferences_sheet.dart b/lib/features/matching/presentation/widgets/match_preferences_sheet.dart index b4471dd..5b3ce77 100644 --- a/lib/features/matching/presentation/widgets/match_preferences_sheet.dart +++ b/lib/features/matching/presentation/widgets/match_preferences_sheet.dart @@ -229,7 +229,7 @@ class _SectionLabel extends StatelessWidget { style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, - color: AppColors.blue500, + color: AppColors.tangerine, ), ), ], diff --git a/lib/features/pet_profile/presentation/screens/edit_profile_screen.dart b/lib/features/pet_profile/presentation/screens/edit_profile_screen.dart index 7fa7649..3bb9c49 100644 --- a/lib/features/pet_profile/presentation/screens/edit_profile_screen.dart +++ b/lib/features/pet_profile/presentation/screens/edit_profile_screen.dart @@ -104,7 +104,7 @@ class _EditProfileScreenState extends ConsumerState { builder: (context, child) => Theme( data: Theme.of(context).copyWith( colorScheme: Theme.of(context).colorScheme.copyWith( - primary: AppColors.blue500, + primary: AppColors.tangerine, ), ), child: child!, diff --git a/lib/features/pet_profile/presentation/screens/manage_pets_screen.dart b/lib/features/pet_profile/presentation/screens/manage_pets_screen.dart index 3f12aa6..282403b 100644 --- a/lib/features/pet_profile/presentation/screens/manage_pets_screen.dart +++ b/lib/features/pet_profile/presentation/screens/manage_pets_screen.dart @@ -529,9 +529,9 @@ class _AddPetCallout extends StatelessWidget { child: Container( padding: const EdgeInsets.fromLTRB(14, 14, 14, 14), decoration: BoxDecoration( - color: AppColors.blue50, + color: AppColors.tangerineSoft, borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.blue400.withAlpha(80)), + border: Border.all(color: AppColors.tangerine.withAlpha(80)), ), child: Row( children: [ @@ -656,14 +656,14 @@ class _ShareAccessSheet extends StatelessWidget { Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: AppColors.blue50, + color: AppColors.tangerineSoft, borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.blue400.withAlpha(80)), + border: Border.all(color: AppColors.tangerine.withAlpha(80)), ), child: Row( children: [ Icon(Icons.schedule_rounded, - size: 18, color: AppColors.blue600), + size: 18, color: AppColors.tangerine700), const SizedBox(width: 10), Expanded( child: Text( diff --git a/lib/features/pet_profile/presentation/widgets/pet_switcher_sheet.dart b/lib/features/pet_profile/presentation/widgets/pet_switcher_sheet.dart index 45299f4..0ae4dd5 100644 --- a/lib/features/pet_profile/presentation/widgets/pet_switcher_sheet.dart +++ b/lib/features/pet_profile/presentation/widgets/pet_switcher_sheet.dart @@ -353,7 +353,7 @@ class _AddPetButton extends StatelessWidget { behavior: HitTestBehavior.opaque, child: CustomPaint( painter: _DashedRoundedBorderPainter( - color: AppColors.blue400, + color: AppColors.tangerine, radius: 18, strokeWidth: 1.5, ), @@ -361,7 +361,7 @@ class _AddPetButton extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), decoration: BoxDecoration( - color: AppColors.blue50, + color: AppColors.tangerineSoft, borderRadius: BorderRadius.circular(18), ), child: Row( @@ -469,12 +469,12 @@ class _SignOutRow extends ConsumerWidget { child: Container( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), decoration: BoxDecoration( - color: AppColors.coral500.withAlpha(14), + color: AppColors.poppy.withAlpha(14), borderRadius: BorderRadius.circular(14), ), child: Row( children: [ - Icon(Icons.logout_rounded, size: 18, color: AppColors.coral500), + Icon(Icons.logout_rounded, size: 18, color: AppColors.poppy), const SizedBox(width: 12), Expanded( child: Text( @@ -482,7 +482,7 @@ class _SignOutRow extends ConsumerWidget { style: TextStyle( fontWeight: FontWeight.w600, fontSize: 14, - color: AppColors.coral500, + color: AppColors.poppy, ), ), ), @@ -505,7 +505,7 @@ class _SignOutRow extends ConsumerWidget { ), FilledButton( style: FilledButton.styleFrom( - backgroundColor: AppColors.coral500, + backgroundColor: AppColors.poppy, ), onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Sign out'), diff --git a/lib/features/social/data/repositories/social_repository.dart b/lib/features/social/data/repositories/social_repository.dart index 934c187..6a1e34e 100644 --- a/lib/features/social/data/repositories/social_repository.dart +++ b/lib/features/social/data/repositories/social_repository.dart @@ -198,26 +198,26 @@ class SocialRepository { switch (species.toLowerCase()) { case 'cat': return const _SpeciesPalette( - accent: AppColors.mulberry500, + accent: AppColors.lilac, subject: Color(0xFF7A4570), gradient: [ Color(0xFFF5ECD7), Color(0xFFD4B896), - AppColors.mulberry500, + AppColors.lilac, ], ); case 'rabbit': return const _SpeciesPalette( - accent: AppColors.meadow500, + accent: AppColors.mint, subject: Color(0xFF4F8C72), - gradient: [Color(0xFFE3F1E9), Color(0xFF9CCDB3), AppColors.meadow500], + gradient: [Color(0xFFE3F1E9), Color(0xFF9CCDB3), AppColors.mint], ); case 'dog': default: return const _SpeciesPalette( - accent: AppColors.blue500, + accent: AppColors.tangerine, subject: Color(0xFF1D4ED8), - gradient: [Color(0xFFBFD7FF), Color(0xFF6EA8FE), AppColors.blue500], + gradient: [Color(0xFFBFD7FF), Color(0xFF6EA8FE), AppColors.tangerine], ); } } diff --git a/lib/features/social/presentation/screens/create_post_screen.dart b/lib/features/social/presentation/screens/create_post_screen.dart index 6a19963..e99b44c 100644 --- a/lib/features/social/presentation/screens/create_post_screen.dart +++ b/lib/features/social/presentation/screens/create_post_screen.dart @@ -217,7 +217,7 @@ class _CreatePostScreenState extends ConsumerState { child: FilledButton( onPressed: canPost ? _submit : null, style: FilledButton.styleFrom( - backgroundColor: AppColors.sunset500, + backgroundColor: AppColors.tangerine, disabledBackgroundColor: pt.line, foregroundColor: Colors.white, disabledForegroundColor: pt.ink300, @@ -298,20 +298,20 @@ class _PetIdentityRow extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: AppColors.sunset500.withAlpha(26), + color: AppColors.tangerine.withAlpha(26), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.public_rounded, size: 12, color: AppColors.sunset500), + Icon(Icons.public_rounded, size: 12, color: AppColors.tangerine), const SizedBox(width: 4), Text( 'Public', style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: AppColors.sunset500, + color: AppColors.tangerine, ), ), ], @@ -472,13 +472,13 @@ class _EmptyImagePlaceholder extends StatelessWidget { width: 72, height: 72, decoration: BoxDecoration( - color: AppColors.sunset500.withAlpha(20), + color: AppColors.tangerine.withAlpha(20), shape: BoxShape.circle, ), child: const Icon( Icons.add_photo_alternate_rounded, size: 34, - color: AppColors.sunset500, + color: AppColors.tangerine, ), ), const SizedBox(height: 14), @@ -514,13 +514,13 @@ class _EmptyImagePlaceholder extends StatelessWidget { _SourceChip( icon: Icons.photo_library_outlined, label: 'Gallery', - color: AppColors.blue500, + color: AppColors.tangerine, ), const SizedBox(width: 10), _SourceChip( icon: Icons.camera_alt_outlined, label: 'Camera', - color: AppColors.meadow500, + color: AppColors.mint, ), ], ), @@ -690,10 +690,10 @@ class _VisibilityInfo extends StatelessWidget { width: 36, height: 36, decoration: BoxDecoration( - color: AppColors.sunset500.withAlpha(20), + color: AppColors.tangerine.withAlpha(20), borderRadius: BorderRadius.circular(10), ), - child: const Icon(Icons.public_rounded, size: 18, color: AppColors.sunset500), + child: const Icon(Icons.public_rounded, size: 18, color: AppColors.tangerine), ), const SizedBox(width: 12), Expanded( @@ -765,7 +765,7 @@ class _ImageSourceSheet extends StatelessWidget { const SizedBox(height: 20), _SheetOption( icon: Icons.photo_library_rounded, - color: AppColors.blue500, + color: AppColors.tangerine, title: 'Photo Library', subtitle: 'Choose from your gallery', onTap: () => Navigator.of(context).pop(ImageSource.gallery), @@ -773,7 +773,7 @@ class _ImageSourceSheet extends StatelessWidget { const SizedBox(height: 10), _SheetOption( icon: Icons.camera_alt_rounded, - color: AppColors.meadow500, + color: AppColors.mint, title: 'Take Photo', subtitle: 'Use your camera', onTap: () => Navigator.of(context).pop(ImageSource.camera), @@ -916,7 +916,7 @@ class _UploadOverlay extends StatelessWidget { height: 48, child: CircularProgressIndicator( strokeWidth: 3, - color: AppColors.sunset500, + color: AppColors.tangerine, ), ), const SizedBox(height: 20), @@ -957,17 +957,17 @@ class _ErrorBanner extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - color: AppColors.coral500.withAlpha(20), + color: AppColors.poppy.withAlpha(20), child: Row( children: [ - const Icon(Icons.error_outline_rounded, color: AppColors.coral500, size: 18), + const Icon(Icons.error_outline_rounded, color: AppColors.poppy, size: 18), const SizedBox(width: 10), Expanded( child: Text( message, style: const TextStyle( fontSize: 13, - color: AppColors.coral500, + color: AppColors.poppy, fontWeight: FontWeight.w500, ), ), diff --git a/lib/features/social/presentation/screens/create_story_screen.dart b/lib/features/social/presentation/screens/create_story_screen.dart index 3299daa..daafc84 100644 --- a/lib/features/social/presentation/screens/create_story_screen.dart +++ b/lib/features/social/presentation/screens/create_story_screen.dart @@ -254,7 +254,7 @@ class _CreateStoryScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator( - color: AppColors.sunset500, + color: AppColors.tangerine, ), const SizedBox(height: 16), Text( @@ -411,11 +411,11 @@ class _CreateStoryScreenState extends ConsumerState { decoration: BoxDecoration( borderRadius: BorderRadius.circular(30), gradient: const LinearGradient( - colors: [AppColors.sunset500, AppColors.coral500], + colors: [AppColors.tangerine, AppColors.poppy], ), boxShadow: [ BoxShadow( - color: AppColors.sunset500.withAlpha(100), + color: AppColors.tangerine.withAlpha(100), blurRadius: 16, offset: const Offset(0, 6), ), @@ -504,7 +504,7 @@ class _BrowseLibraryTile extends StatelessWidget { ), child: const Icon( Icons.photo_library_rounded, - color: AppColors.sunset500, + color: AppColors.tangerine, size: 18, ), ), @@ -721,7 +721,7 @@ class _CameraViewfinderCardState extends State<_CameraViewfinderCard> ), child: Row( children: [ - const Icon(Icons.center_focus_weak_rounded, color: AppColors.sunset500, size: 14), + const Icon(Icons.center_focus_weak_rounded, color: AppColors.tangerine, size: 14), const SizedBox(width: 4), Text( 'AF-S Auto', @@ -868,7 +868,7 @@ class _UploadOverlay extends StatelessWidget { height: 48, child: CircularProgressIndicator( strokeWidth: 3, - color: AppColors.sunset500, + color: AppColors.tangerine, ), ), const SizedBox(height: 20), diff --git a/lib/features/social/presentation/screens/notifications_screen.dart b/lib/features/social/presentation/screens/notifications_screen.dart index a3ea34d..e20e902 100644 --- a/lib/features/social/presentation/screens/notifications_screen.dart +++ b/lib/features/social/presentation/screens/notifications_screen.dart @@ -166,7 +166,7 @@ class _NotificationTile extends StatelessWidget { width: 8, height: 8, decoration: BoxDecoration( - color: AppColors.coral500, + color: AppColors.poppy, shape: BoxShape.circle, ), ) @@ -178,11 +178,11 @@ class _NotificationTile extends StatelessWidget { Color _iconColor(String type) { switch (type) { case 'like': - return AppColors.coral500; + return AppColors.poppy; case 'follow': - return AppColors.meadow500; + return AppColors.mint; case 'comment': - return AppColors.blue500; + return AppColors.tangerine; default: return AppColors.ink500; } diff --git a/lib/features/social/presentation/screens/post_detail_screen.dart b/lib/features/social/presentation/screens/post_detail_screen.dart index a2283f8..a638294 100644 --- a/lib/features/social/presentation/screens/post_detail_screen.dart +++ b/lib/features/social/presentation/screens/post_detail_screen.dart @@ -93,7 +93,7 @@ class _PostDetailScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to post comment: ${e.toString()}'), - backgroundColor: AppColors.coral500, + backgroundColor: AppColors.poppy, ), ); } @@ -506,7 +506,7 @@ class _StatsBar extends ConsumerWidget { IconButton( icon: Icon( post.isLiked ? Icons.pets_rounded : Icons.pets_outlined, - color: post.isLiked ? AppColors.coral500 : pt.ink500, + color: post.isLiked ? AppColors.poppy : pt.ink500, ), onPressed: () { final activePet = ref.read(activePetControllerProvider); @@ -629,11 +629,11 @@ class _CommentTile extends ConsumerWidget { // Delete action ListTile( leading: const Icon(Icons.delete_outline_rounded, - color: AppColors.coral500), + color: AppColors.poppy), title: Text( 'Delete Comment', style: tt.bodyMedium?.copyWith( - color: AppColors.coral500, + color: AppColors.poppy, fontWeight: FontWeight.w600, ), ), @@ -723,7 +723,7 @@ class _CommentTile extends ConsumerWidget { const SizedBox(height: 16), FilledButton( style: FilledButton.styleFrom( - backgroundColor: AppColors.coral500, + backgroundColor: AppColors.poppy, foregroundColor: Colors.white, minimumSize: const Size.fromHeight(48), shape: RoundedRectangleBorder( @@ -780,7 +780,7 @@ class _CommentTile extends ConsumerWidget { onTap: () => context.push('/social/profile/${comment.petId}'), child: CircleAvatar( radius: isReply ? 12 : 16, - backgroundColor: AppColors.coral500.withAlpha(200), + backgroundColor: AppColors.poppy.withAlpha(200), backgroundImage: comment.avatarUrl != null ? CachedNetworkImageProvider(comment.avatarUrl!) : null, @@ -875,7 +875,7 @@ class _CommentTile extends ConsumerWidget { icon: Icon( comment.isLiked ? Icons.pets_rounded : Icons.pets_outlined, size: 16, - color: comment.isLiked ? AppColors.coral500 : pt.ink300, + color: comment.isLiked ? AppColors.poppy : pt.ink300, ), padding: EdgeInsets.zero, constraints: const BoxConstraints( @@ -1019,9 +1019,9 @@ class _PostOptionsSheet extends ConsumerWidget { }, ), ListTile( - leading: Icon(Icons.delete_outline, color: AppColors.coral500), + leading: Icon(Icons.delete_outline, color: AppColors.poppy), title: Text('Delete Post', - style: TextStyle(color: AppColors.coral500)), + style: TextStyle(color: AppColors.poppy)), onTap: () { Navigator.pop(context); _confirmDelete(context, ref, post); @@ -1089,7 +1089,7 @@ class _PostOptionsSheet extends ConsumerWidget { child: const Text('Cancel'), ), FilledButton( - style: FilledButton.styleFrom(backgroundColor: AppColors.coral500), + style: FilledButton.styleFrom(backgroundColor: AppColors.poppy), onPressed: () async { Navigator.pop(ctx); final activePet = ref.read(activePetControllerProvider); diff --git a/lib/features/social/presentation/screens/social_screen.dart b/lib/features/social/presentation/screens/social_screen.dart index d47d55e..2f60c74 100644 --- a/lib/features/social/presentation/screens/social_screen.dart +++ b/lib/features/social/presentation/screens/social_screen.dart @@ -375,7 +375,7 @@ class _StoriesRow extends ConsumerWidget { label: 'Your story', avatarUrl: pet.avatarUrl, ringColors: activePetStack.hasUnviewed(userId) - ? const [AppColors.sunset500, AppColors.coral500] + ? const [AppColors.tangerine, AppColors.poppy] : [pt.ink300, pt.ink300], onTap: () => context.push('/social/stories?petId=${pet.id}'), onLongPress: () => _showOwnStoryOptions(context, ref, pet), @@ -385,7 +385,7 @@ class _StoriesRow extends ConsumerWidget { initial: pet.name.isNotEmpty ? pet.name[0].toUpperCase() : '?', label: 'Your story', avatarUrl: pet.avatarUrl, - ringColors: const [AppColors.sunset500, AppColors.coral500], + ringColors: const [AppColors.tangerine, AppColors.poppy], isAdd: true, onTap: () { ref.read(createPostControllerProvider.notifier).setIsStory(true); @@ -403,7 +403,7 @@ class _StoriesRow extends ConsumerWidget { label: stack.petName, avatarUrl: stack.petAvatarUrl, ringColors: stack.hasUnviewed(userId) - ? const [AppColors.sunset500, AppColors.coral500] + ? const [AppColors.tangerine, AppColors.poppy] : [pt.ink300, pt.ink300], onTap: () => context.push('/social/stories?petId=${stack.petId}'), ), diff --git a/progress.md b/progress.md index 6a4b0bc..a771e7b 100644 --- a/progress.md +++ b/progress.md @@ -1,6 +1,37 @@ # Petfolio — Progress Log +## 2026-05-27 — Standardize State Management, GoRouter & Material 3 Accessibility + +- **StateNotifier migration**: Already completed in a prior session — no legacy providers found. +- **Nav color coupling fixed** (`router.dart`): Added `color` field directly to `_NavDestination`; removed separate `_tabColors` const. Reordering tabs can no longer silently misassign colors. +- **WCAG AA contrast** (`router.dart`, `app_theme.dart`): Unselected nav items and label text styles (labelLarge/Medium/Small) now use `ink700` (~4.5:1) instead of `ink500` (~2.8:1). +- **Tablet NavigationRail** (`router.dart`): Removed `indicatorColor: Colors.transparent` override; icons wrapped in `Semantics`; uses `destination.color` directly. +- **`GestureDetector` → `InkWell` + `Semantics`** (`router.dart`): Bottom nav tabs now have Material ripple feedback and screen-reader labels. +- **Spacing token `xxl=20` added** (`app_theme.dart`): `AppThemeSpacing.xxl` inserted between `lg` and `xl`. +- **Legacy color aliases removed** (`app_colors.dart`): Deleted 31-line alias block (`blue50`–`blue900D`, `sunset500`, `coral500`, `meadow500`, `apricot500`, `mulberry500`, `line100/200`). All 34 consumer files updated to canonical names. +- **Duplicate route removed** (`router.dart`): Deleted `/profile/orders/:id` GoRoute (duplicate of `/marketplace/orders/:id`). +- **`_PetEditMissingScreen` fallback fixed** (`router.dart`): "Back to Pets" now navigates to `/pets/manage` instead of `/home`. +- **`care_repository.dart` stub deleted**: Single caller (`care_dashboard_controller.dart`) updated to import `pet_care_repository.dart` directly. +- **Pet model import path consolidated**: 3 care files updated from `core/models/pet.dart` → `features/pet_profile/data/models/pet.dart`. +- `flutter analyze` → **No issues found**. +- **Next step**: UI screen redesigns (pending design approval). + +## 2026-05-27 — P0/P1 Backend Security & Data Integrity + +- **stripe-webhook redeployed (v7)**: Handles `payment_intent.succeeded` → transitions order to `processing/paid`, upserts vendor ledger. Also handles `payment_intent.payment_failed` and `account.updated` (Stripe Connect KYC). JWT verification disabled (Stripe HMAC only). +- **bank_account_details removed**: Dropped raw JSONB column from `shops`; replaced with `stripe_bank_account_token text`. 3 affected shops had raw bank data — purged. Vendors must re-submit via Stripe tokenisation flow. +- **follows NOT NULL enforced**: Added `NOT NULL` to `pet_follows.follower_pet_id/following_pet_id` and `follows.follower_id/following_id`. Zero NULL rows confirmed before migration. +- **vendor_ledgers now always populated**: Added `UNIQUE(order_id)` constraint; updated `process_checkout` RPC to INSERT ledger row at order creation (`pending_clearance`). Webhook upserts on payment confirmation — no double-counting. +- **products.inventory_count default removed**: Dropped `DEFAULT 0` — vendors must now explicitly supply stock count on product insertion. +- **cleanup-stories Edge Function deployed (v1)**: Calls `cleanup_expired_stories()` RPC. pg_cron unavailable on this project; schedule hourly via **Supabase Dashboard → Edge Functions → cleanup-stories → Schedule** (`0 * * * *`). + +New migrations: `20260527000000`, `20260527010000`, `20260527020000`, `20260527030000`. + +**Next step**: Phase complete — please run (/remember) to save tokens. Remaining P1: notifications bell (#6), bottom nav padding (#7), KYC rejection reason in seller dashboard (#8), `inventory_count > 0` filter in `fetchProducts` (#9). + +--- + ## AI AGENT HANDOVER & ARCHITECTURE GUIDE > [!IMPORTANT] diff --git a/supabase/functions/cleanup-stories/index.ts b/supabase/functions/cleanup-stories/index.ts new file mode 100644 index 0000000..77097aa --- /dev/null +++ b/supabase/functions/cleanup-stories/index.ts @@ -0,0 +1,37 @@ +// Supabase Edge Function — cleanup-stories +// +// Calls cleanup_expired_stories() to delete story rows older than 24 hours. +// Designed to be triggered by pg_cron on an hourly schedule, or manually +// via HTTP for testing / one-off runs. +// +// No Supabase JWT verification — this function is intended for internal +// invocation only (pg_cron HTTP call or admin curl). If exposed publicly, +// re-enable JWT verification and restrict to service_role. +// +// Deploy: npx supabase functions deploy cleanup-stories --no-verify-jwt + +import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'; + +serve(async (_req) => { + const admin = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '', + ); + + const { error } = await admin.rpc('cleanup_expired_stories'); + + if (error) { + console.error('cleanup_expired_stories failed:', error); + return new Response(JSON.stringify({ error: error.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } + + console.log('cleanup_expired_stories completed'); + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}); diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index 8434fd9..bcc1eab 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -222,45 +222,42 @@ serve(async (req) => { ); } - // Create the vendor ledger entry so the payout flow can proceed. - // Resolve the shop's platform fee to calculate the split. + // Upsert the vendor ledger entry. + // process_checkout already creates the row in 'pending_clearance'. + // ON CONFLICT: the upsert is a no-op for existing rows — amounts were + // set at order creation and should not be overwritten by a replayed event. const { data: shop } = await admin .from('shops') .select('platform_fee_percent') .eq('id', orderRow.shop_id) .maybeSingle(); - const feePercent = shop?.platform_fee_percent ?? 10; - const platformFeeCents = Math.floor((orderRow.amount_cents * feePercent) / 100); + const feePercent = shop?.platform_fee_percent ?? 10; + const platformFeeCents = Math.floor((orderRow.amount_cents * feePercent) / 100); const vendorEarningsCents = orderRow.amount_cents - platformFeeCents; const { error: ledgerErr } = await admin .from('vendor_ledgers') - .insert({ - shop_id: orderRow.shop_id, - order_id: orderId, - order_total_cents: orderRow.amount_cents, - platform_fee_cents: platformFeeCents, - vendor_earnings_cents: vendorEarningsCents, - status: 'pending_clearance', - }); + .upsert( + { + shop_id: orderRow.shop_id, + order_id: orderId, + order_total_cents: orderRow.amount_cents, + platform_fee_cents: platformFeeCents, + vendor_earnings_cents: vendorEarningsCents, + status: 'pending_clearance', + }, + { onConflict: 'order_id', ignoreDuplicates: false }, + ); if (ledgerErr) { - // Code 23505 = unique_violation: ledger already exists (replayed webhook). - // Not fatal — the order transition succeeded; just log and continue. - if ((ledgerErr as { code?: string }).code === '23505') { - console.warn( - `payment_intent.succeeded: ledger already exists for order ${orderId}`, - ); - } else { - console.error( - 'payment_intent.succeeded: ledger insert failed (non-fatal)', - ledgerErr, - ); - } + console.error( + 'payment_intent.succeeded: ledger upsert failed (non-fatal)', + ledgerErr, + ); } else { console.log( - `payment_intent.succeeded: ledger created for order ${orderId} ` + + `payment_intent.succeeded: ledger upserted for order ${orderId} ` + `(vendor +${vendorEarningsCents}¢, platform +${platformFeeCents}¢)`, ); } diff --git a/supabase/migrations/20260527000000_follows_not_null_constraints.sql b/supabase/migrations/20260527000000_follows_not_null_constraints.sql new file mode 100644 index 0000000..7a86697 --- /dev/null +++ b/supabase/migrations/20260527000000_follows_not_null_constraints.sql @@ -0,0 +1,11 @@ +-- Add NOT NULL constraints to follows FK columns. +-- Both tables have 0 NULL rows (confirmed via live-DB check 2026-05-27), +-- so the ALTER TABLE is safe and will not fail on existing data. + +ALTER TABLE public.pet_follows + ALTER COLUMN follower_pet_id SET NOT NULL, + ALTER COLUMN following_pet_id SET NOT NULL; + +ALTER TABLE public.follows + ALTER COLUMN follower_id SET NOT NULL, + ALTER COLUMN following_id SET NOT NULL; diff --git a/supabase/migrations/20260527010000_shops_bank_account_token.sql b/supabase/migrations/20260527010000_shops_bank_account_token.sql new file mode 100644 index 0000000..d6bec91 --- /dev/null +++ b/supabase/migrations/20260527010000_shops_bank_account_token.sql @@ -0,0 +1,11 @@ +-- Replace the plain JSONB bank_account_details column with a text column +-- that stores only a Stripe-issued bank account token (e.g. btok_...). +-- +-- Raw bank account details (account number, routing number) must never be +-- stored in plain JSONB — use Stripe's tokenisation API instead. +-- Existing JSONB data is intentionally dropped; affected vendors must +-- re-submit their payout info through the proper Stripe-tokenised flow. + +ALTER TABLE public.shops + DROP COLUMN IF EXISTS bank_account_details, + ADD COLUMN IF NOT EXISTS stripe_bank_account_token text; diff --git a/supabase/migrations/20260527020000_vendor_ledger_checkout.sql b/supabase/migrations/20260527020000_vendor_ledger_checkout.sql new file mode 100644 index 0000000..6ff98d5 --- /dev/null +++ b/supabase/migrations/20260527020000_vendor_ledger_checkout.sql @@ -0,0 +1,151 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- 1. Enforce uniqueness on vendor_ledgers.order_id +-- One order → at most one ledger entry. Required for the ON CONFLICT guard +-- in both process_checkout and the stripe-webhook handler. +-- ───────────────────────────────────────────────────────────────────────────── + +ALTER TABLE public.vendor_ledgers + ADD CONSTRAINT vendor_ledgers_order_id_key UNIQUE (order_id); + +-- ───────────────────────────────────────────────────────────────────────────── +-- 2. RLS: allow the service role (used by SECURITY DEFINER functions) to +-- INSERT into vendor_ledgers. +-- process_checkout and stripe-webhook both run as the postgres/service role, +-- which bypasses RLS — this policy is a defensive belt-and-suspenders grant +-- for any future SECURITY INVOKER refactor. +-- ───────────────────────────────────────────────────────────────────────────── + +DROP POLICY IF EXISTS "service_role_insert_ledger" ON public.vendor_ledgers; +CREATE POLICY "service_role_insert_ledger" ON public.vendor_ledgers + FOR INSERT TO service_role + WITH CHECK (true); + +-- ───────────────────────────────────────────────────────────────────────────── +-- 3. Updated process_checkout: adds ledger INSERT at the end of the transaction +-- so a vendor_ledgers row always exists for every order, even if the +-- stripe-webhook is delayed or misconfigured. +-- +-- The stripe-webhook uses ON CONFLICT DO NOTHING on the same order_id, +-- so there is no double-counting risk. +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.process_checkout( + p_buyer_id uuid, + p_shop_id uuid, + p_cart_items jsonb +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_order_id uuid; + v_amount_cents bigint := 0; + v_item jsonb; + v_product_id uuid; + v_quantity int; + v_line_total bigint; + v_inv_count int; + v_product_name text; + v_shop_active boolean; + v_shop_verified boolean; + v_platform_fee_pct integer; + v_platform_fee_cents bigint; + v_vendor_earnings bigint; +BEGIN + IF p_buyer_id IS DISTINCT FROM auth.uid() THEN + RAISE EXCEPTION 'buyer_id mismatch'; + END IF; + + -- ── 1. Validate shop ─────────────────────────────────────────────────────── + SELECT is_active, is_verified, platform_fee_percent + INTO v_shop_active, v_shop_verified, v_platform_fee_pct + FROM public.shops + WHERE id = p_shop_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Shop not found'; + END IF; + + IF NOT v_shop_active THEN + RAISE EXCEPTION 'SHOP_INACTIVE'; + END IF; + + IF NOT v_shop_verified THEN + RAISE EXCEPTION 'SHOP_NOT_VERIFIED'; + END IF; + + -- ── 2. Validate items & accumulate total ────────────────────────────────── + FOR v_item IN SELECT value FROM jsonb_array_elements(p_cart_items) + LOOP + v_product_id := (v_item->>'product_id')::uuid; + v_quantity := (v_item->>'quantity')::int; + v_line_total := (v_item->>'line_total_cents')::bigint; + + SELECT inventory_count, name + INTO v_inv_count, v_product_name + FROM public.products + WHERE id = v_product_id + AND shop_id = p_shop_id + AND active = true; + + IF NOT FOUND THEN + RAISE EXCEPTION 'PRODUCT_UNAVAILABLE:%', v_product_id; + END IF; + + IF v_inv_count < v_quantity THEN + RAISE EXCEPTION 'INSUFFICIENT_STOCK:%:%:%', v_product_name, v_inv_count, v_quantity; + END IF; + + v_amount_cents := v_amount_cents + v_line_total; + END LOOP; + + -- ── 3. Create the order ──────────────────────────────────────────────────── + INSERT INTO public.marketplace_orders ( + buyer_id, shop_id, title, status, + amount_cents, currency, line_items + ) + VALUES ( + p_buyer_id, p_shop_id, 'PetFolio Order', 'pending', + v_amount_cents, 'usd', p_cart_items + ) + RETURNING id INTO v_order_id; + + -- ── 4. Decrement inventory ───────────────────────────────────────────────── + FOR v_item IN SELECT value FROM jsonb_array_elements(p_cart_items) + LOOP + v_product_id := (v_item->>'product_id')::uuid; + v_quantity := (v_item->>'quantity')::int; + + UPDATE public.products + SET inventory_count = inventory_count - v_quantity + WHERE id = v_product_id + AND shop_id = p_shop_id; + END LOOP; + + -- ── 5. Create initial vendor ledger entry ───────────────────────────────── + -- Status starts as 'pending_clearance'; stripe-webhook updates it to + -- 'available' on payment_intent.succeeded. + -- ON CONFLICT DO NOTHING makes this idempotent if the webhook fires first. + v_platform_fee_cents := FLOOR(v_amount_cents * v_platform_fee_pct / 100.0)::bigint; + v_vendor_earnings := v_amount_cents - v_platform_fee_cents; + + INSERT INTO public.vendor_ledgers ( + shop_id, order_id, + order_total_cents, platform_fee_cents, vendor_earnings_cents, + status + ) + VALUES ( + p_shop_id, v_order_id, + v_amount_cents, v_platform_fee_cents, v_vendor_earnings, + 'pending_clearance' + ) + ON CONFLICT (order_id) DO NOTHING; + + RETURN v_order_id; +END; +$$; + +REVOKE ALL ON FUNCTION public.process_checkout(uuid, uuid, jsonb) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.process_checkout(uuid, uuid, jsonb) TO authenticated; diff --git a/supabase/migrations/20260527030000_products_inventory_and_stories_cron.sql b/supabase/migrations/20260527030000_products_inventory_and_stories_cron.sql new file mode 100644 index 0000000..11cabcf --- /dev/null +++ b/supabase/migrations/20260527030000_products_inventory_and_stories_cron.sql @@ -0,0 +1,29 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- 1. products.inventory_count — remove DEFAULT 0 +-- +-- Vendors must now explicitly supply inventory_count when listing a product. +-- The NOT NULL + CHECK (>= 0) constraints remain. Existing rows (including +-- the 8 seed products with inventory_count = 0) are unaffected. +-- ───────────────────────────────────────────────────────────────────────────── + +ALTER TABLE public.products + ALTER COLUMN inventory_count DROP DEFAULT; + +-- ───────────────────────────────────────────────────────────────────────────── +-- 2. Hourly pg_cron job: cleanup_expired_stories +-- +-- Requires the pg_cron extension to be enabled (pg_cron 1.6.4 confirmed). +-- Deletes story rows older than 24 hours every hour on the hour. +-- Idempotent: unschedule guards against duplicate registration on re-runs. +-- ───────────────────────────────────────────────────────────────────────────── + +SELECT cron.unschedule('cleanup-expired-stories') +WHERE EXISTS ( + SELECT 1 FROM cron.job WHERE jobname = 'cleanup-expired-stories' +); + +SELECT cron.schedule( + 'cleanup-expired-stories', + '0 * * * *', + 'SELECT public.cleanup_expired_stories()' +); From eeb1be0275da145d00fe1fedf1cfed9654ce6536 Mon Sep 17 00:00:00 2001 From: Syed Salman Reza Date: Wed, 27 May 2026 07:12:18 +0600 Subject: [PATCH 2/4] Add email verification flow and auth error routing Add a complete email confirmation UX and improve auth error handling: - New EmailVerificationScreen (/verify-email) with resend button, 60s cooldown, and spam hint. - AuthRepository.signUp now returns AuthResponse; added resendVerificationEmail(). Registration navigates to /verify-email when response.session == null (confirmation required). - Added isEmailVerifiedProvider and wired router: new route for /verify-email, router listens to email-verified state and enforces gating/redirects (unauthenticated users see only /login,/register,/verify-email; logged-in unverified users are sent to /verify-email; verified users are redirected away from auth screens). - Login & Registration UIs: field-level error routing (email/password inline errors), error-clearing on input, and password fields use TextInputType.visiblePassword. - AuthField: new errorText parameter to show server-side field errors overriding validator messages. - Updated progress.md to document the changes. --- lib/core/router.dart | 29 ++- .../data/repositories/auth_repository.dart | 5 +- .../controllers/auth_controller.dart | 14 ++ .../screens/email_verification_screen.dart | 181 ++++++++++++++++++ .../presentation/screens/login_screen.dart | 38 +++- .../screens/registration_screen.dart | 30 ++- .../presentation/widgets/auth_widgets.dart | 6 + progress.md | 10 + 8 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 lib/features/auth/presentation/screens/email_verification_screen.dart diff --git a/lib/core/router.dart b/lib/core/router.dart index 0e32668..fc9c31a 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -8,6 +8,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'theme/app_colors.dart'; import '../features/auth/presentation/controllers/auth_controller.dart'; +import '../features/auth/presentation/screens/email_verification_screen.dart'; import '../features/auth/presentation/screens/login_screen.dart'; import '../features/auth/presentation/screens/registration_screen.dart'; import '../features/care/presentation/screens/care_screen.dart'; @@ -102,6 +103,12 @@ final routerProvider = Provider((ref) { path: '/register', builder: (context, state) => const RegistrationScreen(), ), + GoRoute( + path: '/verify-email', + builder: (context, state) => EmailVerificationScreen( + email: state.uri.queryParameters['email'] ?? '', + ), + ), GoRoute( path: '/onboarding', builder: (context, state) { @@ -331,6 +338,9 @@ class _RouterNotifier extends ChangeNotifier { } notifyListeners(); }); + // Also re-evaluate when emailConfirmedAt changes (e.g. user confirms email + // while the app is open) without a sign-in/sign-out transition. + _ref.listen(isEmailVerifiedProvider, (_, _) => notifyListeners()); _ref.listen(petListProvider, (_, _) => notifyListeners()); } @@ -340,13 +350,24 @@ class _RouterNotifier extends ChangeNotifier { final isLoggedIn = _ref.read(isLoggedInProvider); final loc = state.matchedLocation; - // ── Not logged in → only /login and /register are allowed ──────── + // ── Not logged in → only public auth screens are allowed ───────── if (!isLoggedIn) { - return (loc == '/login' || loc == '/register') ? null : '/login'; + final isPublic = loc == '/login' || + loc == '/register' || + loc == '/verify-email'; + return isPublic ? null : '/login'; } - // ── Logged in on an auth screen → leave ───────────────────────── - if (loc == '/login' || loc == '/register') return '/home'; + // ── Logged in but email not yet confirmed → gate to /verify-email ─ + final isVerified = _ref.read(isEmailVerifiedProvider); + if (!isVerified) { + return loc == '/verify-email' ? null : '/verify-email'; + } + + // ── Logged in & verified — redirect away from auth screens ──────── + if (loc == '/login' || loc == '/register' || loc == '/verify-email') { + return '/home'; + } // ── Logged in but no pets → go to /onboarding ─────────────────── // Only redirect when the pet list has finished loading AND is empty, diff --git a/lib/features/auth/data/repositories/auth_repository.dart b/lib/features/auth/data/repositories/auth_repository.dart index 8635771..e32c4fe 100644 --- a/lib/features/auth/data/repositories/auth_repository.dart +++ b/lib/features/auth/data/repositories/auth_repository.dart @@ -13,11 +13,14 @@ class AuthRepository { Future signIn({required String email, required String password}) => _client.auth.signInWithPassword(email: email, password: password); - Future signUp({required String email, required String password}) => + Future signUp({required String email, required String password}) => _client.auth.signUp(email: email, password: password); Future signOut() => _client.auth.signOut(); Future resetPassword(String email) => _client.auth.resetPasswordForEmail(email.trim()); + + Future resendVerificationEmail(String email) => + _client.auth.resend(type: OtpType.signup, email: email.trim()); } diff --git a/lib/features/auth/presentation/controllers/auth_controller.dart b/lib/features/auth/presentation/controllers/auth_controller.dart index c08cb85..c1074f5 100644 --- a/lib/features/auth/presentation/controllers/auth_controller.dart +++ b/lib/features/auth/presentation/controllers/auth_controller.dart @@ -26,6 +26,20 @@ final isLoggedInProvider = Provider((ref) { ); }); +/// True when the current user's email has been confirmed. +/// With Supabase's default email-confirmation flow a session only exists after +/// confirmation, so this is almost always true for logged-in users. It guards +/// the edge case where a session exists but emailConfirmedAt is still null. +final isEmailVerifiedProvider = Provider((ref) { + final asyncState = ref.watch(authStateProvider); + return asyncState.when( + data: (s) => s.session?.user.emailConfirmedAt != null, + loading: () => + Supabase.instance.client.auth.currentUser?.emailConfirmedAt != null, + error: (_, _) => false, + ); +}); + // ───────────────────────────────────────────────────────────────────────────── // Password reset // ───────────────────────────────────────────────────────────────────────────── diff --git a/lib/features/auth/presentation/screens/email_verification_screen.dart b/lib/features/auth/presentation/screens/email_verification_screen.dart new file mode 100644 index 0000000..2332750 --- /dev/null +++ b/lib/features/auth/presentation/screens/email_verification_screen.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'package:petfolio/core/theme/theme.dart'; + +import '../controllers/auth_controller.dart'; +import '../widgets/auth_widgets.dart'; + +class EmailVerificationScreen extends ConsumerStatefulWidget { + const EmailVerificationScreen({super.key, required this.email}); + + final String email; + + @override + ConsumerState createState() => + _EmailVerificationScreenState(); +} + +class _EmailVerificationScreenState + extends ConsumerState { + static const _cooldownSeconds = 60; + + int _remaining = 0; + Timer? _timer; + bool _isResending = false; + String? _resendError; + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _startCooldown() { + setState(() => _remaining = _cooldownSeconds); + _timer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted || _remaining <= 1) { + t.cancel(); + if (mounted) setState(() => _remaining = 0); + } else { + setState(() => _remaining--); + } + }); + } + + Future _resend() async { + setState(() { + _isResending = true; + _resendError = null; + }); + try { + await ref + .read(authRepositoryProvider) + .resendVerificationEmail(widget.email); + if (mounted) _startCooldown(); + } on AuthException catch (e) { + if (mounted) setState(() => _resendError = e.message); + } catch (_) { + if (mounted) { + setState( + () => _resendError = 'Failed to resend. Please try again.'); + } + } finally { + if (mounted) setState(() => _isResending = false); + } + } + + @override + Widget build(BuildContext context) { + final pt = Theme.of(context).extension()!; + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + final canResend = _remaining == 0 && !_isResending; + + return Scaffold( + backgroundColor: pt.surface1, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 48), + const AuthBrand(), + const SizedBox(height: 40), + + AuthCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: cs.primaryContainer, + borderRadius: BorderRadius.circular( + PetfolioThemeExtension.radiusXl), + ), + child: Icon(Icons.mark_email_unread_outlined, + color: cs.primary, size: 28), + ), + ), + const SizedBox(height: 20), + + Text( + 'Check your email', + style: tt.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + + Text.rich( + TextSpan( + text: 'We sent a confirmation link to\n', + style: tt.bodyMedium?.copyWith(color: pt.ink500), + children: [ + TextSpan( + text: widget.email, + style: tt.bodyMedium?.copyWith( + color: cs.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + + if (_resendError != null) ...[ + AuthErrorBanner(message: _resendError!), + const SizedBox(height: 16), + ], + + OutlinedButton( + onPressed: canResend ? _resend : null, + child: Text( + _remaining > 0 + ? 'Resend in ${_remaining}s' + : _isResending + ? 'Sending…' + : 'Resend confirmation email', + ), + ), + const SizedBox(height: 16), + + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.surface2, + borderRadius: BorderRadius.circular( + PetfolioThemeExtension.radiusMd), + ), + child: Text( + "Didn't get it? Check your spam folder, then tap Resend.", + style: tt.bodySmall?.copyWith(color: pt.ink500), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + AuthToggleLink( + question: 'Already verified?', + actionLabel: 'Sign in', + onTap: () => context.go('/login'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart index 83b5433..7ac245d 100644 --- a/lib/features/auth/presentation/screens/login_screen.dart +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -164,6 +164,8 @@ class _LoginScreenState extends ConsumerState bool _obscurePassword = true; bool _isLoading = false; String? _error; + String? _emailError; + String? _passwordError; late final AnimationController _fadeCtrl; late final Animation _fadeAnim; @@ -178,6 +180,16 @@ class _LoginScreenState extends ConsumerState duration: PetfolioThemeExtension.durationMd, )..forward(); _fadeAnim = CurvedAnimation(parent: _fadeCtrl, curve: Curves.easeOut); + _emailController.addListener(_clearEmailError); + _passwordController.addListener(_clearPasswordError); + } + + void _clearEmailError() { + if (_emailError != null) setState(() => _emailError = null); + } + + void _clearPasswordError() { + if (_passwordError != null) setState(() => _passwordError = null); } @override @@ -201,6 +213,8 @@ class _LoginScreenState extends ConsumerState setState(() { _isLoading = true; _error = null; + _emailError = null; + _passwordError = null; }); try { @@ -210,16 +224,29 @@ class _LoginScreenState extends ConsumerState ); // GoRouter's authStateProvider listener triggers redirect to /home. } on AuthException catch (e) { - if (mounted) setState(() => _error = _friendlyAuthError(e.message)); + if (mounted) setState(() => _routeError(e.message)); } catch (e) { - if (mounted) { - setState(() => _error = _friendlyAuthError(e.toString())); - } + if (mounted) setState(() => _error = _friendlyAuthError(e.toString())); } finally { if (mounted) setState(() => _isLoading = false); } } + /// Routes the server error to the most relevant field, or falls back to the + /// generic banner for network / unclassified errors. + void _routeError(String raw) { + final lower = raw.toLowerCase(); + if (lower.contains('invalid login') || + lower.contains('invalid_grant') || + lower.contains('invalid credentials')) { + _passwordError = 'Incorrect email or password.'; + } else if (lower.contains('email not confirmed')) { + _emailError = 'Please verify your email before signing in.'; + } else { + _error = _friendlyAuthError(raw); + } + } + /// Maps verbose Supabase / Dart network errors to a short user-facing /// message. Supabase wraps `ClientException` /`SocketException` inside /// `AuthRetryableFetchException extends AuthException`, so the raw @@ -290,6 +317,7 @@ class _LoginScreenState extends ConsumerState keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, autocorrect: false, + errorText: _emailError, validator: (v) { if (v == null || v.trim().isEmpty) { return 'Email is required'; @@ -306,9 +334,11 @@ class _LoginScreenState extends ConsumerState AuthField( controller: _passwordController, label: 'Password', + keyboardType: TextInputType.visiblePassword, obscureText: _obscurePassword, textInputAction: TextInputAction.done, onSubmitted: (_) => _submit(), + errorText: _passwordError, suffixIcon: VisibilityToggle( obscure: _obscurePassword, onTap: () => setState( diff --git a/lib/features/auth/presentation/screens/registration_screen.dart b/lib/features/auth/presentation/screens/registration_screen.dart index e22ae2d..6356881 100644 --- a/lib/features/auth/presentation/screens/registration_screen.dart +++ b/lib/features/auth/presentation/screens/registration_screen.dart @@ -27,6 +27,7 @@ class _RegistrationScreenState extends ConsumerState bool _obscureConfirm = true; bool _isLoading = false; String? _error; + String? _emailError; late final AnimationController _fadeCtrl; late final Animation _fadeAnim; @@ -41,6 +42,11 @@ class _RegistrationScreenState extends ConsumerState duration: PetfolioThemeExtension.durationMd, )..forward(); _fadeAnim = CurvedAnimation(parent: _fadeCtrl, curve: Curves.easeOut); + _emailController.addListener(_clearEmailError); + } + + void _clearEmailError() { + if (_emailError != null) setState(() => _emailError = null); } @override @@ -58,16 +64,31 @@ class _RegistrationScreenState extends ConsumerState setState(() { _isLoading = true; _error = null; + _emailError = null; }); try { - await ref.read(authRepositoryProvider).signUp( + final response = await ref.read(authRepositoryProvider).signUp( email: _emailController.text.trim(), password: _passwordController.text, ); - // GoRouter redirects to /home or /onboarding after auth state changes. + if (!mounted) return; + if (response.session == null) { + // Email confirmation required — navigate to the verification gate. + final email = Uri.encodeComponent(_emailController.text.trim()); + context.go('/verify-email?email=$email'); + } + // If session != null (auto-confirm enabled), GoRouter handles redirect. } on AuthException catch (e) { - if (mounted) setState(() => _error = e.message); + if (!mounted) return; + final lower = e.message.toLowerCase(); + if (lower.contains('already registered') || + lower.contains('already in use') || + lower.contains('user already exists')) { + setState(() => _emailError = 'An account with this email already exists.'); + } else { + setState(() => _error = e.message); + } } catch (_) { if (mounted) { setState(() => _error = 'Something went wrong. Please try again.'); @@ -120,6 +141,7 @@ class _RegistrationScreenState extends ConsumerState keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, autocorrect: false, + errorText: _emailError, validator: (v) { if (v == null || v.trim().isEmpty) { return 'Email is required'; @@ -136,6 +158,7 @@ class _RegistrationScreenState extends ConsumerState AuthField( controller: _passwordController, label: 'Password', + keyboardType: TextInputType.visiblePassword, obscureText: _obscurePassword, textInputAction: TextInputAction.next, suffixIcon: VisibilityToggle( @@ -160,6 +183,7 @@ class _RegistrationScreenState extends ConsumerState AuthField( controller: _confirmController, label: 'Confirm password', + keyboardType: TextInputType.visiblePassword, obscureText: _obscureConfirm, textInputAction: TextInputAction.done, onSubmitted: (_) => _submit(), diff --git a/lib/features/auth/presentation/widgets/auth_widgets.dart b/lib/features/auth/presentation/widgets/auth_widgets.dart index 43441ec..c9a8f09 100644 --- a/lib/features/auth/presentation/widgets/auth_widgets.dart +++ b/lib/features/auth/presentation/widgets/auth_widgets.dart @@ -100,6 +100,7 @@ class AuthField extends StatefulWidget { this.suffixIcon, this.autocorrect = true, this.validator, + this.errorText, }); final TextEditingController controller; @@ -111,6 +112,8 @@ class AuthField extends StatefulWidget { final Widget? suffixIcon; final bool autocorrect; final FormFieldValidator? validator; + /// Server-side error displayed below the field, overriding any validator message. + final String? errorText; @override State createState() => _AuthFieldState(); @@ -210,6 +213,9 @@ class _AuthFieldState extends State { const EdgeInsets.symmetric(horizontal: 16, vertical: 18), suffixIcon: widget.suffixIcon, // ── Inline validation errors ────────────────────────────────────── + // errorText (server error) takes precedence over the validator message + // when non-null; the field still validates on form submission. + errorText: widget.errorText, errorStyle: TextStyle( color: cs.error, fontSize: 12, diff --git a/progress.md b/progress.md index a771e7b..4d22ccc 100644 --- a/progress.md +++ b/progress.md @@ -1,6 +1,16 @@ # Petfolio — Progress Log +## 2026-05-27 — Auth UX & Security Enhancements + +- **Keyboard types**: `TextInputType.visiblePassword` added to all password fields (login + registration × 2). Email fields already had `TextInputType.emailAddress`. +- **Show/hide toggle**: Already present; no change needed. +- **Field-level error routing** (login): `_emailError` / `_passwordError` state vars replace the catch-all `AuthErrorBanner` for classified server errors. "Invalid credentials" → password field inline error. "Email not confirmed" → email field inline error. Network/generic errors still fall through to the banner. Errors auto-clear when the user types in the relevant field. `AuthField` gained an `errorText` parameter for this. +- **Field-level error routing** (registration): "Email already registered" routes to `_emailError` on the email field. Other errors use the banner. +- **Email verification gate**: `EmailVerificationScreen` (`/verify-email`) created — shows email, resend button (60 s cooldown), spam hint. `AuthRepository.signUp` now returns `AuthResponse`; if `session == null` (confirmation required), registration navigates to `/verify-email?email=...`. `isEmailVerifiedProvider` added to `auth_controller.dart`. `_RouterNotifier` now listens to `isEmailVerifiedProvider` and blocks unverified logged-in users at `/verify-email`. `/login`, `/register`, `/verify-email` are the only routes accessible to unauthenticated users. +- `flutter analyze` → **No issues found**. +- **Next step**: UI screen redesigns (pending design approval). + ## 2026-05-27 — Standardize State Management, GoRouter & Material 3 Accessibility - **StateNotifier migration**: Already completed in a prior session — no legacy providers found. From 57124f6da5f60bfe29289160c3c9a36bc09fb5b3 Mon Sep 17 00:00:00 2001 From: Syed Salman Reza Date: Wed, 27 May 2026 07:21:40 +0600 Subject: [PATCH 3/4] Fix bottom inset handling and add pull-to-refresh Replace hardcoded bottom paddings with dynamic insets and add pull-to-refresh on pet profile. Files changed: - lib/features/care/presentation/screens/care_screen.dart: ListView bottom padding changed from a hardcoded 120 to MediaQuery.paddingOf(context).bottom + 80 to avoid nav clipping. - lib/features/marketplace/presentation/screens/marketplace_screen.dart: SliverPadding bottom changed from 100 to MediaQuery.paddingOf(context).bottom + 80. - lib/features/social/presentation/screens/social_screen.dart: Footer Padding bottom changed from 120 to MediaQuery.paddingOf(context).bottom + 80. - lib/features/pet_profile/presentation/screens/pet_profile_screen.dart: Added AlwaysScrollableScrollPhysics to the CustomScrollView, wrapped the scroll view with RefreshIndicator.adaptive to invalidate relevant providers (petListProvider, careDashboardProvider, petAwardsSummaryProvider) and await petListProvider.future; replaced fixed SizedBox(100) with dynamic bottom inset (MediaQuery.paddingOf(context).bottom + 80); renamed local variable `view` to `scrollView` for clarity. - progress.md: Updated changelog entry describing these fixes. These changes ensure content is not clipped by the bottom navigation area on devices with system insets and enable pull-to-refresh on the pet profile screen. --- .../care/presentation/screens/care_screen.dart | 2 +- .../presentation/screens/marketplace_screen.dart | 2 +- .../presentation/screens/pet_profile_screen.dart | 15 +++++++++++++-- .../presentation/screens/social_screen.dart | 2 +- progress.md | 7 +++++++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/features/care/presentation/screens/care_screen.dart b/lib/features/care/presentation/screens/care_screen.dart index 0db400e..691228b 100644 --- a/lib/features/care/presentation/screens/care_screen.dart +++ b/lib/features/care/presentation/screens/care_screen.dart @@ -190,7 +190,7 @@ class _CareScreenState extends ConsumerState { builder: (context, constraints) { final wide = constraints.maxWidth >= 600; final list = ListView( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 120), + padding: EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.paddingOf(context).bottom + 80), children: [ CareGamifiedHeader( activePet: activePet, diff --git a/lib/features/marketplace/presentation/screens/marketplace_screen.dart b/lib/features/marketplace/presentation/screens/marketplace_screen.dart index f67efdc..4f38867 100644 --- a/lib/features/marketplace/presentation/screens/marketplace_screen.dart +++ b/lib/features/marketplace/presentation/screens/marketplace_screen.dart @@ -490,7 +490,7 @@ class _ShopBody extends ConsumerWidget { ), SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + padding: EdgeInsets.fromLTRB(16, 12, 16, MediaQuery.paddingOf(context).bottom + 80), sliver: SliverGrid.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: MediaQuery.sizeOf(context).width >= ResponsiveLayout.tabletMax diff --git a/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart b/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart index f1547d6..6c91e16 100644 --- a/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart +++ b/lib/features/pet_profile/presentation/screens/pet_profile_screen.dart @@ -59,7 +59,8 @@ class PetProfileScreen extends ConsumerWidget { final screenWidth = MediaQuery.sizeOf(context).width; final isWide = screenWidth >= ResponsiveLayout.mobileMax; - final view = CustomScrollView( + final scrollView = CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), slivers: [ SliverToBoxAdapter( child: _HeroGamifiedBanner(pet: activePet), @@ -128,7 +129,7 @@ class PetProfileScreen extends ConsumerWidget { ), _RecentAchievementsRow(), - const SizedBox(height: 100), + SizedBox(height: MediaQuery.paddingOf(context).bottom + 80), ], ), ), @@ -136,6 +137,16 @@ class PetProfileScreen extends ConsumerWidget { ], ); + final view = RefreshIndicator.adaptive( + onRefresh: () async { + ref.invalidate(petListProvider); + ref.invalidate(careDashboardProvider); + ref.invalidate(petAwardsSummaryProvider); + await ref.read(petListProvider.future).catchError((_) => []); + }, + child: scrollView, + ); + return Scaffold( backgroundColor: pt.surface1, body: isWide diff --git a/lib/features/social/presentation/screens/social_screen.dart b/lib/features/social/presentation/screens/social_screen.dart index 2f60c74..d607da4 100644 --- a/lib/features/social/presentation/screens/social_screen.dart +++ b/lib/features/social/presentation/screens/social_screen.dart @@ -216,7 +216,7 @@ class _SocialViewState extends ConsumerState<_SocialView> { ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), + padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.paddingOf(context).bottom + 80), child: Center( child: Text( "You're all caught up 🐾", diff --git a/progress.md b/progress.md index 4d22ccc..5626cae 100644 --- a/progress.md +++ b/progress.md @@ -1,6 +1,13 @@ # Petfolio — Progress Log +## 2026-05-27 — Profile UI Rendering Fixes + +- **Bottom nav clipping fixed** (all 5 tab screens): Replaced hardcoded pixel values with `MediaQuery.paddingOf(context).bottom + 80` (nav height 68 + bottom margin 12 = 80 logical pixels above system inset). `matching_screen` was already correct. Changes: `pet_profile_screen` SizedBox 100→dynamic, `care_screen` ListView padding 120→dynamic, `social_screen` footer Padding 120→dynamic, `marketplace_screen` SliverPadding 100→dynamic. +- **`PetProfileScreen` `RefreshIndicator`**: Wrapped `CustomScrollView` with `RefreshIndicator.adaptive`. Pull-to-refresh invalidates `petListProvider`, `careDashboardProvider`, and `petAwardsSummaryProvider`, then awaits `petListProvider.future`. Added `AlwaysScrollableScrollPhysics` to `CustomScrollView` so pull gesture fires even when content is short. +- `flutter analyze` → **No issues found**. +- **Next step**: UI screen redesigns (pending design approval). + ## 2026-05-27 — Auth UX & Security Enhancements - **Keyboard types**: `TextInputType.visiblePassword` added to all password fields (login + registration × 2). Email fields already had `TextInputType.emailAddress`. From a4db4731ce1bebd5be08b3bc184adaf932b0c481 Mon Sep 17 00:00:00 2001 From: Syed Salman Reza Date: Wed, 27 May 2026 07:39:40 +0600 Subject: [PATCH 4/4] Refactor care screen and add AI routine flow Introduce modular care widgets and move AI routine generation into the controller. - Add new widgets: care_routine_generator_button, care_task_form_sheet, care_task_list (moved/extracted from care_screen). - Update CareDashboard controller: add isGeneratingRoutine flag on DailyRoutineState and a generateRoutine(Pet) method that uses CareRecommendationService and returns generated CareTask list. - Simplify care_screen: remove many internal widgets (AI banner, task cards, form sheet, date picker, etc.) and wire up the new widgets and controller method; clean up imports and minor layout/formatting tweaks. - Update progress.md. This refactor modularizes the care UI and centralizes routine generation logic in the provider for better testability and reuse. --- .../care_dashboard_controller.dart | 22 + .../presentation/screens/care_screen.dart | 1620 +---------------- .../screens/nutrition_screen.dart | 8 +- .../care_routine_generator_button.dart | 107 ++ .../widgets/care_task_form_sheet.dart | 504 +++++ .../presentation/widgets/care_task_list.dart | 850 +++++++++ progress.md | 9 + 7 files changed, 1542 insertions(+), 1578 deletions(-) create mode 100644 lib/features/care/presentation/widgets/care_routine_generator_button.dart create mode 100644 lib/features/care/presentation/widgets/care_task_form_sheet.dart create mode 100644 lib/features/care/presentation/widgets/care_task_list.dart diff --git a/lib/features/care/presentation/controllers/care_dashboard_controller.dart b/lib/features/care/presentation/controllers/care_dashboard_controller.dart index 1bc22cb..126bac8 100644 --- a/lib/features/care/presentation/controllers/care_dashboard_controller.dart +++ b/lib/features/care/presentation/controllers/care_dashboard_controller.dart @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../pet_profile/data/models/pet.dart' show Pet; import '../../../pet_profile/presentation/controllers/active_pet_controller.dart'; import '../../data/models/care_streak.dart'; import '../../data/models/care_task.dart'; import '../../data/repositories/pet_care_repository.dart'; +import '../../domain/services/care_recommendation_service.dart'; import 'care_streak_stream_provider.dart'; part 'care_dashboard_controller.g.dart'; @@ -18,6 +20,7 @@ class DailyRoutineState { required this.streak, required this.weekGoalHit, required this.badgeTypes, + this.isGeneratingRoutine = false, }); final DateTime selectedDate; @@ -26,6 +29,7 @@ class DailyRoutineState { final AsyncValue streak; final AsyncValue> weekGoalHit; final Set badgeTypes; + final bool isGeneratingRoutine; DailyRoutineState copyWith({ DateTime? selectedDate, @@ -34,6 +38,7 @@ class DailyRoutineState { AsyncValue? streak, AsyncValue>? weekGoalHit, Set? badgeTypes, + bool? isGeneratingRoutine, }) => DailyRoutineState( selectedDate: selectedDate ?? this.selectedDate, @@ -42,6 +47,7 @@ class DailyRoutineState { streak: streak ?? this.streak, weekGoalHit: weekGoalHit ?? this.weekGoalHit, badgeTypes: badgeTypes ?? this.badgeTypes, + isGeneratingRoutine: isGeneratingRoutine ?? this.isGeneratingRoutine, ); } @@ -199,6 +205,22 @@ class CareDashboard extends _$CareDashboard { await _load(petId, _routine.selectedDate); } + Future?> generateRoutine(Pet activePet) async { + _routine = _routine.copyWith(isGeneratingRoutine: true); + state = _routine; + try { + final service = CareRecommendationService(); + final tasks = await service.generateRecommendations(activePet); + _routine = _routine.copyWith(isGeneratingRoutine: false); + state = _routine; + return tasks; + } catch (_) { + _routine = _routine.copyWith(isGeneratingRoutine: false); + state = _routine; + rethrow; + } + } + Future createTask(CareTask task) async { final petId = ref.read(activePetIdProvider); if (petId == null) return; diff --git a/lib/features/care/presentation/screens/care_screen.dart b/lib/features/care/presentation/screens/care_screen.dart index 691228b..8eb29e1 100644 --- a/lib/features/care/presentation/screens/care_screen.dart +++ b/lib/features/care/presentation/screens/care_screen.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -10,16 +8,14 @@ import 'package:petfolio/features/pet_profile/presentation/controllers/active_pe import 'package:petfolio/features/pet_profile/presentation/controllers/pet_list_controller.dart'; import 'package:petfolio/features/pet_profile/presentation/widgets/pet_switcher_sheet.dart'; -import 'package:petfolio/core/errors/app_exception.dart'; import 'package:petfolio/features/pet_profile/data/models/pet.dart' show Pet; -import 'package:petfolio/features/care/data/models/care_task.dart' as dbtask; -import 'package:petfolio/features/care/data/models/care_task_log.dart'; import 'package:petfolio/features/care/presentation/controllers/care_dashboard_controller.dart'; -import 'package:petfolio/features/care/presentation/utils/care_scheduled_time.dart'; -import 'package:petfolio/features/care/presentation/widgets/routine_recommendation_sheet.dart'; -import 'package:petfolio/features/care/domain/services/care_recommendation_service.dart'; +import 'package:petfolio/features/care/presentation/widgets/care_routine_generator_button.dart'; +import 'package:petfolio/features/care/presentation/widgets/care_task_form_sheet.dart'; +import 'package:petfolio/features/care/presentation/widgets/care_task_list.dart'; import 'package:petfolio/features/care/presentation/widgets/gamified_care_ui.dart'; +import 'package:petfolio/features/care/presentation/widgets/routine_recommendation_sheet.dart'; // ───────────────────────────────────────────────────────────────────────────── // CareScreen @@ -34,15 +30,6 @@ class CareScreen extends ConsumerStatefulWidget { class _CareScreenState extends ConsumerState { bool _onboardingSuccessHandled = false; - bool _isGeneratingRoutine = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - Future.microtask(() => _init()); - }); - } @override void didChangeDependencies() { @@ -61,41 +48,26 @@ class _CareScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating), ); - if (!mounted) return; - if (GoRouterState.of(context).uri.queryParameters['onboardingComplete'] == '1') { - context.go('/care'); - } + // Intentionally NOT calling context.go('/care') here — it would dismiss + // the SnackBar before the user can read it. The flag above prevents replay. }); } - Future _init() async { - // careDashboardProvider auto-loads in its build() via Future.microtask. - } - Future _generateRoutine(Pet activePet) async { - setState(() => _isGeneratingRoutine = true); final hasTasks = ref.read(careDashboardProvider).tasks.value?.isNotEmpty == true; - List? tasks; - Object? caught; try { - final service = CareRecommendationService(); - tasks = await service.generateRecommendations(activePet); + final tasks = await ref + .read(careDashboardProvider.notifier) + .generateRoutine(activePet); + if (!mounted || tasks == null) return; + RoutineRecommendationSheet.show(context, activePet, tasks, + isRefresh: hasTasks); } catch (e) { - caught = e; - } finally { - if (mounted) setState(() => _isGeneratingRoutine = false); - } - if (!mounted) return; - if (caught != null) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to generate routine: $caught')), + SnackBar(content: Text('Failed to generate routine: $e')), ); - return; - } - if (tasks != null) { - RoutineRecommendationSheet.show(context, activePet, tasks, - isRefresh: hasTasks); } } @@ -115,7 +87,8 @@ class _CareScreenState extends ConsumerState { children: [ Icon(Icons.wifi_off_rounded, size: 48, color: pt.ink300), const SizedBox(height: 12), - Text('Could not load pets', style: TextStyle(fontSize: 15, color: pt.ink500)), + Text('Could not load pets', + style: TextStyle(fontSize: 15, color: pt.ink500)), const SizedBox(height: 16), FilledButton.icon( onPressed: () => ref.invalidate(petListProvider), @@ -136,22 +109,17 @@ class _CareScreenState extends ConsumerState { ) : const TailWagLoader(), ); - return Scaffold(backgroundColor: pt.surface1, body: Center(child: body)); + return Scaffold( + backgroundColor: pt.surface1, body: Center(child: body)); } final dashboard = ref.watch(careDashboardProvider); final species = activePet.speciesEnum; - void openAddSheet() => showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (_) => _CareTaskFormSheet( - petId: activePet.id, - petName: activePet.name, - ), + void openAddSheet() => CareTaskFormSheet.show( + context, + petId: activePet.id, + petName: activePet.name, ); return Scaffold( @@ -180,8 +148,7 @@ class _CareScreenState extends ConsumerState { tooltip: themeMode == ThemeMode.dark ? 'Switch to light theme' : 'Switch to dark theme', - onTap: () => - ref.read(themeProvider.notifier).toggleTheme(), + onTap: () => ref.read(themeProvider.notifier).toggleTheme(), ), ], ), @@ -190,7 +157,8 @@ class _CareScreenState extends ConsumerState { builder: (context, constraints) { final wide = constraints.maxWidth >= 600; final list = ListView( - padding: EdgeInsets.fromLTRB(0, 0, 0, MediaQuery.paddingOf(context).bottom + 80), + padding: EdgeInsets.fromLTRB( + 0, 0, 0, MediaQuery.paddingOf(context).bottom + 80), children: [ CareGamifiedHeader( activePet: activePet, @@ -220,7 +188,8 @@ class _CareScreenState extends ConsumerState { const CareGamifiedTrophyRoom(), const SizedBox(height: 32), Padding( - padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + padding: + const EdgeInsets.fromLTRB(4, 0, 4, 8), child: Row( children: [ Text( @@ -233,17 +202,18 @@ class _CareScreenState extends ConsumerState { ), ), const Spacer(), - _DoneCounter(tasks: dashboard.tasks.value ?? []), + CareTaskDoneCounter( + tasks: dashboard.tasks.value ?? []), ], ), ), - _AiRoutineBanner( - activePetId: activePet.id, - hasNoTasks: dashboard.tasks.value?.isEmpty == true, - isGenerating: _isGeneratingRoutine, + CareRoutineGeneratorButton( + hasNoTasks: + dashboard.tasks.value?.isEmpty == true, + isGenerating: dashboard.isGeneratingRoutine, onTap: () => _generateRoutine(activePet), ), - _DailyTasksDashboard( + CareTaskList( state: dashboard, petId: activePet.id, petName: activePet.name, @@ -257,9 +227,15 @@ class _CareScreenState extends ConsumerState { ), CareGamifiedWeeklyChart( selectedDay: dashboard.selectedDate, - weekHits: dashboard.weekGoalHit.value ?? List.filled(7, false), - progressPercent: (dashboard.tasks.value != null && dashboard.tasks.value!.isNotEmpty) - ? (dashboard.tasks.value!.where((t) => t.isCompleted).length / dashboard.tasks.value!.length) + weekHits: dashboard.weekGoalHit.value ?? + List.filled(7, false), + progressPercent: (dashboard.tasks.value != + null && + dashboard.tasks.value!.isNotEmpty) + ? (dashboard.tasks.value! + .where((t) => t.isCompleted) + .length / + dashboard.tasks.value!.length) : 0.0, ), const SizedBox(height: 32), @@ -289,1112 +265,6 @@ class _CareScreenState extends ConsumerState { } } -// ───────────────────────────────────────────────────────────────────────────── -// AI Routine Banner -// ───────────────────────────────────────────────────────────────────────────── - -class _AiRoutineBanner extends StatelessWidget { - const _AiRoutineBanner({ - required this.activePetId, - required this.hasNoTasks, - required this.isGenerating, - required this.onTap, - }); - - final String activePetId; - final bool hasNoTasks; - final bool isGenerating; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - if (!hasNoTasks) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: OutlinedButton.icon( - onPressed: isGenerating ? null : onTap, - icon: isGenerating - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.lilac, - ), - ) - : const Icon(Icons.auto_awesome, size: 16, color: AppColors.lilac), - label: Text(isGenerating ? 'Generating…' : 'Refresh AI Routine'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.lilac, - side: const BorderSide(color: AppColors.lilac), - minimumSize: const Size.fromHeight(44), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14)), - ), - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: InkWell( - onTap: isGenerating ? null : onTap, - borderRadius: BorderRadius.circular(16), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.lilacSoft, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.lilac.withAlpha(60)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: const BoxDecoration( - color: AppColors.lilac, - shape: BoxShape.circle, - ), - child: isGenerating - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Icon(Icons.auto_awesome, color: Colors.white, size: 20), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isGenerating ? 'Generating...' : 'Generate AI Routine', - style: const TextStyle( - fontWeight: FontWeight.w700, - color: AppColors.lilac700, - fontSize: 15, - ), - ), - const SizedBox(height: 2), - Text( - isGenerating - ? 'Building personalized care plan...' - : 'Get daily, weekly & monthly tasks tailored for your pet', - style: const TextStyle( - color: AppColors.lilac700, - fontSize: 13, - ), - ), - ], - ), - ), - if (!isGenerating) - const Icon(Icons.chevron_right, color: AppColors.lilac), - ], - ), - ), - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Streak Banner -// ───────────────────────────────────────────────────────────────────────────── - -class _HorizontalDatePicker extends StatefulWidget { - const _HorizontalDatePicker({ - required this.selectedDate, - required this.onDateSelected, - }); - - final DateTime selectedDate; - final ValueChanged onDateSelected; - - @override - State<_HorizontalDatePicker> createState() => _HorizontalDatePickerState(); -} - -class _HorizontalDatePickerState extends State<_HorizontalDatePicker> { - late final ScrollController _scroll; - - static const _chipW = 52.0; - static const _chipGap = 8.0; - static const _daysBack = 7; - static const _daysAhead = 6; - static const _totalDays = _daysBack + 1 + _daysAhead; - - @override - void initState() { - super.initState(); - _scroll = ScrollController(); - WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToToday()); - } - - void _scrollToToday() { - if (!_scroll.hasClients) return; - final screenW = context.size?.width ?? 360; - final todayOffset = - _daysBack * (_chipW + _chipGap) - (screenW / 2 - _chipW / 2) + 16; - _scroll.jumpTo(todayOffset.clamp(0.0, _scroll.position.maxScrollExtent)); - } - - @override - void dispose() { - _scroll.dispose(); - super.dispose(); - } - - static const _dayLetters = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; - - @override - Widget build(BuildContext context) { - final pt = Theme.of(context).extension()!; - final cs = Theme.of(context).colorScheme; - final today = DateUtils.dateOnly(DateTime.now()); - - return SizedBox( - height: 76, - child: ListView.builder( - controller: _scroll, - scrollDirection: Axis.horizontal, - padding: EdgeInsets.zero, - itemCount: _totalDays, - itemBuilder: (context, i) { - final date = today.subtract(Duration(days: _daysBack - i)); - final isSelected = DateUtils.dateOnly(widget.selectedDate) == date; - final isToday = date == today; - final isFuture = date.isAfter(today); - - final ymd = - '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - return Padding( - padding: EdgeInsets.only(right: i < _totalDays - 1 ? _chipGap : 0), - child: GestureDetector( - key: ValueKey('care_date_$ymd'), - onTap: () => widget.onDateSelected(date), - child: AnimatedContainer( - duration: PetfolioThemeExtension.durationSm, - width: _chipW, - decoration: BoxDecoration( - color: isSelected - ? cs.primary - : (isToday ? cs.primary.withAlpha(15) : pt.surface2), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: isSelected - ? Colors.transparent - : (isToday ? cs.primary.withAlpha(80) : pt.line), - width: isToday && !isSelected ? 1.5 : 0.5, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - _dayLetters[date.weekday - 1], - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: isSelected - ? Colors.white.withAlpha(200) - : (isFuture ? pt.ink300 : pt.ink500), - ), - ), - const SizedBox(height: 4), - Text( - '${date.day}', - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 18, - height: 1, - color: isSelected - ? Colors.white - : (isToday - ? cs.primary - : (isFuture ? pt.ink300 : cs.onSurface)), - ), - ), - if (isToday && !isSelected) ...[ - const SizedBox(height: 4), - Container( - width: 4, - height: 4, - decoration: BoxDecoration(color: cs.primary, shape: BoxShape.circle), - ), - ], - ], - ), - ), - ), - ); - }, - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Daily Tasks Dashboard -// ───────────────────────────────────────────────────────────────────────────── - -class _DailyTasksDashboard extends ConsumerWidget { - const _DailyTasksDashboard({ - required this.state, - required this.petId, - required this.petName, - required this.species, - this.onAddTask, - }); - - final DailyRoutineState state; - final String petId; - final String petName; - final PetSpecies species; - final VoidCallback? onAddTask; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return state.tasks.when( - loading: () => const Column( - children: [ - _TaskCardSkeleton(), - _TaskCardSkeleton(), - _TaskCardSkeleton(), - ], - ), - error: (err, st) => _CareErrorCard( - error: err, - onRetry: () => ref.read(careDashboardProvider.notifier).refresh(), - ), - data: (tasks) => tasks.isEmpty - ? _EmptyRoutineState(petName: petName, date: state.selectedDate, onAddTask: onAddTask) - : Column( - children: tasks - .map( - (t) => _CareTaskCard( - task: t, - petId: petId, - petName: petName, - species: species, - ), - ) - .toList(), - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Done Counter -// ───────────────────────────────────────────────────────────────────────────── - -class _DoneCounter extends StatelessWidget { - const _DoneCounter({required this.tasks}); - final List tasks; - - @override - Widget build(BuildContext context) { - if (tasks.isEmpty) return const SizedBox.shrink(); - final done = tasks.where((t) => t.isCompleted).length; - final total = tasks.length; - final allDone = done == total; - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: Text( - key: ValueKey('$done/$total'), - allDone ? 'All done! 🎉' : '$done/$total done', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - color: allDone ? AppColors.mint700 : AppColors.sunny700, - ), - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Care Task Card — animated card matching JSX TaskRow design -// ───────────────────────────────────────────────────────────────────────────── - -class _CareTaskCard extends ConsumerStatefulWidget { - const _CareTaskCard({ - required this.task, - required this.petId, - required this.petName, - required this.species, - }); - - final dbtask.CareTask task; - final String petId; - final String petName; - final PetSpecies species; - - @override - ConsumerState<_CareTaskCard> createState() => _CareTaskCardState(); -} - -class _CareTaskCardState extends ConsumerState<_CareTaskCard> - with SingleTickerProviderStateMixin { - late AnimationController _xpCtrl; - bool _showBurst = false; - - @override - void initState() { - super.initState(); - _xpCtrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1100), - )..addStatusListener((s) { - if (s == AnimationStatus.completed && mounted) { - setState(() => _showBurst = false); - _xpCtrl.reset(); - } - }); - } - - @override - void dispose() { - _xpCtrl.dispose(); - super.dispose(); - } - - void _toggle() { - final nowDone = !widget.task.isCompleted; - ref.read(careDashboardProvider.notifier) - .toggleTaskCompletion(widget.task.id, isCompleted: nowDone); - if (nowDone && widget.task.gamificationPoints > 0) { - setState(() => _showBurst = true); - _xpCtrl.forward(from: 0); - } - } - - Color get _color { - switch (widget.task.taskType) { - case dbtask.CareTaskType.feeding: return AppColors.tangerine; - case dbtask.CareTaskType.medication: return AppColors.poppy; - case dbtask.CareTaskType.walk: return AppColors.mint; - case dbtask.CareTaskType.playtime: return AppColors.sunny; - case dbtask.CareTaskType.dental: return AppColors.lilac; - case dbtask.CareTaskType.grooming: return AppColors.lilac; - case dbtask.CareTaskType.vetVisit: return AppColors.mint; - case dbtask.CareTaskType.training: return AppColors.tangerine; - case dbtask.CareTaskType.nailTrim: return AppColors.lilac; - case dbtask.CareTaskType.bath: return AppColors.sky; - case dbtask.CareTaskType.other: return AppColors.sunny; - } - } - - String get _emoji { - switch (widget.task.taskType) { - case dbtask.CareTaskType.feeding: return '🥩'; - case dbtask.CareTaskType.walk: return '🦮'; - case dbtask.CareTaskType.grooming: return '✂️'; - case dbtask.CareTaskType.medication: return '💊'; - case dbtask.CareTaskType.vetVisit: return '🏥'; - case dbtask.CareTaskType.training: return '🎓'; - case dbtask.CareTaskType.playtime: return '🎾'; - case dbtask.CareTaskType.dental: return '🦷'; - case dbtask.CareTaskType.nailTrim: return '💅'; - case dbtask.CareTaskType.bath: return '🛁'; - case dbtask.CareTaskType.other: return '⭐'; - } - } - - bool get _isWeeklyish => - widget.task.frequency == dbtask.CareFrequency.weekly || - widget.task.frequency == dbtask.CareFrequency.biweekly || - widget.task.frequency == dbtask.CareFrequency.monthly; - - String get _sublabel { - final t = widget.task; - if (t.isLogDerived) return 'Activity log · Today'; - if (t.scheduledTime != null) { - return (!t.isCompleted && t.isDueToday) - ? 'Due ${t.scheduledTime}' - : t.scheduledTime!; - } - switch (t.frequency) { - case dbtask.CareFrequency.once: return 'Once'; - case dbtask.CareFrequency.daily: return 'Daily'; - case dbtask.CareFrequency.twiceDaily: return 'Twice daily'; - case dbtask.CareFrequency.weekly: return 'Weekly'; - case dbtask.CareFrequency.biweekly: return 'Every 2 weeks'; - case dbtask.CareFrequency.monthly: return 'Monthly'; - case dbtask.CareFrequency.asNeeded: return 'As needed'; - } - } - - void _showContextMenu(BuildContext ctx) { - final task = widget.task; - final logOnly = task.isLogDerived; - showModalBottomSheet( - context: ctx, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (_) => _TaskContextMenu( - taskTitle: task.title, - logOnly: logOnly, - onAddPlan: logOnly - ? () { - Navigator.pop(ctx); - showModalBottomSheet( - context: ctx, - isScrollControlled: true, - useSafeArea: true, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (_) => _CareTaskFormSheet( - petId: widget.petId, - petName: widget.petName, - createSeed: task, - ), - ); - } - : null, - onRemoveDay: logOnly - ? () async { - Navigator.pop(ctx); - await _confirmDialog( - ctx, - title: 'Remove from this day', - body: 'Clear this completion for "${task.title}"?', - confirmLabel: 'Remove', - onConfirmed: () => ref - .read(careDashboardProvider.notifier) - .deleteTask(task.id), - ); - } - : null, - onEdit: !logOnly - ? () { - Navigator.pop(ctx); - showModalBottomSheet( - context: ctx, - isScrollControlled: true, - useSafeArea: true, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (_) => _CareTaskFormSheet( - petId: widget.petId, - petName: widget.petName, - existing: task, - ), - ); - } - : null, - onDelete: !logOnly - ? () async { - Navigator.pop(ctx); - await _confirmDialog( - ctx, - title: 'Delete task', - body: 'Remove "${task.title}" from ${widget.petName}\'s care plan?', - confirmLabel: 'Delete', - onConfirmed: () => ref - .read(careDashboardProvider.notifier) - .deleteTask(task.id), - ); - } - : null, - ), - ); - } - - Future _confirmDialog( - BuildContext ctx, { - required String title, - required String body, - required String confirmLabel, - required Future Function() onConfirmed, - }) async { - final ok = await showDialog( - context: ctx, - builder: (d) => AlertDialog( - title: Text(title), - content: Text(body), - actions: [ - TextButton( - onPressed: () => Navigator.pop(d, false), - child: const Text('Cancel')), - FilledButton( - onPressed: () => Navigator.pop(d, true), - style: FilledButton.styleFrom( - backgroundColor: Theme.of(d).colorScheme.error), - child: Text(confirmLabel), - ), - ], - ), - ); - if (ok != true || !ctx.mounted) return; - try { - await onConfirmed(); - } catch (e) { - AppSnackBar.showError(e); - } - } - - @override - Widget build(BuildContext context) { - final pt = Theme.of(context).extension()!; - final cs = Theme.of(context).colorScheme; - final task = widget.task; - final done = task.isCompleted; - final due = !done && task.isDueToday; - final color = _color; - - final yAnim = Tween(begin: 0.0, end: -72.0).animate( - CurvedAnimation(parent: _xpCtrl, curve: const Cubic(0.2, 0.8, 0.2, 1.0)), - ); - final opacityAnim = TweenSequence([ - TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 15), - TweenSequenceItem(tween: ConstantTween(1.0), weight: 55), - TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30), - ]).animate(_xpCtrl); - - Widget card = GestureDetector( - onTap: _toggle, - onLongPress: () => _showContextMenu(context), - child: AnimatedContainer( - duration: const Duration(milliseconds: 240), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - decoration: BoxDecoration( - color: done - ? Color.alphaBlend(color.withAlpha(36), cs.surface) - : cs.surface, - borderRadius: BorderRadius.circular(22), - border: Border.all( - color: done ? color : pt.line, - width: 2, - ), - boxShadow: due - ? [BoxShadow(color: AppColors.poppy.withAlpha(64), blurRadius: 0, spreadRadius: 4)] - : pt.shadowE1, - ), - child: Row( - children: [ - // ── Icon box ────────────────────────────────────────────────── - AnimatedContainer( - duration: const Duration(milliseconds: 240), - width: 52, - height: 52, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: done ? color : color.withAlpha(48), - ), - alignment: Alignment.center, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: Text( - key: ValueKey(done), - done ? '✅' : _emoji, - style: const TextStyle(fontSize: 26, height: 1.0), - ), - ), - ), - const SizedBox(width: 12), - // ── Content ─────────────────────────────────────────────────── - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Flexible( - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 200), - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: done ? pt.ink500 : cs.onSurface, - decoration: done ? TextDecoration.lineThrough : null, - decorationColor: pt.ink300, - ), - child: Text( - task.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - if (_isWeeklyish) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 7, vertical: 2), - decoration: BoxDecoration( - color: AppColors.lilacSoft, - borderRadius: BorderRadius.circular(999), - ), - child: Text( - task.frequency == dbtask.CareFrequency.monthly - ? 'MONTHLY' - : 'WEEKLY', - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w900, - color: AppColors.lilac700, - ), - ), - ), - ], - ], - ), - const SizedBox(height: 3), - Text( - _sublabel, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: due ? AppColors.poppy700 : pt.ink500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - const SizedBox(width: 8), - // ── XP chip + check button ──────────────────────────────────── - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric( - horizontal: 9, vertical: 4), - decoration: BoxDecoration( - color: done ? AppColors.mintSoft : AppColors.sunnySoft, - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '+${task.gamificationPoints}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w900, - color: done - ? AppColors.mint700 - : AppColors.sunny700, - ), - ), - const SizedBox(width: 2), - const Text('⭐', - style: TextStyle(fontSize: 11)), - ], - ), - ), - const SizedBox(height: 6), - GestureDetector( - onTap: _toggle, - behavior: HitTestBehavior.opaque, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 34, - height: 34, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: done ? color : cs.surface, - border: Border.all( - color: done ? color : pt.line, - width: 2, - ), - ), - alignment: Alignment.center, - child: done - ? const Icon(Icons.check_rounded, - color: Colors.white, size: 18) - : null, - ), - ), - ], - ), - ], - ), - ), - ); - - // ── XP burst overlay ───────────────────────────────────────────────── - card = Stack( - clipBehavior: Clip.none, - children: [ - card, - if (_showBurst) - Positioned( - right: 14, - bottom: 52, - child: AnimatedBuilder( - animation: _xpCtrl, - builder: (_, child) => Transform.translate( - offset: Offset(0, yAnim.value), - child: Opacity( - opacity: opacityAnim.value.clamp(0.0, 1.0), - child: Text( - '+${task.gamificationPoints} XP ⭐', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w900, - color: AppColors.sunny700, - shadows: [ - Shadow( - color: AppColors.sunny, - blurRadius: 12, - offset: Offset(0, 4), - ), - ], - ), - ), - ), - ), - ), - ), - ], - ); - - // ── Swipe-to-toggle (Dismissible) for non-log tasks ────────────────── - if (!task.isLogDerived) { - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Dismissible( - key: ValueKey('d_${task.id}'), - direction: DismissDirection.startToEnd, - background: Container( - decoration: BoxDecoration( - color: done ? pt.surface2 : AppColors.success, - borderRadius: BorderRadius.circular(22), - ), - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 22), - child: Icon( - done ? Icons.replay_rounded : Icons.check_circle_outline_rounded, - color: done ? pt.ink300 : Colors.white, - size: 28, - ), - ), - confirmDismiss: (_) async { - _toggle(); - return false; - }, - child: card, - ), - ); - } - - return Padding(padding: const EdgeInsets.only(bottom: 10), child: card); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Task Context Menu (long-press sheet) -// ───────────────────────────────────────────────────────────────────────────── - -class _TaskContextMenu extends StatelessWidget { - const _TaskContextMenu({ - required this.taskTitle, - required this.logOnly, - this.onAddPlan, - this.onRemoveDay, - this.onEdit, - this.onDelete, - }); - - final String taskTitle; - final bool logOnly; - final VoidCallback? onAddPlan; - final VoidCallback? onRemoveDay; - final VoidCallback? onEdit; - final VoidCallback? onDelete; - - @override - Widget build(BuildContext context) { - final pt = Theme.of(context).extension()!; - final cs = Theme.of(context).colorScheme; - return Container( - decoration: BoxDecoration( - color: pt.surface1, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - ), - padding: EdgeInsets.fromLTRB( - 20, 0, 20, MediaQuery.paddingOf(context).bottom + 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: pt.line, - borderRadius: BorderRadius.circular(2)), - ), - ), - ), - Text(taskTitle, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: cs.onSurface)), - const SizedBox(height: 12), - if (onAddPlan != null) - _MenuTile( - icon: Icons.add_task_rounded, - label: 'Add to plan', - onTap: onAddPlan!), - if (onRemoveDay != null) - _MenuTile( - icon: Icons.remove_circle_outline_rounded, - label: 'Remove from day', - onTap: onRemoveDay!), - if (onEdit != null) - _MenuTile( - icon: Icons.edit_outlined, - label: 'Edit task', - onTap: onEdit!), - if (onDelete != null) - _MenuTile( - icon: Icons.delete_outline_rounded, - label: 'Delete task', - color: cs.error, - onTap: onDelete!), - ], - ), - ); - } -} - -class _MenuTile extends StatelessWidget { - const _MenuTile( - {required this.icon, required this.label, required this.onTap, this.color}); - final IconData icon; - final String label; - final VoidCallback onTap; - final Color? color; - - @override - Widget build(BuildContext context) { - final c = color ?? Theme.of(context).colorScheme.onSurface; - return ListTile( - leading: Icon(icon, color: c), - title: Text(label, style: TextStyle(color: c, fontWeight: FontWeight.w600)), - onTap: onTap, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// XP Progress Bar (shown between streak and date picker) -// ───────────────────────────────────────────────────────────────────────────── - -class _TaskCardSkeleton extends StatelessWidget { - const _TaskCardSkeleton(); - - @override - Widget build(BuildContext context) { - final pt = Theme.of(context).extension()!; - final cs = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Container( - height: 72, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: cs.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: pt.line, width: 0.5), - boxShadow: [ - const BoxShadow(color: AppColors.shadowE1L, blurRadius: 2, offset: Offset(0, 1)), - ], - ), - child: Row( - children: [ - const SkeletonLoader(width: 40, height: 40, borderRadius: 12), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - SkeletonLoader(width: 140, height: 14), - SizedBox(height: 6), - SkeletonLoader(width: 100, height: 11), - ], - ), - ), - const SkeletonLoader(width: 36, height: 36, borderRadius: 999), - ], - ), - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Care Error Card -// ───────────────────────────────────────────────────────────────────────────── - -class _CareErrorCard extends StatelessWidget { - const _CareErrorCard({required this.error, required this.onRetry}); - - final Object error; - final VoidCallback onRetry; - - @override - Widget build(BuildContext context) { - final pt = Theme.of(context).extension()!; - final cs = Theme.of(context).colorScheme; - final isNetwork = error is NetworkException; - final message = error is AppException - ? (error as AppException).message - : 'Could not load tasks. Check your connection and try again.'; - - return Container( - padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), - decoration: BoxDecoration( - color: cs.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: pt.line, width: 0.5), - ), - child: Column( - children: [ - Icon( - isNetwork ? Icons.wifi_off_rounded : Icons.cloud_off_rounded, - size: 40, - color: pt.ink300, - ), - const SizedBox(height: 10), - Text( - message, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, color: pt.ink500, height: 1.4), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh_rounded, size: 16), - label: const Text('Retry'), - ), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Empty Routine State -// ───────────────────────────────────────────────────────────────────────────── - -class _EmptyRoutineState extends StatelessWidget { - const _EmptyRoutineState({required this.petName, required this.date, this.onAddTask}); - - final String petName; - final DateTime date; - final VoidCallback? onAddTask; - - @override - Widget build(BuildContext context) { - final pt = Theme.of(context).extension()!; - final cs = Theme.of(context).colorScheme; - final isToday = DateUtils.dateOnly(date) == DateUtils.dateOnly(DateTime.now()); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 32), - child: Column( - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: pt.surface2, - borderRadius: BorderRadius.circular(20), - ), - child: Icon(Icons.task_alt_rounded, size: 32, color: pt.ink300), - ), - const SizedBox(height: 16), - Text( - isToday ? 'No tasks for today' : 'No tasks for this day', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 16, - color: cs.onSurface, - ), - ), - const SizedBox(height: 6), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), - child: Text( - isToday - ? 'Add care tasks for ${petName.isNotEmpty ? petName : 'your pet'} to start tracking daily routines.' - : 'Completed tasks from this day appear here.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 13, color: pt.ink500, height: 1.4), - ), - ), - if (isToday && onAddTask != null) ...[ - const SizedBox(height: 20), - OutlinedButton.icon( - onPressed: onAddTask, - icon: const Icon(Icons.add_rounded, size: 16), - label: const Text('Add first task'), - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), - ), - ), - ], - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Task type helpers (shared by card + sheet) -// ───────────────────────────────────────────────────────────────────────────── - -String _typeLabel(dbtask.CareTaskType type) { - switch (type) { - case dbtask.CareTaskType.feeding: return 'Feeding'; - case dbtask.CareTaskType.walk: return 'Walk'; - case dbtask.CareTaskType.grooming: return 'Grooming'; - case dbtask.CareTaskType.medication: return 'Meds'; - case dbtask.CareTaskType.vetVisit: return 'Vet Visit'; - case dbtask.CareTaskType.training: return 'Training'; - case dbtask.CareTaskType.playtime: return 'Playtime'; - case dbtask.CareTaskType.dental: return 'Dental'; - case dbtask.CareTaskType.nailTrim: return 'Nail Trim'; - case dbtask.CareTaskType.bath: return 'Bath'; - case dbtask.CareTaskType.other: return 'Other'; - } -} - -String _defaultTitle(dbtask.CareTaskType type) { - switch (type) { - case dbtask.CareTaskType.feeding: return 'Feeding time'; - case dbtask.CareTaskType.walk: return 'Walk'; - case dbtask.CareTaskType.grooming: return 'Grooming session'; - case dbtask.CareTaskType.medication: return 'Medication'; - case dbtask.CareTaskType.vetVisit: return 'Vet visit'; - case dbtask.CareTaskType.training: return 'Training'; - case dbtask.CareTaskType.playtime: return 'Playtime'; - case dbtask.CareTaskType.dental: return 'Dental care'; - case dbtask.CareTaskType.nailTrim: return 'Nail trim'; - case dbtask.CareTaskType.bath: return 'Bath time'; - case dbtask.CareTaskType.other: return 'New task'; - } -} - // ───────────────────────────────────────────────────────────────────────────── // Medical vault & nutrition entry banners // ───────────────────────────────────────────────────────────────────────────── @@ -1522,407 +392,3 @@ class _NutritionBanner extends StatelessWidget { ); } } - -// ───────────────────────────────────────────────────────────────────────────── - -// Add / Edit Care Task Sheet -// ───────────────────────────────────────────────────────────────────────────── - -class _CareTaskFormSheet extends ConsumerStatefulWidget { - const _CareTaskFormSheet({ - required this.petId, - required this.petName, - this.existing, - this.createSeed, - }); - - final String petId; - final String petName; - final dbtask.CareTask? existing; - final dbtask.CareTask? createSeed; - - @override - ConsumerState<_CareTaskFormSheet> createState() => _CareTaskFormSheetState(); -} - -class _CareTaskFormSheetState extends ConsumerState<_CareTaskFormSheet> { - var _type = dbtask.CareTaskType.feeding; - var _frequency = dbtask.CareFrequency.daily; - late TextEditingController _titleCtrl; - final _titleFocus = FocusNode(); - bool _titleFocused = false; - bool _userEditedTitle = false; - TimeOfDay? _time; - bool _saving = false; - - bool get _isEdit => widget.existing != null; - bool get _isPrefilledCreate => - widget.existing == null && widget.createSeed != null; - - @override - void initState() { - super.initState(); - final ex = widget.existing; - final seed = widget.createSeed; - if (ex != null) { - _type = ex.taskType; - _frequency = ex.frequency; - _userEditedTitle = true; - _titleCtrl = TextEditingController(text: ex.title); - _time = parseCareScheduledTimeOfDay(ex.scheduledTime); - } else if (seed != null) { - _type = seed.taskType; - _frequency = dbtask.CareFrequency.daily; - _userEditedTitle = true; - _titleCtrl = TextEditingController(text: seed.title); - _time = parseCareScheduledTimeOfDay(seed.scheduledTime); - } else { - _titleCtrl = TextEditingController(text: _defaultTitle(dbtask.CareTaskType.feeding)); - } - _titleFocus.addListener(() { - if (mounted) setState(() => _titleFocused = _titleFocus.hasFocus); - }); - } - - @override - void dispose() { - _titleCtrl.dispose(); - _titleFocus.dispose(); - super.dispose(); - } - - void _onTypeSelected(dbtask.CareTaskType t) { - setState(() { - _type = t; - if (!_userEditedTitle) _titleCtrl.text = _defaultTitle(t); - }); - } - - Future _save() async { - final title = _titleCtrl.text.trim(); - if (title.isEmpty || _saving) return; - setState(() => _saving = true); - try { - final timeStr = _time != null - ? '${_time!.hour.toString().padLeft(2, '0')}:${_time!.minute.toString().padLeft(2, '0')}' - : null; - final ex = widget.existing; - if (ex != null) { - final updated = ex.copyWith( - taskType: _type, - title: title, - frequency: _frequency, - scheduledTime: timeStr, - updatedAt: DateTime.now(), - ); - await ref.read(careDashboardProvider.notifier).updateTask(updated); - } else { - final now = DateTime.now(); - final task = dbtask.CareTask( - id: '', - petId: widget.petId, - taskType: _type, - title: title, - frequency: _frequency, - scheduledTime: timeStr, - isCompleted: false, - gamificationPoints: 10, - createdAt: now, - updatedAt: now, - ); - await ref.read(careDashboardProvider.notifier).createTask(task); - } - if (mounted) Navigator.of(context).pop(); - } catch (e) { - if (mounted) { - setState(() => _saving = false); - AppSnackBar.showError(e); - } - } - } - - @override - Widget build(BuildContext context) { - final pt = Theme.of(context).extension()!; - final cs = Theme.of(context).colorScheme; - final bottom = MediaQuery.viewInsetsOf(context).bottom; - - return Container( - decoration: BoxDecoration( - color: pt.surface1, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - ), - padding: EdgeInsets.fromLTRB(20, 0, 20, math.max(bottom, 24) + 16), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Container( - width: 36, - height: 4, - decoration: BoxDecoration(color: pt.line, borderRadius: BorderRadius.circular(2)), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Text( - _isEdit - ? 'Edit care task' - : (_isPrefilledCreate ? 'Add to plan' : 'New care task'), - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 18, - color: cs.onSurface, - ), - ), - ), - - // ── Task type ─────────────────────────────────────────────────── - _SheetLabel('Task type', pt), - const SizedBox(height: 10), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - mainAxisExtent: 72, - ), - itemCount: dbtask.CareTaskType.values.length, - itemBuilder: (_, i) { - final t = dbtask.CareTaskType.values[i]; - final selected = t == _type; - return GestureDetector( - onTap: () => _onTypeSelected(t), - child: AnimatedContainer( - duration: PetfolioThemeExtension.durationSm, - decoration: BoxDecoration( - color: selected ? cs.primary : pt.surface2, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: selected ? Colors.transparent : pt.line, - width: 0.5, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(_taskTypeIcon(t), size: 22, - color: selected ? Colors.white : pt.ink500), - const SizedBox(height: 5), - Text( - _typeLabel(t), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: selected ? Colors.white : pt.ink500, - height: 1, - ), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ); - }, - ), - const SizedBox(height: 20), - - // ── Title ──────────────────────────────────────────────────────── - _SheetLabel('Title', pt), - const SizedBox(height: 10), - AnimatedContainer( - duration: PetfolioThemeExtension.durationSm, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - boxShadow: _titleFocused - ? [BoxShadow(color: cs.primary.withAlpha(30), blurRadius: 8)] - : [], - ), - child: TextField( - controller: _titleCtrl, - focusNode: _titleFocus, - onChanged: (_) => _userEditedTitle = true, - textCapitalization: TextCapitalization.sentences, - style: TextStyle(fontSize: 15, color: cs.onSurface), - decoration: InputDecoration( - hintText: 'e.g. Morning feeding', - filled: true, - fillColor: _titleFocused ? cs.surface : pt.surface2, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: pt.line, width: 0.5), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: cs.primary, width: 2), - ), - ), - ), - ), - const SizedBox(height: 20), - - // ── Frequency ──────────────────────────────────────────────────── - _SheetLabel('How often?', pt), - const SizedBox(height: 10), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: dbtask.CareFrequency.values.map((f) { - final selected = f == _frequency; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( - onTap: () => setState(() => _frequency = f), - child: AnimatedContainer( - duration: PetfolioThemeExtension.durationSm, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9), - decoration: BoxDecoration( - color: selected ? cs.primary : pt.surface2, - borderRadius: BorderRadius.circular(40), - border: Border.all( - color: selected ? Colors.transparent : pt.line, - width: 0.5, - ), - ), - child: Text( - _freqLabel(f), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: selected ? Colors.white : pt.ink500, - ), - ), - ), - ), - ); - }).toList(), - ), - ), - const SizedBox(height: 20), - - // ── Time (optional) ────────────────────────────────────────────── - _SheetLabel('Time (optional)', pt), - const SizedBox(height: 10), - GestureDetector( - onTap: () async { - final picked = await showTimePicker( - context: context, - initialTime: _time ?? TimeOfDay.now(), - ); - if (picked != null && mounted) setState(() => _time = picked); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: pt.surface2, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: pt.line, width: 0.5), - ), - child: Row( - children: [ - Icon(Icons.schedule_rounded, size: 18, color: pt.ink500), - const SizedBox(width: 10), - Text( - _time != null ? _time!.format(context) : 'No time set', - style: TextStyle( - fontSize: 15, - color: _time != null ? cs.onSurface : pt.ink300, - ), - ), - const Spacer(), - if (_time != null) - GestureDetector( - onTap: () => setState(() => _time = null), - child: Icon(Icons.close_rounded, size: 16, color: pt.ink300), - ) - else - Icon(Icons.chevron_right_rounded, size: 18, color: pt.ink300), - ], - ), - ), - ), - const SizedBox(height: 28), - - // ── Save button ────────────────────────────────────────────────── - SizedBox( - width: double.infinity, - height: 52, - child: FilledButton( - onPressed: _saving ? null : _save, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - ), - child: _saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : Text( - _isEdit - ? 'Save changes' - : (_isPrefilledCreate ? 'Save plan' : 'Add Task'), - style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 15), - ), - ), - ), - ], - ), - ), - ); - } - - String _freqLabel(dbtask.CareFrequency f) { - switch (f) { - case dbtask.CareFrequency.once: return 'Once'; - case dbtask.CareFrequency.daily: return 'Daily'; - case dbtask.CareFrequency.twiceDaily: return 'Twice daily'; - case dbtask.CareFrequency.weekly: return 'Weekly'; - case dbtask.CareFrequency.biweekly: return 'Every 2 wks'; - case dbtask.CareFrequency.monthly: return 'Monthly'; - case dbtask.CareFrequency.asNeeded: return 'As needed'; - } - } -} - -class _SheetLabel extends StatelessWidget { - const _SheetLabel(this.text, this.pt); - final String text; - final PetfolioThemeExtension pt; - - @override - Widget build(BuildContext context) => Text( - text, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - letterSpacing: 0.08 * 12, - color: pt.ink500, - ), - ); -} - -IconData _taskTypeIcon(dbtask.CareTaskType type) { - switch (type) { - case dbtask.CareTaskType.feeding: return Icons.restaurant_menu_rounded; - case dbtask.CareTaskType.walk: return Icons.directions_walk_rounded; - case dbtask.CareTaskType.grooming: return Icons.content_cut_rounded; - case dbtask.CareTaskType.medication: return Icons.medication_rounded; - case dbtask.CareTaskType.vetVisit: return Icons.local_hospital_rounded; - case dbtask.CareTaskType.training: return Icons.school_rounded; - case dbtask.CareTaskType.playtime: return Icons.sports_tennis_rounded; - case dbtask.CareTaskType.dental: return Icons.medical_services_rounded; - case dbtask.CareTaskType.nailTrim: return Icons.cut_rounded; - case dbtask.CareTaskType.bath: return Icons.water_drop_rounded; - case dbtask.CareTaskType.other: return Icons.star_outline_rounded; - } -} diff --git a/lib/features/care/presentation/screens/nutrition_screen.dart b/lib/features/care/presentation/screens/nutrition_screen.dart index 518dfbe..e4522ab 100644 --- a/lib/features/care/presentation/screens/nutrition_screen.dart +++ b/lib/features/care/presentation/screens/nutrition_screen.dart @@ -593,7 +593,13 @@ class _HistoryList extends StatelessWidget { return history.when( data: (logs) { final weights = logs.where((l) => l.weightKg != null).toList(); - if (weights.isEmpty) return const SizedBox.shrink(); + if (weights.isEmpty) { + return PetfolioEmptyState( + icon: Icons.monitor_weight_outlined, + title: 'No weight entries yet', + subtitle: 'Log your first weight entry to start tracking your pet\'s progress.', + ); + } return Container( decoration: BoxDecoration( color: cs.surface, diff --git a/lib/features/care/presentation/widgets/care_routine_generator_button.dart b/lib/features/care/presentation/widgets/care_routine_generator_button.dart new file mode 100644 index 0000000..89e93b5 --- /dev/null +++ b/lib/features/care/presentation/widgets/care_routine_generator_button.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/theme/app_colors.dart'; + +class CareRoutineGeneratorButton extends StatelessWidget { + const CareRoutineGeneratorButton({ + super.key, + required this.hasNoTasks, + required this.isGenerating, + required this.onTap, + }); + + final bool hasNoTasks; + final bool isGenerating; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + if (!hasNoTasks) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: OutlinedButton.icon( + onPressed: isGenerating ? null : onTap, + icon: isGenerating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.lilac, + ), + ) + : const Icon(Icons.auto_awesome, size: 16, color: AppColors.lilac), + label: Text(isGenerating ? 'Generating…' : 'Refresh AI Routine'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.lilac, + side: const BorderSide(color: AppColors.lilac), + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: InkWell( + onTap: isGenerating ? null : onTap, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.lilacSoft, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.lilac.withAlpha(60)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: const BoxDecoration( + color: AppColors.lilac, + shape: BoxShape.circle, + ), + child: isGenerating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.auto_awesome, color: Colors.white, size: 20), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isGenerating ? 'Generating...' : 'Generate AI Routine', + style: const TextStyle( + fontWeight: FontWeight.w700, + color: AppColors.lilac700, + fontSize: 15, + ), + ), + const SizedBox(height: 2), + Text( + isGenerating + ? 'Building personalized care plan...' + : 'Get daily, weekly & monthly tasks tailored for your pet', + style: const TextStyle( + color: AppColors.lilac700, + fontSize: 13, + ), + ), + ], + ), + ), + if (!isGenerating) const Icon(Icons.chevron_right, color: AppColors.lilac), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/care/presentation/widgets/care_task_form_sheet.dart b/lib/features/care/presentation/widgets/care_task_form_sheet.dart new file mode 100644 index 0000000..fcece4e --- /dev/null +++ b/lib/features/care/presentation/widgets/care_task_form_sheet.dart @@ -0,0 +1,504 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../data/models/care_task.dart' as dbtask; +import '../controllers/care_dashboard_controller.dart'; +import '../utils/care_scheduled_time.dart'; + +class CareTaskFormSheet extends ConsumerStatefulWidget { + const CareTaskFormSheet({ + super.key, + required this.petId, + required this.petName, + this.existing, + this.createSeed, + }); + + final String petId; + final String petName; + final dbtask.CareTask? existing; + final dbtask.CareTask? createSeed; + + static Future show( + BuildContext context, { + required String petId, + required String petName, + dbtask.CareTask? existing, + dbtask.CareTask? createSeed, + }) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (_) => CareTaskFormSheet( + petId: petId, + petName: petName, + existing: existing, + createSeed: createSeed, + ), + ); + + @override + ConsumerState createState() => _CareTaskFormSheetState(); +} + +class _CareTaskFormSheetState extends ConsumerState { + var _type = dbtask.CareTaskType.feeding; + var _frequency = dbtask.CareFrequency.daily; + late TextEditingController _titleCtrl; + final _titleFocus = FocusNode(); + bool _titleFocused = false; + bool _userEditedTitle = false; + TimeOfDay? _time; + bool _saving = false; + + bool get _isEdit => widget.existing != null; + bool get _isPrefilledCreate => + widget.existing == null && widget.createSeed != null; + + @override + void initState() { + super.initState(); + final ex = widget.existing; + final seed = widget.createSeed; + if (ex != null) { + _type = ex.taskType; + _frequency = ex.frequency; + _userEditedTitle = true; + _titleCtrl = TextEditingController(text: ex.title); + _time = parseCareScheduledTimeOfDay(ex.scheduledTime); + } else if (seed != null) { + _type = seed.taskType; + _frequency = dbtask.CareFrequency.daily; + _userEditedTitle = true; + _titleCtrl = TextEditingController(text: seed.title); + _time = parseCareScheduledTimeOfDay(seed.scheduledTime); + } else { + _titleCtrl = TextEditingController( + text: _defaultTitle(dbtask.CareTaskType.feeding)); + } + _titleFocus.addListener(() { + if (mounted) setState(() => _titleFocused = _titleFocus.hasFocus); + }); + } + + @override + void dispose() { + _titleCtrl.dispose(); + _titleFocus.dispose(); + super.dispose(); + } + + void _onTypeSelected(dbtask.CareTaskType t) { + setState(() { + _type = t; + if (!_userEditedTitle) _titleCtrl.text = _defaultTitle(t); + }); + } + + Future _save() async { + final title = _titleCtrl.text.trim(); + if (title.isEmpty || _saving) return; + setState(() => _saving = true); + try { + final timeStr = _time != null + ? '${_time!.hour.toString().padLeft(2, '0')}:${_time!.minute.toString().padLeft(2, '0')}' + : null; + final ex = widget.existing; + if (ex != null) { + final updated = ex.copyWith( + taskType: _type, + title: title, + frequency: _frequency, + scheduledTime: timeStr, + updatedAt: DateTime.now(), + ); + await ref.read(careDashboardProvider.notifier).updateTask(updated); + } else { + final now = DateTime.now(); + final task = dbtask.CareTask( + id: '', + petId: widget.petId, + taskType: _type, + title: title, + frequency: _frequency, + scheduledTime: timeStr, + isCompleted: false, + gamificationPoints: 10, + createdAt: now, + updatedAt: now, + ); + await ref.read(careDashboardProvider.notifier).createTask(task); + } + if (mounted) Navigator.of(context).pop(); + } catch (e) { + if (mounted) { + setState(() => _saving = false); + AppSnackBar.showError(e); + } + } + } + + @override + Widget build(BuildContext context) { + final pt = Theme.of(context).extension()!; + final cs = Theme.of(context).colorScheme; + final bottom = MediaQuery.viewInsetsOf(context).bottom; + + return Container( + decoration: BoxDecoration( + color: pt.surface1, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.fromLTRB(20, 0, 20, math.max(bottom, 24) + 16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: pt.line, borderRadius: BorderRadius.circular(2)), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + _isEdit + ? 'Edit care task' + : (_isPrefilledCreate ? 'Add to plan' : 'New care task'), + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 18, + color: cs.onSurface, + ), + ), + ), + _SheetLabel('Task type', pt), + const SizedBox(height: 10), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + mainAxisExtent: 72, + ), + itemCount: dbtask.CareTaskType.values.length, + itemBuilder: (_, i) { + final t = dbtask.CareTaskType.values[i]; + final selected = t == _type; + return GestureDetector( + onTap: () => _onTypeSelected(t), + child: AnimatedContainer( + duration: PetfolioThemeExtension.durationSm, + decoration: BoxDecoration( + color: selected ? cs.primary : pt.surface2, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected ? Colors.transparent : pt.line, + width: 0.5, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(_taskTypeIcon(t), + size: 22, + color: selected ? Colors.white : pt.ink500), + const SizedBox(height: 5), + Text( + _typeLabel(t), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: selected ? Colors.white : pt.ink500, + height: 1, + ), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + const SizedBox(height: 20), + _SheetLabel('Title', pt), + const SizedBox(height: 10), + AnimatedContainer( + duration: PetfolioThemeExtension.durationSm, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: _titleFocused + ? [BoxShadow(color: cs.primary.withAlpha(30), blurRadius: 8)] + : [], + ), + child: TextField( + controller: _titleCtrl, + focusNode: _titleFocus, + onChanged: (_) => _userEditedTitle = true, + textCapitalization: TextCapitalization.sentences, + style: TextStyle(fontSize: 15, color: cs.onSurface), + decoration: InputDecoration( + hintText: 'e.g. Morning feeding', + filled: true, + fillColor: _titleFocused ? cs.surface : pt.surface2, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: pt.line, width: 0.5), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: cs.primary, width: 2), + ), + ), + ), + ), + const SizedBox(height: 20), + _SheetLabel('How often?', pt), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: dbtask.CareFrequency.values.map((f) { + final selected = f == _frequency; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () => setState(() => _frequency = f), + child: AnimatedContainer( + duration: PetfolioThemeExtension.durationSm, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 9), + decoration: BoxDecoration( + color: selected ? cs.primary : pt.surface2, + borderRadius: BorderRadius.circular(40), + border: Border.all( + color: selected ? Colors.transparent : pt.line, + width: 0.5, + ), + ), + child: Text( + _freqLabel(f), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: selected ? Colors.white : pt.ink500, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 20), + _SheetLabel('Time (optional)', pt), + const SizedBox(height: 10), + GestureDetector( + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: _time ?? TimeOfDay.now(), + ); + if (picked != null && mounted) setState(() => _time = picked); + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: pt.surface2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: pt.line, width: 0.5), + ), + child: Row( + children: [ + Icon(Icons.schedule_rounded, size: 18, color: pt.ink500), + const SizedBox(width: 10), + Text( + _time != null ? _time!.format(context) : 'No time set', + style: TextStyle( + fontSize: 15, + color: _time != null ? cs.onSurface : pt.ink300, + ), + ), + const Spacer(), + if (_time != null) + GestureDetector( + onTap: () => setState(() => _time = null), + child: Icon(Icons.close_rounded, + size: 16, color: pt.ink300), + ) + else + Icon(Icons.chevron_right_rounded, + size: 18, color: pt.ink300), + ], + ), + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + height: 52, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + ), + child: _saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : Text( + _isEdit + ? 'Save changes' + : (_isPrefilledCreate ? 'Save plan' : 'Add Task'), + style: const TextStyle( + fontWeight: FontWeight.w700, fontSize: 15), + ), + ), + ), + ], + ), + ), + ); + } + + String _freqLabel(dbtask.CareFrequency f) { + switch (f) { + case dbtask.CareFrequency.once: + return 'Once'; + case dbtask.CareFrequency.daily: + return 'Daily'; + case dbtask.CareFrequency.twiceDaily: + return 'Twice daily'; + case dbtask.CareFrequency.weekly: + return 'Weekly'; + case dbtask.CareFrequency.biweekly: + return 'Every 2 wks'; + case dbtask.CareFrequency.monthly: + return 'Monthly'; + case dbtask.CareFrequency.asNeeded: + return 'As needed'; + } + } +} + +class _SheetLabel extends StatelessWidget { + const _SheetLabel(this.text, this.pt); + final String text; + final PetfolioThemeExtension pt; + + @override + Widget build(BuildContext context) => Text( + text, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + letterSpacing: 0.08 * 12, + color: pt.ink500, + ), + ); +} + +String _typeLabel(dbtask.CareTaskType type) { + switch (type) { + case dbtask.CareTaskType.feeding: + return 'Feeding'; + case dbtask.CareTaskType.walk: + return 'Walk'; + case dbtask.CareTaskType.grooming: + return 'Grooming'; + case dbtask.CareTaskType.medication: + return 'Meds'; + case dbtask.CareTaskType.vetVisit: + return 'Vet Visit'; + case dbtask.CareTaskType.training: + return 'Training'; + case dbtask.CareTaskType.playtime: + return 'Playtime'; + case dbtask.CareTaskType.dental: + return 'Dental'; + case dbtask.CareTaskType.nailTrim: + return 'Nail Trim'; + case dbtask.CareTaskType.bath: + return 'Bath'; + case dbtask.CareTaskType.other: + return 'Other'; + } +} + +String _defaultTitle(dbtask.CareTaskType type) { + switch (type) { + case dbtask.CareTaskType.feeding: + return 'Feeding time'; + case dbtask.CareTaskType.walk: + return 'Walk'; + case dbtask.CareTaskType.grooming: + return 'Grooming session'; + case dbtask.CareTaskType.medication: + return 'Medication'; + case dbtask.CareTaskType.vetVisit: + return 'Vet visit'; + case dbtask.CareTaskType.training: + return 'Training'; + case dbtask.CareTaskType.playtime: + return 'Playtime'; + case dbtask.CareTaskType.dental: + return 'Dental care'; + case dbtask.CareTaskType.nailTrim: + return 'Nail trim'; + case dbtask.CareTaskType.bath: + return 'Bath time'; + case dbtask.CareTaskType.other: + return 'New task'; + } +} + +IconData _taskTypeIcon(dbtask.CareTaskType type) { + switch (type) { + case dbtask.CareTaskType.feeding: + return Icons.restaurant_menu_rounded; + case dbtask.CareTaskType.walk: + return Icons.directions_walk_rounded; + case dbtask.CareTaskType.grooming: + return Icons.content_cut_rounded; + case dbtask.CareTaskType.medication: + return Icons.medication_rounded; + case dbtask.CareTaskType.vetVisit: + return Icons.local_hospital_rounded; + case dbtask.CareTaskType.training: + return Icons.school_rounded; + case dbtask.CareTaskType.playtime: + return Icons.sports_tennis_rounded; + case dbtask.CareTaskType.dental: + return Icons.medical_services_rounded; + case dbtask.CareTaskType.nailTrim: + return Icons.cut_rounded; + case dbtask.CareTaskType.bath: + return Icons.water_drop_rounded; + case dbtask.CareTaskType.other: + return Icons.star_outline_rounded; + } +} diff --git a/lib/features/care/presentation/widgets/care_task_list.dart b/lib/features/care/presentation/widgets/care_task_list.dart new file mode 100644 index 0000000..e55b9dd --- /dev/null +++ b/lib/features/care/presentation/widgets/care_task_list.dart @@ -0,0 +1,850 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/errors/app_exception.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/widgets/skeleton_loader.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../pet_profile/data/models/pet_species.dart' show PetSpecies; +import '../../data/models/care_task.dart' as dbtask; +import '../../data/models/care_task_log.dart'; +import '../controllers/care_dashboard_controller.dart'; +import 'care_task_form_sheet.dart'; + +class CareTaskList extends ConsumerWidget { + const CareTaskList({ + super.key, + required this.state, + required this.petId, + required this.petName, + required this.species, + this.onAddTask, + }); + + final DailyRoutineState state; + final String petId; + final String petName; + final PetSpecies species; + final VoidCallback? onAddTask; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return state.tasks.when( + loading: () => const Column( + children: [ + _TaskCardSkeleton(), + _TaskCardSkeleton(), + _TaskCardSkeleton(), + ], + ), + error: (err, _) => _CareErrorCard( + error: err, + onRetry: () => ref.read(careDashboardProvider.notifier).refresh(), + ), + data: (tasks) => tasks.isEmpty + ? _EmptyRoutineState( + petName: petName, + date: state.selectedDate, + onAddTask: onAddTask, + ) + : Column( + children: tasks + .map((t) => _CareTaskCard( + task: t, + petId: petId, + petName: petName, + species: species, + )) + .toList(), + ), + ); + } +} + +class CareTaskDoneCounter extends StatelessWidget { + const CareTaskDoneCounter({super.key, required this.tasks}); + final List tasks; + + @override + Widget build(BuildContext context) { + if (tasks.isEmpty) return const SizedBox.shrink(); + final done = tasks.where((t) => t.isCompleted).length; + final total = tasks.length; + final allDone = done == total; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Text( + key: ValueKey('$done/$total'), + allDone ? 'All done! 🎉' : '$done/$total done', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: allDone ? AppColors.mint700 : AppColors.sunny700, + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Care Task Card +// ───────────────────────────────────────────────────────────────────────────── + +class _CareTaskCard extends ConsumerStatefulWidget { + const _CareTaskCard({ + required this.task, + required this.petId, + required this.petName, + required this.species, + }); + + final dbtask.CareTask task; + final String petId; + final String petName; + final PetSpecies species; + + @override + ConsumerState<_CareTaskCard> createState() => _CareTaskCardState(); +} + +class _CareTaskCardState extends ConsumerState<_CareTaskCard> + with SingleTickerProviderStateMixin { + late AnimationController _xpCtrl; + bool _showBurst = false; + + @override + void initState() { + super.initState(); + _xpCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..addStatusListener((s) { + if (s == AnimationStatus.completed && mounted) { + setState(() => _showBurst = false); + _xpCtrl.reset(); + } + }); + } + + @override + void dispose() { + _xpCtrl.dispose(); + super.dispose(); + } + + void _toggle() { + final nowDone = !widget.task.isCompleted; + ref + .read(careDashboardProvider.notifier) + .toggleTaskCompletion(widget.task.id, isCompleted: nowDone); + if (nowDone && widget.task.gamificationPoints > 0) { + setState(() => _showBurst = true); + _xpCtrl.forward(from: 0); + } + } + + Color get _color { + switch (widget.task.taskType) { + case dbtask.CareTaskType.feeding: + return AppColors.tangerine; + case dbtask.CareTaskType.medication: + return AppColors.poppy; + case dbtask.CareTaskType.walk: + return AppColors.mint; + case dbtask.CareTaskType.playtime: + return AppColors.sunny; + case dbtask.CareTaskType.dental: + return AppColors.lilac; + case dbtask.CareTaskType.grooming: + return AppColors.lilac; + case dbtask.CareTaskType.vetVisit: + return AppColors.mint; + case dbtask.CareTaskType.training: + return AppColors.tangerine; + case dbtask.CareTaskType.nailTrim: + return AppColors.lilac; + case dbtask.CareTaskType.bath: + return AppColors.sky; + case dbtask.CareTaskType.other: + return AppColors.sunny; + } + } + + String get _emoji { + switch (widget.task.taskType) { + case dbtask.CareTaskType.feeding: + return '🥩'; + case dbtask.CareTaskType.walk: + return '🦮'; + case dbtask.CareTaskType.grooming: + return '✂️'; + case dbtask.CareTaskType.medication: + return '💊'; + case dbtask.CareTaskType.vetVisit: + return '🏥'; + case dbtask.CareTaskType.training: + return '🎓'; + case dbtask.CareTaskType.playtime: + return '🎾'; + case dbtask.CareTaskType.dental: + return '🦷'; + case dbtask.CareTaskType.nailTrim: + return '💅'; + case dbtask.CareTaskType.bath: + return '🛁'; + case dbtask.CareTaskType.other: + return '⭐'; + } + } + + bool get _isWeeklyish => + widget.task.frequency == dbtask.CareFrequency.weekly || + widget.task.frequency == dbtask.CareFrequency.biweekly || + widget.task.frequency == dbtask.CareFrequency.monthly; + + String get _sublabel { + final t = widget.task; + if (t.isLogDerived) return 'Activity log · Today'; + if (t.scheduledTime != null) { + return (!t.isCompleted && t.isDueToday) + ? 'Due ${t.scheduledTime}' + : t.scheduledTime!; + } + switch (t.frequency) { + case dbtask.CareFrequency.once: + return 'Once'; + case dbtask.CareFrequency.daily: + return 'Daily'; + case dbtask.CareFrequency.twiceDaily: + return 'Twice daily'; + case dbtask.CareFrequency.weekly: + return 'Weekly'; + case dbtask.CareFrequency.biweekly: + return 'Every 2 weeks'; + case dbtask.CareFrequency.monthly: + return 'Monthly'; + case dbtask.CareFrequency.asNeeded: + return 'As needed'; + } + } + + void _showContextMenu(BuildContext ctx) { + final task = widget.task; + final logOnly = task.isLogDerived; + showModalBottomSheet( + context: ctx, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (_) => _TaskContextMenu( + taskTitle: task.title, + logOnly: logOnly, + onAddPlan: logOnly + ? () { + Navigator.pop(ctx); + CareTaskFormSheet.show( + ctx, + petId: widget.petId, + petName: widget.petName, + createSeed: task, + ); + } + : null, + onRemoveDay: logOnly + ? () async { + Navigator.pop(ctx); + await _confirmDialog( + ctx, + title: 'Remove from this day', + body: 'Clear this completion for "${task.title}"?', + confirmLabel: 'Remove', + onConfirmed: () => ref + .read(careDashboardProvider.notifier) + .deleteTask(task.id), + ); + } + : null, + onEdit: !logOnly + ? () { + Navigator.pop(ctx); + CareTaskFormSheet.show( + ctx, + petId: widget.petId, + petName: widget.petName, + existing: task, + ); + } + : null, + onDelete: !logOnly + ? () async { + Navigator.pop(ctx); + await _confirmDialog( + ctx, + title: 'Delete task', + body: + 'Remove "${task.title}" from ${widget.petName}\'s care plan?', + confirmLabel: 'Delete', + onConfirmed: () => ref + .read(careDashboardProvider.notifier) + .deleteTask(task.id), + ); + } + : null, + ), + ); + } + + Future _confirmDialog( + BuildContext ctx, { + required String title, + required String body, + required String confirmLabel, + required Future Function() onConfirmed, + }) async { + final ok = await showDialog( + context: ctx, + builder: (d) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton( + onPressed: () => Navigator.pop(d, false), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(d, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(d).colorScheme.error), + child: Text(confirmLabel), + ), + ], + ), + ); + if (ok != true || !ctx.mounted) return; + try { + await onConfirmed(); + } catch (e) { + AppSnackBar.showError(e); + } + } + + @override + Widget build(BuildContext context) { + final pt = Theme.of(context).extension()!; + final cs = Theme.of(context).colorScheme; + final task = widget.task; + final done = task.isCompleted; + final due = !done && task.isDueToday; + final color = _color; + + final yAnim = Tween(begin: 0.0, end: -72.0).animate( + CurvedAnimation( + parent: _xpCtrl, curve: const Cubic(0.2, 0.8, 0.2, 1.0)), + ); + final opacityAnim = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 15), + TweenSequenceItem(tween: ConstantTween(1.0), weight: 55), + TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30), + ]).animate(_xpCtrl); + + Widget card = GestureDetector( + onTap: _toggle, + onLongPress: () => _showContextMenu(context), + child: AnimatedContainer( + duration: const Duration(milliseconds: 240), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: BoxDecoration( + color: done + ? Color.alphaBlend(color.withAlpha(36), cs.surface) + : cs.surface, + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: done ? color : pt.line, + width: 2, + ), + boxShadow: due + ? [ + BoxShadow( + color: AppColors.poppy.withAlpha(64), + blurRadius: 0, + spreadRadius: 4) + ] + : pt.shadowE1, + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 240), + width: 52, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: done ? color : color.withAlpha(48), + ), + alignment: Alignment.center, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + key: ValueKey(done), + done ? '✅' : _emoji, + style: const TextStyle(fontSize: 26, height: 1.0), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Flexible( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: done ? pt.ink500 : cs.onSurface, + decoration: + done ? TextDecoration.lineThrough : null, + decorationColor: pt.ink300, + ), + child: Text( + task.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (_isWeeklyish) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: AppColors.lilacSoft, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + task.frequency == + dbtask.CareFrequency.monthly + ? 'MONTHLY' + : 'WEEKLY', + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w900, + color: AppColors.lilac700, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 3), + Text( + _sublabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: due ? AppColors.poppy700 : pt.ink500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: + const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: done ? AppColors.mintSoft : AppColors.sunnySoft, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '+${task.gamificationPoints}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w900, + color: done ? AppColors.mint700 : AppColors.sunny700, + ), + ), + const SizedBox(width: 2), + const Text('⭐', style: TextStyle(fontSize: 11)), + ], + ), + ), + const SizedBox(height: 6), + GestureDetector( + onTap: _toggle, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: done ? color : cs.surface, + border: Border.all( + color: done ? color : pt.line, + width: 2, + ), + ), + alignment: Alignment.center, + child: done + ? const Icon(Icons.check_rounded, + color: Colors.white, size: 18) + : null, + ), + ), + ], + ), + ], + ), + ), + ); + + card = Stack( + clipBehavior: Clip.none, + children: [ + card, + if (_showBurst) + Positioned( + right: 14, + bottom: 52, + child: AnimatedBuilder( + animation: _xpCtrl, + builder: (_, child) => Transform.translate( + offset: Offset(0, yAnim.value), + child: Opacity( + opacity: opacityAnim.value.clamp(0.0, 1.0), + child: Text( + '+${task.gamificationPoints} XP ⭐', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: AppColors.sunny700, + shadows: [ + Shadow( + color: AppColors.sunny, + blurRadius: 12, + offset: Offset(0, 4), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + + if (!task.isLogDerived) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Dismissible( + key: ValueKey('d_${task.id}'), + direction: DismissDirection.startToEnd, + background: Container( + decoration: BoxDecoration( + color: done ? pt.surface2 : AppColors.success, + borderRadius: BorderRadius.circular(22), + ), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 22), + child: Icon( + done + ? Icons.replay_rounded + : Icons.check_circle_outline_rounded, + color: done ? pt.ink300 : Colors.white, + size: 28, + ), + ), + confirmDismiss: (_) async { + _toggle(); + return false; + }, + child: card, + ), + ); + } + + return Padding(padding: const EdgeInsets.only(bottom: 10), child: card); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Supporting widgets +// ───────────────────────────────────────────────────────────────────────────── + +class _TaskContextMenu extends StatelessWidget { + const _TaskContextMenu({ + required this.taskTitle, + required this.logOnly, + this.onAddPlan, + this.onRemoveDay, + this.onEdit, + this.onDelete, + }); + + final String taskTitle; + final bool logOnly; + final VoidCallback? onAddPlan; + final VoidCallback? onRemoveDay; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + + @override + Widget build(BuildContext context) { + final pt = Theme.of(context).extension()!; + final cs = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: pt.surface1, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.fromLTRB( + 20, 0, 20, MediaQuery.paddingOf(context).bottom + 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: pt.line, borderRadius: BorderRadius.circular(2)), + ), + ), + ), + Text(taskTitle, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: cs.onSurface)), + const SizedBox(height: 12), + if (onAddPlan != null) + _MenuTile( + icon: Icons.add_task_rounded, + label: 'Add to plan', + onTap: onAddPlan!), + if (onRemoveDay != null) + _MenuTile( + icon: Icons.remove_circle_outline_rounded, + label: 'Remove from day', + onTap: onRemoveDay!), + if (onEdit != null) + _MenuTile( + icon: Icons.edit_outlined, + label: 'Edit task', + onTap: onEdit!), + if (onDelete != null) + _MenuTile( + icon: Icons.delete_outline_rounded, + label: 'Delete task', + color: cs.error, + onTap: onDelete!), + ], + ), + ); + } +} + +class _MenuTile extends StatelessWidget { + const _MenuTile( + {required this.icon, + required this.label, + required this.onTap, + this.color}); + final IconData icon; + final String label; + final VoidCallback onTap; + final Color? color; + + @override + Widget build(BuildContext context) { + final c = color ?? Theme.of(context).colorScheme.onSurface; + return ListTile( + leading: Icon(icon, color: c), + title: Text(label, + style: TextStyle(color: c, fontWeight: FontWeight.w600)), + onTap: onTap, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ); + } +} + +class _TaskCardSkeleton extends StatelessWidget { + const _TaskCardSkeleton(); + + @override + Widget build(BuildContext context) { + final pt = Theme.of(context).extension()!; + final cs = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + height: 72, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: pt.line, width: 0.5), + boxShadow: const [ + BoxShadow( + color: AppColors.shadowE1L, + blurRadius: 2, + offset: Offset(0, 1)), + ], + ), + child: Row( + children: const [ + SkeletonLoader(width: 40, height: 40, borderRadius: 12), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SkeletonLoader(width: 140, height: 14), + SizedBox(height: 6), + SkeletonLoader(width: 100, height: 11), + ], + ), + ), + SkeletonLoader(width: 36, height: 36, borderRadius: 999), + ], + ), + ), + ); + } +} + +class _CareErrorCard extends StatelessWidget { + const _CareErrorCard({required this.error, required this.onRetry}); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final pt = Theme.of(context).extension()!; + final cs = Theme.of(context).colorScheme; + final isNetwork = error is NetworkException; + final message = error is AppException + ? (error as AppException).message + : 'Could not load tasks. Check your connection and try again.'; + + return Container( + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: pt.line, width: 0.5), + ), + child: Column( + children: [ + Icon( + isNetwork ? Icons.wifi_off_rounded : Icons.cloud_off_rounded, + size: 40, + color: pt.ink300, + ), + const SizedBox(height: 10), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: pt.ink500, height: 1.4), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Retry'), + ), + ], + ), + ); + } +} + +class _EmptyRoutineState extends StatelessWidget { + const _EmptyRoutineState( + {required this.petName, required this.date, this.onAddTask}); + + final String petName; + final DateTime date; + final VoidCallback? onAddTask; + + @override + Widget build(BuildContext context) { + final pt = Theme.of(context).extension()!; + final cs = Theme.of(context).colorScheme; + final isToday = + DateUtils.dateOnly(date) == DateUtils.dateOnly(DateTime.now()); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: pt.surface2, + borderRadius: BorderRadius.circular(20), + ), + child: Icon(Icons.task_alt_rounded, size: 32, color: pt.ink300), + ), + const SizedBox(height: 16), + Text( + isToday ? 'No tasks for today' : 'No tasks for this day', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + color: cs.onSurface, + ), + ), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Text( + isToday + ? 'Add care tasks for ${petName.isNotEmpty ? petName : 'your pet'} to start tracking daily routines.' + : 'Completed tasks from this day appear here.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: pt.ink500, height: 1.4), + ), + ), + if (isToday && onAddTask != null) ...[ + const SizedBox(height: 20), + OutlinedButton.icon( + onPressed: onAddTask, + icon: const Icon(Icons.add_rounded, size: 16), + label: const Text('Add first task'), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 11), + ), + ), + ], + ], + ), + ); + } +} diff --git a/progress.md b/progress.md index 5626cae..f6f2545 100644 --- a/progress.md +++ b/progress.md @@ -1,6 +1,15 @@ # Petfolio — Progress Log +## 2026-05-27 — Care Module Refactoring + +- **`care_screen.dart` decomposed** (1929 → ~320 lines): Extracted three widget files — `care_routine_generator_button.dart` (`CareRoutineGeneratorButton`), `care_task_form_sheet.dart` (`CareTaskFormSheet` with static `.show()` helper), `care_task_list.dart` (`CareTaskList` + `CareTaskDoneCounter` + all private task card/context menu/skeleton/error/empty-state widgets). Dead code `_HorizontalDatePicker` removed. +- **`_isGeneratingRoutine` moved to controller**: `DailyRoutineState` now has `isGeneratingRoutine` field; `CareDashboard.generateRoutine(Pet)` method added — sets flag, calls `CareRecommendationService`, returns tasks or rethrows. Screen reads `dashboard.isGeneratingRoutine` instead of local state. +- **SnackBar race condition fixed**: Removed `context.go('/care')` call that was clearing the onboarding SnackBar before the user could read it. `_onboardingSuccessHandled` flag prevents replay. +- **NutritionScreen empty state**: Replaced `SizedBox.shrink()` in `_HistoryList` with `PetfolioEmptyState(icon: monitor_weight_outlined, title: 'No weight entries yet', ...)`. +- `dart analyze` → **No issues found**. +- **Next step**: Health vitals UI and repository scaffold. + ## 2026-05-27 — Profile UI Rendering Fixes - **Bottom nav clipping fixed** (all 5 tab screens): Replaced hardcoded pixel values with `MediaQuery.paddingOf(context).bottom + 80` (nav height 68 + bottom margin 12 = 80 logical pixels above system inset). `matching_screen` was already correct. Changes: `pet_profile_screen` SizedBox 100→dynamic, `care_screen` ListView padding 120→dynamic, `social_screen` footer Padding 120→dynamic, `marketplace_screen` SliverPadding 100→dynamic.