From 7085a542909e7b92fa41fcda1a5fac9e62962888 Mon Sep 17 00:00:00 2001 From: Zahar Date: Fri, 26 Sep 2025 14:38:27 +0800 Subject: [PATCH] feat(ui): revamp onboarding, map, capture/review, and theme - Redesign WelcomeScreen and OnboardingFlow with gradients, branding, and updated messaging; refresh bottom navigation styling and animations - Enhance MapScreen with severity-based marker styling, category icons, improved cluster bubbles, legend overlay, and polished filter modal; refine camera fit and device centering logic - Revamp CaptureScreen and ReviewScreen UX with modern cards, AI analysis section, improved selection controls, and clearer notes/location presentation - Update theme to civic palette with expanded neutrals, surface containers, and refined button styles - Expand design tokens: add civic colors, light variants, and gradient definitions; align semantic mappings - Refresh English copy in en.json for welcome and onboarding - Redesign ReportCard with icons, severity/status chips, improved layout, and action menus Note: some new UI strings are temporarily hardcoded (English) and will be moved to i18n assets in a follow-up. --- assets/lang/en.json | 14 +- assets/tokens/design-tokens.json | 103 ++- lib/app.dart | 627 ++++++++++++---- lib/screens/map/map_screen.dart | 769 +++++++++++++------- lib/screens/report_flow/capture_screen.dart | 258 +++++-- lib/screens/report_flow/review_screen.dart | 738 ++++++++++++++----- lib/theme/themes.dart | 101 ++- lib/widgets/report_card.dart | 381 ++++++++-- lib/widgets/report_card.dart.backup | 390 ++++++++++ 9 files changed, 2643 insertions(+), 738 deletions(-) create mode 100644 lib/widgets/report_card.dart.backup diff --git a/assets/lang/en.json b/assets/lang/en.json index 4e4131c..418384b 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -81,19 +81,19 @@ "lang.en": "English", "lang.ms": "Bahasa Malaysia", "welcome.title": "Spot it. Snap it. Fix it.", - "welcome.subtitle": "Report city issues in seconds—help crews act faster.", + "welcome.subtitle": "Report city issues in seconds with AI-powered detection. Help create safer, better communities together.", "cta.continueGuest": "Continue as guest", "cta.signIn": "Sign in", "cta.skip": "Skip", "cta.next": "Next", "cta.getStarted": "Get started", "onboarding.header": "Welcome to FixMate", - "onboarding.title1": "Fast capture", - "onboarding.body1": "Take a photo and submit in under a minute.", - "onboarding.title2": "Map clarity", - "onboarding.body2": "See issues around you with smart clustering.", - "onboarding.title3": "Track progress", - "onboarding.body3": "Follow status updates as work gets done.", + "onboarding.title1": "Fast Issue Reporting", + "onboarding.body1": "Simply take a photo of any urban issue - our AI automatically identifies and categorizes the problem in seconds.", + "onboarding.title2": "Smart City Mapping", + "onboarding.body2": "View all reported issues on an interactive map with intelligent clustering and filtering options.", + "onboarding.title3": "Track Progress", + "onboarding.body3": "Follow the status of your reports from submission to resolution. Help make your community better.", "auth.title": "Sign in", "auth.signInWithApple": "Sign in with Apple", "auth.signInWithGoogle": "Sign in with Google", diff --git a/assets/tokens/design-tokens.json b/assets/tokens/design-tokens.json index 79acd18..4df7ebc 100644 --- a/assets/tokens/design-tokens.json +++ b/assets/tokens/design-tokens.json @@ -9,40 +9,67 @@ "brandPrimary": "#1E5CE0", "brandPrimaryDark": "#1748AC", "brandPrimaryLight": "#8CB0FF", + "civicBlue": "#2563EB", + "civicBlueDark": "#1E40AF", + "civicBlueLight": "#3B82F6", + "civicOrange": "#F97316", + "civicOrangeLight": "#FB923C", + "civicGreen": "#22C55E", + "civicGreenLight": "#4ADE80", "neutral0": "#FFFFFF", - "neutral10": "#F5F7FB", - "neutral20": "#E9EDF5", - "neutral30": "#D8DFEB", - "neutral40": "#B8C2D5", - "neutral50": "#99A3B8", - "neutral60": "#7A859E", - "neutral70": "#5C6782", - "neutral80": "#3D4765", - "neutral90": "#21283C", - "neutral100": "#0E1322", - "success": "#2E7D32", - "warning": "#ED6C02", - "error": "#D32F2F", - "info": "#0288D1" + "neutral10": "#F8FAFC", + "neutral20": "#F1F5F9", + "neutral30": "#E2E8F0", + "neutral40": "#CBD5E1", + "neutral50": "#94A3B8", + "neutral60": "#64748B", + "neutral70": "#475569", + "neutral80": "#334155", + "neutral90": "#1E293B", + "neutral100": "#0F172A", + "success": "#16A34A", + "successLight": "#86EFAC", + "warning": "#F59E0B", + "warningLight": "#FCD34D", + "error": "#DC2626", + "errorLight": "#FCA5A5", + "info": "#0EA5E9", + "infoLight": "#7DD3FC", + "surfaceGradient1": "#FAFAFA", + "surfaceGradient2": "#F8FAFC" }, "semantic": { - "primary": "{color.palette.brandPrimary}", - "primaryDark": "{color.palette.brandPrimaryDark}", - "primaryLight": "{color.palette.brandPrimaryLight}", - "background": "{color.palette.neutral10}", + "primary": "{color.palette.civicBlue}", + "primaryDark": "{color.palette.civicBlueDark}", + "primaryLight": "{color.palette.civicBlueLight}", + "secondary": "{color.palette.civicOrange}", + "secondaryLight": "{color.palette.civicOrangeLight}", + "accent": "{color.palette.civicGreen}", + "accentLight": "{color.palette.civicGreenLight}", + "background": "{color.palette.surfaceGradient1}", "surface": "{color.palette.neutral0}", "surfaceVariant": "{color.palette.neutral20}", + "surfaceContainer": "{color.palette.neutral10}", + "surfaceContainerHigh": "{color.palette.neutral20}", "onPrimary": "#FFFFFF", + "onSecondary": "#FFFFFF", + "onAccent": "#FFFFFF", "onBackground": "{color.palette.neutral100}", "onSurface": "{color.palette.neutral100}", + "onSurfaceVariant": "{color.palette.neutral80}", "outline": "{color.palette.neutral30}", + "outlineVariant": "{color.palette.neutral40}", "success": "{color.palette.success}", + "successLight": "{color.palette.successLight}", "onSuccess": "#FFFFFF", "warning": "{color.palette.warning}", + "warningLight": "{color.palette.warningLight}", "onWarning": "#FFFFFF", "error": "{color.palette.error}", + "errorLight": "{color.palette.errorLight}", "onError": "#FFFFFF", "info": "{color.palette.info}", + "infoLight": "{color.palette.infoLight}", "onInfo": "#FFFFFF" } }, @@ -161,14 +188,42 @@ }, "status": { "severity": { - "high": "#D32F2F", - "medium": "#ED6C02", - "low": "#2E7D32" + "high": "#DC2626", + "highLight": "#FCA5A5", + "medium": "#F59E0B", + "mediumLight": "#FCD34D", + "low": "#16A34A", + "lowLight": "#86EFAC" }, "report": { - "submitted": "#1E5CE0", - "in_progress": "#7A859E", - "fixed": "#2E7D32" + "submitted": "#2563EB", + "submittedLight": "#3B82F6", + "in_progress": "#64748B", + "in_progressLight": "#94A3B8", + "fixed": "#16A34A", + "fixedLight": "#86EFAC" } + }, + "gradients": { + "primary": [ + {"color": "#2563EB", "position": 0.0}, + {"color": "#3B82F6", "position": 1.0} + ], + "secondary": [ + {"color": "#F97316", "position": 0.0}, + {"color": "#FB923C", "position": 1.0} + ], + "success": [ + {"color": "#16A34A", "position": 0.0}, + {"color": "#22C55E", "position": 1.0} + ], + "surface": [ + {"color": "#FAFAFA", "position": 0.0}, + {"color": "#F8FAFC", "position": 1.0} + ], + "card": [ + {"color": "#FFFFFF", "position": 0.0}, + {"color": "#F8FAFC", "position": 1.0} + ] } } \ No newline at end of file diff --git a/lib/app.dart b/lib/app.dart index 4675304..60ba4a7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -28,12 +28,11 @@ class FixMateApp extends StatelessWidget { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], - supportedLocales: const [ - Locale('en'), - Locale('ms'), - ], + supportedLocales: const [Locale('en'), Locale('ms')], localeResolutionCallback: (locale, supported) { - debugPrint('[i18n] localeResolution: device=$locale, supported=$supported'); + debugPrint( + '[i18n] localeResolution: device=$locale, supported=$supported', + ); if (locale == null) return supported.first; for (final s in supported) { if (s.languageCode == locale.languageCode) { @@ -43,7 +42,9 @@ class FixMateApp extends StatelessWidget { return supported.first; }, builder: (context, child) { - debugPrint('[i18n] Building MaterialApp; locale=${localeProvider.locale}'); + debugPrint( + '[i18n] Building MaterialApp; locale=${localeProvider.locale}', + ); return child!; }, home: const StartRouter(), @@ -83,7 +84,10 @@ class _MainScreenState extends State { body: AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { - final offsetAnimation = Tween(begin: const Offset(0.0, 0.02), end: Offset.zero).animate(animation); + final offsetAnimation = Tween( + begin: const Offset(0.0, 0.02), + end: Offset.zero, + ).animate(animation); return FadeTransition( opacity: animation, child: SlideTransition(position: offsetAnimation, child: child), @@ -94,32 +98,98 @@ class _MainScreenState extends State { child: _screens[_selectedIndex], ), ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _selectedIndex, - onTap: (index) { - setState(() { - _selectedIndex = index; - }); - }, - type: BottomNavigationBarType.fixed, - items: [ - BottomNavigationBarItem( - icon: const Icon(Icons.camera_alt), - label: I18n.t(_navLabels[0]), + bottomNavigationBar: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), ), - BottomNavigationBarItem( - icon: const Icon(Icons.map), - label: I18n.t(_navLabels[1]), + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withOpacity(0.15), + blurRadius: 20, + offset: const Offset(0, -4), + ), + ], + ), + child: BottomNavigationBar( + currentIndex: _selectedIndex, + onTap: (index) { + setState(() { + _selectedIndex = index; + }); + }, + type: BottomNavigationBarType.fixed, + backgroundColor: Colors.transparent, + elevation: 0, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + selectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, ), - BottomNavigationBarItem( - icon: const Icon(Icons.list), - label: I18n.t(_navLabels[2]), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 12, ), - BottomNavigationBarItem( - icon: const Icon(Icons.settings), - label: I18n.t(_navLabels[3]), - ), - ], + items: [ + BottomNavigationBarItem( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _selectedIndex == 0 + ? Theme.of(context).colorScheme.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.camera_alt, size: 24), + ), + label: I18n.t(_navLabels[0]), + ), + BottomNavigationBarItem( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _selectedIndex == 1 + ? Theme.of(context).colorScheme.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.map, size: 24), + ), + label: I18n.t(_navLabels[1]), + ), + BottomNavigationBarItem( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _selectedIndex == 2 + ? Theme.of(context).colorScheme.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.list, size: 24), + ), + label: I18n.t(_navLabels[2]), + ), + BottomNavigationBarItem( + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _selectedIndex == 3 + ? Theme.of(context).colorScheme.primary.withOpacity(0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.settings, size: 24), + ), + label: I18n.t(_navLabels[3]), + ), + ], + ), ), ); } @@ -133,12 +203,8 @@ class PlaceholderScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text(title), - ), - body: Center( - child: Text('$title - Coming Soon!'), - ), + appBar: AppBar(title: Text(title)), + body: Center(child: Text('$title - Coming Soon!')), ); } } @@ -157,15 +223,15 @@ class _StartRouterState extends State { static const String _kOnboardedKey = 'onboarded_v1'; static const String _kUserModeKey = 'user_mode'; - + String? _userMode; - + @override void initState() { super.initState(); _load(); } - + Future _load() async { final prefs = await SharedPreferences.getInstance(); final flag = prefs.getBool(_kOnboardedKey) ?? false; @@ -178,7 +244,7 @@ class _StartRouterState extends State { }); } } - + Future _setGuestMode() async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kUserModeKey, 'guest'); @@ -188,7 +254,7 @@ class _StartRouterState extends State { }); } } - + Future _setOnboarded({bool asGuest = false}) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_kOnboardedKey, true); @@ -205,11 +271,13 @@ class _StartRouterState extends State { @override Widget build(BuildContext context) { - debugPrint('[i18n] StartRouter: hasMaterial=${Localizations.of(context, MaterialLocalizations) != null} locale=${Localizations.localeOf(context)}'); - + debugPrint( + '[i18n] StartRouter: hasMaterial=${Localizations.of(context, MaterialLocalizations) != null} locale=${Localizations.localeOf(context)}', + ); + Widget screen; String screenKey; - + if (_loading) { screen = const Scaffold(body: Center(child: CircularProgressIndicator())); screenKey = 'loading'; @@ -246,22 +314,22 @@ class _StartRouterState extends State { screen = const MainScreen(); screenKey = 'main'; } - + return AnimatedSwitcher( duration: const Duration(milliseconds: 420), transitionBuilder: (child, animation) { return FadeTransition( opacity: animation, child: SlideTransition( - position: Tween(begin: const Offset(0.0, 0.02), end: Offset.zero).animate(animation), + position: Tween( + begin: const Offset(0.0, 0.02), + end: Offset.zero, + ).animate(animation), child: child, ), ); }, - child: KeyedSubtree( - key: ValueKey(screenKey), - child: screen, - ), + child: KeyedSubtree(key: ValueKey(screenKey), child: screen), ); } } @@ -283,58 +351,173 @@ class WelcomeScreen extends StatelessWidget { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 12), - Row( - children: [ - CircleAvatar( - radius: 20, - backgroundColor: cs.primary, - child: const Icon(Icons.build, color: Colors.white), + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF8FAFC), Color(0xFFF1F5F9)], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + // Enhanced header with better branding + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF2563EB), Color(0xFF3B82F6)], + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color(0xFF2563EB).withOpacity(0.25), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.build, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'FixMate', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + ), + Text( + 'Civic Solutions', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: cs.onSurface.withOpacity(0.7)), + ), + ], + ), + ], + ), + const Spacer(), + // Enhanced main content with better visuals + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + children: [ + // Hero icon + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF22C55E), Color(0xFF4ADE80)], + ), + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.camera_alt, + color: Colors.white, + size: 48, + ), + ), + const SizedBox(height: 24), + Text( + 'Spot it. Snap it. Fix it.', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Report city issues in seconds with AI-powered detection. Help create safer, better communities together.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: cs.onSurface.withOpacity(0.8), + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], ), - const SizedBox(width: 12), - Text(I18n.t('app.name'), style: Theme.of(context).textTheme.titleLarge), - ], - ), - const Spacer(), - Text( - I18n.t('welcome.title'), - style: Theme.of(context).textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 12), - Text( - I18n.t('welcome.subtitle'), - style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: cs.onSurface.withOpacity(0.75)), - ), - const Spacer(), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: onContinue, - icon: const Icon(Icons.arrow_forward), - label: Text(I18n.t('cta.continueGuest')), ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: onSignIn, - icon: const Icon(Icons.login), - label: Text(I18n.t('cta.signIn')), + const Spacer(), + // Enhanced buttons + SizedBox( + height: 56, + child: ElevatedButton.icon( + onPressed: onContinue, + icon: const Icon(Icons.arrow_forward, size: 20), + label: Text( + 'Continue as Guest', + style: const TextStyle(fontSize: 16), + ), + style: ElevatedButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 2, + ), + ), ), - ), - const SizedBox(height: 8), - TextButton( - onPressed: onSkip, - child: Text(I18n.t('cta.skip')), - ), - ], + const SizedBox(height: 16), + SizedBox( + height: 56, + child: OutlinedButton.icon( + onPressed: onSignIn, + icon: const Icon(Icons.login, size: 20), + label: Text( + 'Sign In', + style: const TextStyle(fontSize: 16), + ), + style: OutlinedButton.styleFrom( + side: BorderSide(color: cs.primary, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + foregroundColor: cs.primary, + ), + ), + ), + const SizedBox(height: 12), + TextButton( + onPressed: onSkip, + child: Text( + 'Skip for now', + style: TextStyle( + color: cs.onSurface.withOpacity(0.7), + fontSize: 16, + ), + ), + ), + ], + ), ), ), ), @@ -342,7 +525,7 @@ class WelcomeScreen extends StatelessWidget { } } -/// Three-step onboarding flow with concise benefits +/// Enhanced onboarding flow with engaging civic messaging class OnboardingFlow extends StatefulWidget { const OnboardingFlow({super.key}); @@ -356,7 +539,10 @@ class _OnboardingFlowState extends State { void _next() { if (_index < 2) { - _pc.nextPage(duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic); + _pc.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); } else { Navigator.pop(context, true); } @@ -365,53 +551,209 @@ class _OnboardingFlowState extends State { @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - Widget page(String titleKey, String bodyKey, IconData icon) { + + Widget _buildPage({ + required String title, + required String subtitle, + required String description, + required IconData icon, + required Color gradientStart, + required Color gradientEnd, + }) { return Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircleAvatar(radius: 48, backgroundColor: cs.primary, child: Icon(icon, color: Colors.white, size: 40)), - const SizedBox(height: 24), - Text(I18n.t(titleKey), style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), textAlign: TextAlign.center), - const SizedBox(height: 12), - Text(I18n.t(bodyKey), style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center), + // Enhanced icon container with gradient + Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + gradient: LinearGradient(colors: [gradientStart, gradientEnd]), + borderRadius: BorderRadius.circular(32), + boxShadow: [ + BoxShadow( + color: gradientStart.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Icon(icon, color: Colors.white, size: 64), + ), + const SizedBox(height: 32), + Text( + title, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + subtitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: cs.primary, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + description, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: cs.onSurface.withOpacity(0.8), + height: 1.6, + ), + textAlign: TextAlign.center, + ), ], ), ); } return Scaffold( - appBar: AppBar( - title: Text(I18n.t('onboarding.header')), - ), - body: Column( - children: [ - Expanded( - child: PageView( - controller: _pc, - onPageChanged: (i) => setState(() => _index = i), - children: [ - page('onboarding.title1', 'onboarding.body1', Icons.flash_on), - page('onboarding.title2', 'onboarding.body2', Icons.map), - page('onboarding.title3', 'onboarding.body3', Icons.check_circle), - ], - ), + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF8FAFC), Color(0xFFF1F5F9)], ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), - child: Row( - children: [ - TextButton(onPressed: () => Navigator.pop(context, true), child: Text(I18n.t('cta.skip'))), - const Spacer(), - ElevatedButton( - onPressed: _next, - child: Text(_index < 2 ? I18n.t('cta.next') : I18n.t('cta.getStarted')), + ), + child: SafeArea( + child: Column( + children: [ + // Enhanced header + Padding( + padding: const EdgeInsets.all(24), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF2563EB), Color(0xFF3B82F6)], + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.build, + color: Colors.white, + size: 20, + ), + ), + const SizedBox(width: 12), + Text( + 'Welcome to FixMate', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ], ), - ], - ), + ), + // Page view + Expanded( + child: PageView( + controller: _pc, + onPageChanged: (i) => setState(() => _index = i), + children: [ + _buildPage( + title: 'Fast Issue Reporting', + subtitle: 'AI-Powered Detection', + description: + 'Simply take a photo of any urban issue - our AI automatically identifies and categorizes the problem in seconds.', + icon: Icons.camera_alt, + gradientStart: const Color(0xFF22C55E), + gradientEnd: const Color(0xFF4ADE80), + ), + _buildPage( + title: 'Smart City Mapping', + subtitle: 'Real-Time Visualization', + description: + 'View all reported issues on an interactive map with intelligent clustering and filtering options.', + icon: Icons.map, + gradientStart: const Color(0xFF2563EB), + gradientEnd: const Color(0xFF3B82F6), + ), + _buildPage( + title: 'Track Progress', + subtitle: 'Stay Informed', + description: + 'Follow the status of your reports from submission to resolution. Help make your community better.', + icon: Icons.check_circle, + gradientStart: const Color(0xFFF97316), + gradientEnd: const Color(0xFFFB923C), + ), + ], + ), + ), + // Enhanced bottom controls + Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // Page indicator + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (index) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: _index == index ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: _index == index ? cs.primary : cs.outline, + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ), + const SizedBox(height: 24), + Row( + children: [ + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + 'Skip', + style: TextStyle( + color: cs.onSurface.withOpacity(0.7), + fontSize: 16, + ), + ), + ), + const Spacer(), + ElevatedButton( + onPressed: _next, + style: ElevatedButton.styleFrom( + backgroundColor: cs.primary, + foregroundColor: cs.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Text( + _index < 2 ? 'Next' : 'Get Started', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ], ), - ], + ), ), ); } @@ -433,14 +775,21 @@ class SignInScreen extends StatelessWidget { children: [ const SizedBox(height: 24), ElevatedButton.icon( - onPressed: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('auth.comingSoon')))), + onPressed: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(I18n.t('auth.comingSoon'))), + ), icon: const Icon(Icons.apple), label: Text(I18n.t('auth.signInWithApple')), - style: ElevatedButton.styleFrom(backgroundColor: cs.onSurface, foregroundColor: cs.surface), + style: ElevatedButton.styleFrom( + backgroundColor: cs.onSurface, + foregroundColor: cs.surface, + ), ), const SizedBox(height: 12), OutlinedButton.icon( - onPressed: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('auth.comingSoon')))), + onPressed: () => ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(I18n.t('auth.comingSoon'))), + ), icon: const Icon(Icons.g_mobiledata), label: Text(I18n.t('auth.signInWithGoogle')), ), @@ -463,4 +812,4 @@ class SignInScreen extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/map/map_screen.dart b/lib/screens/map/map_screen.dart index d06ccd5..3a78ef9 100644 --- a/lib/screens/map/map_screen.dart +++ b/lib/screens/map/map_screen.dart @@ -51,7 +51,10 @@ class _MapScreenState extends State { void _setDefaultDateRange() { final now = DateTime.now(); - _filterDateRange = DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now); + _filterDateRange = DateTimeRange( + start: now.subtract(const Duration(days: 30)), + end: now, + ); } Future _refresh() async { @@ -64,11 +67,17 @@ class _MapScreenState extends State { _applyFilters(); // If we have filtered reports, fit; otherwise try device location if (_filteredReports.isNotEmpty) { - debugPrint('[map] _refresh: filtered=${_filteredReports.length}; scheduling fitBounds postFrame'); + debugPrint( + '[map] _refresh: filtered=${_filteredReports.length}; scheduling fitBounds postFrame', + ); WidgetsBinding.instance.addPostFrameCallback((_) => _fitToBounds()); } else { - debugPrint('[map] _refresh: filtered=0; scheduling centerOnDeviceOrDefault postFrame'); - WidgetsBinding.instance.addPostFrameCallback((_) => _centerOnDeviceOrDefault()); + debugPrint( + '[map] _refresh: filtered=0; scheduling centerOnDeviceOrDefault postFrame', + ); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _centerOnDeviceOrDefault(), + ); } } @@ -76,12 +85,16 @@ class _MapScreenState extends State { try { final pos = await LocationService.getBestAvailablePosition(); if (pos != null) { - debugPrint('[map] _centerOnDeviceOrDefault: moving to device location (${pos.latitude}, ${pos.longitude})'); + debugPrint( + '[map] _centerOnDeviceOrDefault: moving to device location (${pos.latitude}, ${pos.longitude})', + ); _mapController.move(LatLng(pos.latitude, pos.longitude), _defaultZoom); return; } } catch (_) {} - debugPrint('[map] _centerOnDeviceOrDefault: moving to default center ($_defaultCenter) zoom=$_defaultZoom'); + debugPrint( + '[map] _centerOnDeviceOrDefault: moving to default center ($_defaultCenter) zoom=$_defaultZoom', + ); _mapController.move(_defaultCenter, _defaultZoom); } @@ -96,8 +109,16 @@ class _MapScreenState extends State { final created = DateTime.tryParse(r.createdAt); if (created == null) return false; // include the end day fully - final endInclusive = DateTime(range.end.year, range.end.month, range.end.day, 23, 59, 59); - if (created.isBefore(range.start) || created.isAfter(endInclusive)) return false; + final endInclusive = DateTime( + range.end.year, + range.end.month, + range.end.day, + 23, + 59, + 59, + ); + if (created.isBefore(range.start) || created.isAfter(endInclusive)) + return false; } return true; }).toList(); @@ -111,7 +132,9 @@ class _MapScreenState extends State { void _fitToBounds() { if (_filteredReports.isEmpty) return; - final points = _filteredReports.map((r) => LatLng(r.location.lat, r.location.lng)).toList(); + final points = _filteredReports + .map((r) => LatLng(r.location.lat, r.location.lng)) + .toList(); if (points.isEmpty) return; final bounds = LatLngBounds.fromPoints(points); try { @@ -126,16 +149,37 @@ class _MapScreenState extends State { List _buildMarkers() { return _filteredReports.map((r) { final latlng = LatLng(r.location.lat, r.location.lng); + final severityColor = _getSeverityColor(r.severity); + return Marker( point: latlng, - width: 40, - height: 40, + width: 48, + height: 48, child: GestureDetector( onTap: () => _onMarkerTap(r), child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.location_on, color: r.severity.color, size: 36), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: severityColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: severityColor.withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + _getCategoryIcon(r.category), + color: Colors.white, + size: 20, + ), + ), ], ), ), @@ -143,6 +187,34 @@ class _MapScreenState extends State { }).toList(); } + Color _getSeverityColor(Severity severity) { + switch (severity) { + case Severity.high: + return const Color(0xFFDC2626); + case Severity.medium: + return const Color(0xFFF59E0B); + case Severity.low: + return const Color(0xFF16A34A); + } + } + + IconData _getCategoryIcon(Category category) { + switch (category) { + case Category.pothole: + return Icons.warning; + case Category.streetlight: + return Icons.lightbulb; + case Category.signage: + return Icons.traffic; + case Category.trash: + return Icons.delete; + case Category.drainage: + return Icons.water; + case Category.other: + return Icons.category; + } + } + void _onMarkerTap(Report r) { showModalBottomSheet( context: context, @@ -162,17 +234,26 @@ class _MapScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(I18n.t(r.category.key), style: Theme.of(context).textTheme.titleLarge), + Text( + I18n.t(r.category.key), + style: Theme.of(context).textTheme.titleLarge, + ), const SizedBox(height: 8), - Row(children: [ - SeverityBadge(severity: r.severity, small: true), - const SizedBox(width: 8), - StatusBadge(status: r.status, small: true), - ]), + Row( + children: [ + SeverityBadge(severity: r.severity, small: true), + const SizedBox(width: 8), + StatusBadge(status: r.status, small: true), + ], + ), const SizedBox(height: 8), - Text('${I18n.t('label.location')}: ${r.location.lat.toStringAsFixed(6)}, ${r.location.lng.toStringAsFixed(6)}'), + Text( + '${I18n.t('label.location')}: ${r.location.lat.toStringAsFixed(6)}, ${r.location.lng.toStringAsFixed(6)}', + ), const SizedBox(height: 4), - Text('${I18n.t('label.createdAt')}: ${r.createdAt.split('T').first}'), + Text( + '${I18n.t('label.createdAt')}: ${r.createdAt.split('T').first}', + ), ], ), ), @@ -186,16 +267,27 @@ class _MapScreenState extends State { onPressed: () { Navigator.of(ctx).pop(); // close sheet // Navigate to My Reports tab/screen (simplest) - Navigator.push(context, MaterialPageRoute(builder: (_) => const MyReportsScreen())); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const MyReportsScreen(), + ), + ); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(I18n.t('map.openedInMyReports') ?? I18n.t('nav.myReports'))), + SnackBar( + content: Text( + I18n.t('map.openedInMyReports') ?? + I18n.t('nav.myReports'), + ), + ), ); }, child: Text(I18n.t('btn.viewDetails')), ), const SizedBox(width: 8), ElevatedButton( - onPressed: () => _openExternalMap(r.location.lat, r.location.lng), + onPressed: () => + _openExternalMap(r.location.lat, r.location.lng), child: Text(I18n.t('btn.openMap')), ), ], @@ -239,13 +331,19 @@ class _MapScreenState extends State { } Future _openExternalMap(double lat, double lng) async { - final uri = Uri.parse('https://www.google.com/maps/search/?api=1&query=$lat,$lng'); + final uri = Uri.parse( + 'https://www.google.com/maps/search/?api=1&query=$lat,$lng', + ); try { if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('error.openMap')))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(I18n.t('error.openMap')))); } } catch (e) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('error.openMap')))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(I18n.t('error.openMap')))); } } @@ -261,146 +359,184 @@ class _MapScreenState extends State { context: context, isScrollControlled: true, builder: (ctx) { - return StatefulBuilder(builder: (context, setModalState) { - return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(I18n.t('btn.filter'), style: Theme.of(context).textTheme.titleMedium), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(ctx), - ) - ], - ), - const SizedBox(height: 8), - Align(alignment: Alignment.centerLeft, child: Text(I18n.t('filter.category'))), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: Category.all.map((c) { - final selected = selCategories.contains(c); - return FilterChip( - label: Text(I18n.t(c.key)), - selected: selected, - onSelected: (v) { - setModalState(() { - if (v) { - selCategories.add(c); - } else { - selCategories.remove(c); - } - }); - }, - ); - }).toList(), - ), - const SizedBox(height: 12), - Align(alignment: Alignment.centerLeft, child: Text(I18n.t('filter.severity'))), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: Severity.all.map((s) { - final selected = selSeverities.contains(s); - return FilterChip( - label: Text(I18n.t(s.key)), - selected: selected, - onSelected: (v) { - setModalState(() { - if (v) { - selSeverities.add(s); - } else { - selSeverities.remove(s); - } - }); - }, - ); - }).toList(), - ), - const SizedBox(height: 12), - Align(alignment: Alignment.centerLeft, child: Text(I18n.t('filter.status'))), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: Status.all.map((st) { - final selected = selStatuses.contains(st); - return FilterChip( - label: Text(I18n.t(st.key)), - selected: selected, - onSelected: (v) { - setModalState(() { - if (v) { - selStatuses.add(st); - } else { - selStatuses.remove(st); - } - }); - }, - ); - }).toList(), - ), - const SizedBox(height: 12), - Align(alignment: Alignment.centerLeft, child: Text(I18n.t('filter.dateRange'))), - const SizedBox(height: 8), - Row(children: [ - Expanded( - child: OutlinedButton( - onPressed: () async { - final picked = await showDateRangePicker( - context: context, - firstDate: DateTime(2000), - lastDate: now, - initialDateRange: selRange ?? DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now), - ); - if (picked != null) { - setModalState(() => selRange = picked); - } - }, - child: Text(selRange == null ? I18n.t('filter.dateRange') : '${selRange!.start.toLocal().toIso8601String().split('T').first} - ${selRange!.end.toLocal().toIso8601String().split('T').first}'), - ), - ), - ]), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - onPressed: () { - setModalState(() { - selCategories = Category.all.toSet(); - selSeverities = Severity.all.toSet(); - selStatuses = Status.all.toSet(); - selRange = DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now); - }); - }, - child: Text(I18n.t('btn.reset')), - ), - ElevatedButton( - onPressed: () { - // Apply - setState(() { - _filterCategories = selCategories; - _filterSeverities = selSeverities; - _filterStatuses = selStatuses; - _filterDateRange = selRange; - }); - _applyFilters(); - Navigator.pop(ctx); - }, - child: Text(I18n.t('btn.apply')), - ), - ], - ), - ]), + return StatefulBuilder( + builder: (context, setModalState) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom, ), - ), - ); - }); + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + I18n.t('btn.filter'), + style: Theme.of(context).textTheme.titleMedium, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text(I18n.t('filter.category')), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: Category.all.map((c) { + final selected = selCategories.contains(c); + return FilterChip( + label: Text(I18n.t(c.key)), + selected: selected, + onSelected: (v) { + setModalState(() { + if (v) { + selCategories.add(c); + } else { + selCategories.remove(c); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text(I18n.t('filter.severity')), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: Severity.all.map((s) { + final selected = selSeverities.contains(s); + return FilterChip( + label: Text(I18n.t(s.key)), + selected: selected, + onSelected: (v) { + setModalState(() { + if (v) { + selSeverities.add(s); + } else { + selSeverities.remove(s); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text(I18n.t('filter.status')), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: Status.all.map((st) { + final selected = selStatuses.contains(st); + return FilterChip( + label: Text(I18n.t(st.key)), + selected: selected, + onSelected: (v) { + setModalState(() { + if (v) { + selStatuses.add(st); + } else { + selStatuses.remove(st); + } + }); + }, + ); + }).toList(), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text(I18n.t('filter.dateRange')), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () async { + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2000), + lastDate: now, + initialDateRange: + selRange ?? + DateTimeRange( + start: now.subtract( + const Duration(days: 30), + ), + end: now, + ), + ); + if (picked != null) { + setModalState(() => selRange = picked); + } + }, + child: Text( + selRange == null + ? I18n.t('filter.dateRange') + : '${selRange!.start.toLocal().toIso8601String().split('T').first} - ${selRange!.end.toLocal().toIso8601String().split('T').first}', + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + setModalState(() { + selCategories = Category.all.toSet(); + selSeverities = Severity.all.toSet(); + selStatuses = Status.all.toSet(); + selRange = DateTimeRange( + start: now.subtract(const Duration(days: 30)), + end: now, + ); + }); + }, + child: Text(I18n.t('btn.reset')), + ), + ElevatedButton( + onPressed: () { + // Apply + setState(() { + _filterCategories = selCategories; + _filterSeverities = selSeverities; + _filterStatuses = selStatuses; + _filterDateRange = selRange; + }); + _applyFilters(); + Navigator.pop(ctx); + }, + child: Text(I18n.t('btn.apply')), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); }, ); } @@ -413,101 +549,200 @@ class _MapScreenState extends State { appBar: AppBar( title: Text(I18n.t('nav.map')), actions: [ - IconButton(icon: const Icon(Icons.filter_list), onPressed: _openFilterModal), + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: _openFilterModal, + ), IconButton(icon: const Icon(Icons.refresh), onPressed: _refresh), ], ), body: _loading ? const Center(child: CircularProgressIndicator()) : _filteredReports.isEmpty - ? Center(child: Text(I18n.t('map.noReports'))) - : Stack( + ? Center(child: Text(I18n.t('map.noReports'))) + : Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: _filteredReports.isNotEmpty + ? LatLng( + _filteredReports.first.location.lat, + _filteredReports.first.location.lng, + ) + : _defaultCenter, + initialZoom: _defaultZoom, + minZoom: 3.0, + maxZoom: 18.0, + ), children: [ - FlutterMap( - mapController: _mapController, - options: MapOptions( - initialCenter: _filteredReports.isNotEmpty - ? LatLng(_filteredReports.first.location.lat, _filteredReports.first.location.lng) - : _defaultCenter, - initialZoom: _defaultZoom, - minZoom: 3.0, - maxZoom: 18.0, - ), - children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.example.citypulse', - ), - if (markers.isNotEmpty) - MarkerClusterLayerWidget( - options: MarkerClusterLayerOptions( - maxClusterRadius: 60, - size: const Size(40, 40), - markers: markers, - spiderfyCircleRadius: 80, - showPolygon: false, - disableClusteringAtZoom: 16, - builder: (context, markers) { - return Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, - ), - child: Text( - markers.length.toString(), - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), - ), - ); - }, - onClusterTap: (cluster) { - try { - final pts = cluster.markers.map((m) => m.point).toList(); - final bounds = LatLngBounds.fromPoints(pts); - _mapController.fitCamera( - CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(60)), - ); - } catch (_) {} - }, - ), - ), - ], + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.example.citypulse', ), - // Legend overlay - Positioned( - top: 16, - right: 16, - child: Card( - elevation: 2, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _legendItem(Severity.high, I18n.t('severity.high')), - const SizedBox(width: 8), - _legendItem(Severity.medium, I18n.t('severity.medium')), - const SizedBox(width: 8), - _legendItem(Severity.low, I18n.t('severity.low')), - ], - ), + if (markers.isNotEmpty) + MarkerClusterLayerWidget( + options: MarkerClusterLayerOptions( + maxClusterRadius: 60, + size: const Size(48, 48), + markers: markers, + spiderfyCircleRadius: 80, + showPolygon: false, + disableClusteringAtZoom: 16, + builder: (context, markers) { + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF2563EB), + Color(0xFF3B82F6), + ], + ), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: const Color( + 0xFF2563EB, + ).withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Text( + markers.length.toString(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ); + }, + onClusterTap: (cluster) { + try { + final pts = cluster.markers + .map((m) => m.point) + .toList(); + final bounds = LatLngBounds.fromPoints(pts); + _mapController.fitCamera( + CameraFit.bounds( + bounds: bounds, + padding: const EdgeInsets.all(60), + ), + ); + } catch (_) {} + }, ), ), - ), ], ), + // Enhanced Legend overlay + Positioned( + top: 16, + right: 16, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Theme.of( + context, + ).shadowColor.withOpacity(0.15), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 1, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Issue Severity', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 12), + _enhancedLegendItem(Severity.high, 'High Priority'), + const SizedBox(height: 8), + _enhancedLegendItem(Severity.medium, 'Medium Priority'), + const SizedBox(height: 8), + _enhancedLegendItem(Severity.low, 'Low Priority'), + ], + ), + ), + ), + ], + ), ); } Widget _legendItem(Severity s, String label) { return Row( children: [ - Container(width: 12, height: 12, decoration: BoxDecoration(color: s.color, shape: BoxShape.circle)), + Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: s.color, shape: BoxShape.circle), + ), const SizedBox(width: 6), Text(label, style: const TextStyle(fontSize: 12)), ], ); } + + Widget _enhancedLegendItem(Severity s, String label) { + return Row( + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: _getSeverityColor(s), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ); + } } /// Full screen report details used elsewhere in the app. @@ -522,25 +757,47 @@ class MapReportDetails extends StatelessWidget { appBar: AppBar(title: Text(I18n.t('btn.details'))), body: SingleChildScrollView( padding: const EdgeInsets.all(16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (kIsWeb && report.base64Photo != null) - Image.memory(base64Decode(report.base64Photo!)) - else if (!kIsWeb && report.photoPath != null) - Image.file(File(report.photoPath!)) - else - Container(height: 180, color: Colors.grey.shade200, alignment: Alignment.center, child: const Icon(Icons.photo, size: 64)), - const SizedBox(height: 12), - Text(I18n.t(report.category.key), style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 8), - Row(children: [SeverityBadge(severity: report.severity), const SizedBox(width: 8), StatusBadge(status: report.status)]), - const SizedBox(height: 12), - Text('${I18n.t('label.location')}: ${report.location.lat.toStringAsFixed(6)}, ${report.location.lng.toStringAsFixed(6)}'), - const SizedBox(height: 8), - Text('${I18n.t('label.createdAt')}: ${created != null ? created.toLocal().toString() : report.createdAt}'), - const SizedBox(height: 8), - if (report.notes != null) Text('${I18n.t('label.notes')}: ${report.notes}'), - ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (kIsWeb && report.base64Photo != null) + Image.memory(base64Decode(report.base64Photo!)) + else if (!kIsWeb && report.photoPath != null) + Image.file(File(report.photoPath!)) + else + Container( + height: 180, + color: Colors.grey.shade200, + alignment: Alignment.center, + child: const Icon(Icons.photo, size: 64), + ), + const SizedBox(height: 12), + Text( + I18n.t(report.category.key), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Row( + children: [ + SeverityBadge(severity: report.severity), + const SizedBox(width: 8), + StatusBadge(status: report.status), + ], + ), + const SizedBox(height: 12), + Text( + '${I18n.t('label.location')}: ${report.location.lat.toStringAsFixed(6)}, ${report.location.lng.toStringAsFixed(6)}', + ), + const SizedBox(height: 8), + Text( + '${I18n.t('label.createdAt')}: ${created != null ? created.toLocal().toString() : report.createdAt}', + ), + const SizedBox(height: 8), + if (report.notes != null) + Text('${I18n.t('label.notes')}: ${report.notes}'), + ], + ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/report_flow/capture_screen.dart b/lib/screens/report_flow/capture_screen.dart index b70b3ca..98800b6 100644 --- a/lib/screens/report_flow/capture_screen.dart +++ b/lib/screens/report_flow/capture_screen.dart @@ -7,23 +7,23 @@ import '../../services/mock_ai.dart'; import '../../models/report.dart'; import '../../models/enums.dart'; import 'review_screen.dart'; - + class CaptureScreen extends StatefulWidget { const CaptureScreen({super.key}); - + @override State createState() => _CaptureScreenState(); } - + class _CaptureScreenState extends State { final ImagePicker _picker = ImagePicker(); bool _isLoading = false; - + Future _pickImage(ImageSource source) async { setState(() { _isLoading = true; }); - + try { final XFile? image = await _picker.pickImage( source: source, @@ -31,15 +31,15 @@ class _CaptureScreenState extends State { maxHeight: 1080, imageQuality: 85, ); - + if (image != null) { await _processImage(image, source); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error picking image: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error picking image: $e'))); } } finally { if (mounted) { @@ -49,24 +49,26 @@ class _CaptureScreenState extends State { } } } - + Future _processImage(XFile image, ImageSource source) async { try { // Get current position (Geolocator.Position) final position = await LocationService.getCurrentPosition(); - + if (position == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Unable to get location. Please try again.')), + const SnackBar( + content: Text('Unable to get location. Please try again.'), + ), ); } return; } - + // Convert Position -> LocationData (app model) final locationData = LocationService.positionToLocationData(position); - + // Generate AI suggestion (seeded deterministic) final aiSuggestion = MockAIService.generateSuggestion( id: DateTime.now().millisecondsSinceEpoch.toString(), @@ -75,7 +77,7 @@ class _CaptureScreenState extends State { lng: locationData.lng, photoSizeBytes: await image.length(), ); - + // Create report with AI suggestion final report = Report( id: DateTime.now().millisecondsSinceEpoch.toString(), @@ -96,72 +98,204 @@ class _CaptureScreenState extends State { aiSuggestion: aiSuggestion, schemaVersion: 1, ); - + if (mounted) { Navigator.push( context, MaterialPageRoute( - builder: (context) => ReviewScreen(report: report, imageFile: File(image.path)), + builder: (context) => + ReviewScreen(report: report, imageFile: File(image.path)), ), ); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error processing image: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error processing image: $e'))); } } } - + @override Widget build(BuildContext context) { - debugPrint('[i18n] CaptureScreen: locale=${I18n.currentLocale} prompt=${I18n.t('capture.prompt')}'); + debugPrint( + '[i18n] CaptureScreen: locale=${I18n.currentLocale} prompt=${I18n.t('capture.prompt')}', + ); + final cs = Theme.of(context).colorScheme; + return Scaffold( appBar: AppBar( - title: Text(I18n.t('nav.report')), + title: Text( + 'Report Issue', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + elevation: 0, + backgroundColor: Colors.transparent, + foregroundColor: cs.onSurface, ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - I18n.t('capture.prompt'), - style: Theme.of(context).textTheme.headlineSmall, - textAlign: TextAlign.center, + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF8FAFC), Color(0xFFF1F5F9)], + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Enhanced header section + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + children: [ + // Camera icon with gradient + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF22C55E), Color(0xFF4ADE80)], + ), + borderRadius: BorderRadius.circular(20), + ), + child: const Icon( + Icons.camera_alt, + color: Colors.white, + size: 48, + ), + ), + const SizedBox(height: 20), + Text( + 'Report City Issues', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.w700, + color: cs.onSurface, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Take a photo of any urban maintenance issue like potholes, broken streetlights, or damaged signage.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: cs.onSurface.withOpacity(0.7), + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 32), + if (_isLoading) + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: const Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + 'Processing image...', + style: TextStyle(fontSize: 16), + ), + ], + ), + ) + else ...[ + // Enhanced camera button + Container( + width: double.infinity, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + colors: [Color(0xFF2563EB), Color(0xFF3B82F6)], + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF2563EB).withOpacity(0.3), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: ElevatedButton.icon( + onPressed: () => _pickImage(ImageSource.camera), + icon: const Icon(Icons.camera_alt, size: 24), + label: const Text( + 'Take Photo', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ), + ), + ), + const SizedBox(height: 16), + // Enhanced gallery button + Container( + width: double.infinity, + height: 64, + child: OutlinedButton.icon( + onPressed: () => _pickImage(ImageSource.gallery), + icon: const Icon(Icons.photo_library, size: 24), + label: const Text( + 'Choose from Gallery', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + style: OutlinedButton.styleFrom( + side: BorderSide(color: cs.primary, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + foregroundColor: cs.primary, + padding: const EdgeInsets.symmetric(vertical: 20), + ), + ), + ), + ], + ], ), - const SizedBox(height: 32), - if (_isLoading) - const CircularProgressIndicator() - else ...[ - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => _pickImage(ImageSource.camera), - icon: const Icon(Icons.camera_alt), - label: Text(I18n.t('btn.camera')), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _pickImage(ImageSource.gallery), - icon: const Icon(Icons.photo_library), - label: Text(I18n.t('btn.gallery')), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ), - ], - ], + ), ), ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/report_flow/review_screen.dart b/lib/screens/report_flow/review_screen.dart index 62a479c..b959494 100644 --- a/lib/screens/report_flow/review_screen.dart +++ b/lib/screens/report_flow/review_screen.dart @@ -57,18 +57,18 @@ class _ReviewScreenState extends State { await StorageService.saveReport(updatedReport); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(I18n.t('toast.reportSaved'))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(I18n.t('toast.reportSaved')))); // Navigate back to main screen Navigator.of(context).popUntil((route) => route.isFirst); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error saving report: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error saving report: $e'))); } } finally { if (mounted) { @@ -81,9 +81,17 @@ class _ReviewScreenState extends State { @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( appBar: AppBar( - title: Text(I18n.t('btn.submit')), + title: Text( + 'Review & Submit', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + elevation: 0, + backgroundColor: Colors.transparent, + foregroundColor: cs.onSurface, actions: [ TextButton( onPressed: _isSubmitting ? null : _submitReport, @@ -93,90 +101,233 @@ class _ReviewScreenState extends State { height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : Text(I18n.t('btn.submit')), + : Text( + 'Submit', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: cs.primary, + ), + ), ), ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Image preview - Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF8FAFC), Color(0xFFF1F5F9)], + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Enhanced image preview + Container( + height: 240, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.file(widget.imageFile, fit: BoxFit.cover), + ), ), - child: Image.file( - widget.imageFile, - fit: BoxFit.cover, - ), - ), - const SizedBox(height: 24), + const SizedBox(height: 32), - // AI Suggestion Card - Card( - child: Padding( - padding: const EdgeInsets.all(16), + // AI Suggestion Card + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFF0F9FF), Color(0xFFE0F2FE)], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: const Color(0xFF0EA5E9).withOpacity(0.3), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Icon( - Icons.lightbulb, - color: Theme.of(context).primaryColor, - ), - const SizedBox(width: 8), - Text( - I18n.t('label.aiSuggestion'), - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - _buildSuggestionChip( - widget.report.aiSuggestion.category.displayName, - Colors.blue.shade100, - ), - const SizedBox(width: 8), - _buildSuggestionChip( - widget.report.aiSuggestion.severity.displayName, - _getSeverityColor(widget.report.aiSuggestion.severity), - ), - const SizedBox(width: 8), - _buildSuggestionChip( - '${(widget.report.aiSuggestion.confidence * 100).round()}%', - Colors.grey.shade100, - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: ElevatedButton( - onPressed: () { - setState(() { - _selectedCategory = widget.report.aiSuggestion.category; - _selectedSeverity = widget.report.aiSuggestion.severity; - }); - }, - child: Text(I18n.t('btn.useSuggestion')), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0EA5E9), Color(0xFF38BDF8)], + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.smart_toy, + color: Colors.white, + size: 20, ), ), - const SizedBox(width: 8), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Analysis Complete', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + Text( + 'Smart detection with ${(widget.report.aiSuggestion.confidence * 100).round()}% confidence', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: cs.onSurface.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: const Color(0xFF0EA5E9), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0EA5E9).withOpacity(0.3), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.category, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 8), + Text( + widget.report.aiSuggestion.category.displayName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: const Color(0xFF16A34A), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFF16A34A).withOpacity(0.3), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 8), + Text( + widget.report.aiSuggestion.severity.displayName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ Expanded( - child: OutlinedButton( + child: ElevatedButton.icon( onPressed: () { - // Keep manual selections + setState(() { + _selectedCategory = + widget.report.aiSuggestion.category; + _selectedSeverity = + widget.report.aiSuggestion.severity; + }); }, - child: Text(I18n.t('btn.keepManual')), + icon: const Icon(Icons.check_circle, size: 18), + label: const Text('Use AI Suggestion'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0EA5E9), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.edit, size: 18), + label: const Text('Edit Manually'), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: const Color(0xFF0EA5E9), + width: 1.5, + ), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + foregroundColor: const Color(0xFF0EA5E9), + ), ), ), ], @@ -184,106 +335,349 @@ class _ReviewScreenState extends State { ], ), ), - ), - const SizedBox(height: 24), + const SizedBox(height: 32), - // Category Selection - Text( - I18n.t('label.category'), - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: Category.values.map((category) { - return ChoiceChip( - label: Text(category.displayName), - selected: _selectedCategory == category, - onSelected: (selected) { - if (selected) { - setState(() { - _selectedCategory = category; - }); - } - }, - ); - }).toList(), - ), - const SizedBox(height: 24), - - // Severity Selection - Text( - I18n.t('label.severity'), - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: Severity.values.map((severity) { - return ChoiceChip( - label: Text(severity.displayName), - selected: _selectedSeverity == severity, - onSelected: (selected) { - if (selected) { - setState(() { - _selectedSeverity = severity; - }); - } - }, - ); - }).toList(), - ), - const SizedBox(height: 24), - - // Notes - Text( - I18n.t('label.notes'), - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - TextField( - controller: _notesController, - decoration: InputDecoration( - hintText: 'Add any additional notes...', - border: const OutlineInputBorder(), + // Category Selection + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.category, color: cs.primary, size: 20), + const SizedBox(width: 8), + Text( + 'Issue Category', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: Category.values.map((category) { + final isSelected = _selectedCategory == category; + return Container( + decoration: BoxDecoration( + border: Border.all( + color: isSelected ? cs.primary : cs.outline, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(16), + color: isSelected + ? cs.primary.withOpacity(0.1) + : cs.surface, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + setState(() { + _selectedCategory = category; + }); + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Text( + category.displayName, + style: TextStyle( + color: isSelected + ? cs.primary + : cs.onSurface, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ], + ), ), - maxLines: 3, - ), - const SizedBox(height: 24), + const SizedBox(height: 24), - // Location info - Text( - I18n.t('label.location'), - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - 'Lat: ${widget.report.location.lat.toStringAsFixed(6)}, ' - 'Lng: ${widget.report.location.lng.toStringAsFixed(6)}', - ), - if (widget.report.location.accuracy != null) - Text('Accuracy: ${widget.report.location.accuracy!.toStringAsFixed(1)}m'), - ], + // Severity Selection + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warning, color: cs.secondary, size: 20), + const SizedBox(width: 8), + Text( + 'Severity Level', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: Severity.values.map((severity) { + final isSelected = _selectedSeverity == severity; + final color = severity == Severity.high + ? const Color(0xFFDC2626) + : severity == Severity.medium + ? const Color(0xFFF59E0B) + : const Color(0xFF16A34A); + return Container( + decoration: BoxDecoration( + border: Border.all( + color: isSelected ? color : cs.outline, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(16), + color: isSelected + ? color.withOpacity(0.1) + : cs.surface, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + setState(() { + _selectedSeverity = severity; + }); + }, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 12, color: color), + const SizedBox(width: 8), + Text( + severity.displayName, + style: TextStyle( + color: isSelected + ? color + : cs.onSurface, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Notes Section + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.note, color: cs.primary, size: 20), + const SizedBox(width: 8), + Text( + 'Additional Notes', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + TextField( + controller: _notesController, + decoration: InputDecoration( + hintText: 'Add any additional details or context...', + hintStyle: TextStyle( + color: cs.onSurface.withOpacity(0.6), + ), + filled: true, + fillColor: cs.surfaceContainerHighest.withOpacity(0.3), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: cs.outline), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: cs.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: cs.primary, width: 2), + ), + contentPadding: const EdgeInsets.all(16), + ), + maxLines: 4, + style: TextStyle(color: cs.onSurface), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Location Info + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.location_on, color: cs.secondary, size: 20), + const SizedBox(width: 8), + Text( + 'Location Details', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant), + ), + child: Column( + children: [ + Row( + children: [ + Icon( + Icons.gps_fixed, + size: 16, + color: cs.onSurface.withOpacity(0.7), + ), + const SizedBox(width: 8), + Text( + 'Coordinates', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: cs.onSurface.withOpacity(0.7), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '${widget.report.location.lat.toStringAsFixed(6)}, ${widget.report.location.lng.toStringAsFixed(6)}', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: cs.onSurface, + fontFamily: 'monospace', + ), + ), + if (widget.report.location.accuracy != null) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.my_location, + size: 16, + color: cs.onSurface.withOpacity(0.7), + ), + const SizedBox(width: 8), + Text( + 'Accuracy: ${widget.report.location.accuracy!.toStringAsFixed(1)}m', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: cs.onSurface.withOpacity(0.7), + ), + ), + ], + ), + ], + ], + ), + ), + ], + ), + ), + ], + ), ), ), ); } - - Widget _buildSuggestionChip(String label, Color color) { - return Chip( - label: Text(label), - backgroundColor: color, - ); - } - - Color _getSeverityColor(Severity severity) { - switch (severity) { - case Severity.high: - return Colors.red.shade100; - case Severity.medium: - return Colors.orange.shade100; - case Severity.low: - return Colors.green.shade100; - } - } -} \ No newline at end of file +} diff --git a/lib/theme/themes.dart b/lib/theme/themes.dart index 10ec84e..9f7e778 100644 --- a/lib/theme/themes.dart +++ b/lib/theme/themes.dart @@ -2,26 +2,50 @@ import 'package:flutter/material.dart'; /// Design tokens and themes for FixMate (Civic Pro Minimal) class AppColors { - static const Color brandPrimary = Color(0xFF1E5CE0); - static const Color brandPrimaryDark = Color(0xFF1748AC); - static const Color brandPrimaryLight = Color(0xFF8CB0FF); + // Primary civic colors + static const Color civicBlue = Color(0xFF2563EB); + static const Color civicBlueDark = Color(0xFF1E40AF); + static const Color civicBlueLight = Color(0xFF3B82F6); + // Secondary civic colors + static const Color civicOrange = Color(0xFFF97316); + static const Color civicOrangeLight = Color(0xFFFB923C); + + // Accent colors + static const Color civicGreen = Color(0xFF22C55E); + static const Color civicGreenLight = Color(0xFF4ADE80); + + // Enhanced neutral palette static const Color neutral0 = Color(0xFFFFFFFF); - static const Color neutral10 = Color(0xFFF5F7FB); - static const Color neutral20 = Color(0xFFE9EDF5); - static const Color neutral30 = Color(0xFFD8DFEB); - static const Color neutral40 = Color(0xFFB8C2D5); - static const Color neutral50 = Color(0xFF99A3B8); - static const Color neutral60 = Color(0xFF7A859E); - static const Color neutral70 = Color(0xFF5C6782); - static const Color neutral80 = Color(0xFF3D4765); - static const Color neutral90 = Color(0xFF21283C); - static const Color neutral100 = Color(0xFF0E1322); + static const Color neutral10 = Color(0xFFF8FAFC); + static const Color neutral20 = Color(0xFFF1F5F9); + static const Color neutral30 = Color(0xFFE2E8F0); + static const Color neutral40 = Color(0xFFCBD5E1); + static const Color neutral50 = Color(0xFF94A3B8); + static const Color neutral60 = Color(0xFF64748B); + static const Color neutral70 = Color(0xFF475569); + static const Color neutral80 = Color(0xFF334155); + static const Color neutral90 = Color(0xFF1E293B); + static const Color neutral100 = Color(0xFF0F172A); - static const Color success = Color(0xFF2E7D32); - static const Color warning = Color(0xFFED6C02); - static const Color error = Color(0xFFD32F2F); - static const Color info = Color(0xFF0288D1); + // Status colors with light variants + static const Color success = Color(0xFF16A34A); + static const Color successLight = Color(0xFF86EFAC); + static const Color warning = Color(0xFFF59E0B); + static const Color warningLight = Color(0xFFFCD34D); + static const Color error = Color(0xFFDC2626); + static const Color errorLight = Color(0xFFFCA5A5); + static const Color info = Color(0xFF0EA5E9); + static const Color infoLight = Color(0xFF7DD3FC); + + // Gradient colors + static const Color surfaceGradient1 = Color(0xFFFAFAFA); + static const Color surfaceGradient2 = Color(0xFFF8FAFC); + + // Legacy colors for backward compatibility + static const Color brandPrimary = civicBlue; + static const Color brandPrimaryDark = civicBlueDark; + static const Color brandPrimaryLight = civicBlueLight; } class AppRadii { @@ -71,18 +95,25 @@ class AppThemes { static ThemeData light() { final colorScheme = ColorScheme.fromSeed( - seedColor: AppColors.brandPrimary, + seedColor: AppColors.civicBlue, brightness: Brightness.light, - primary: AppColors.brandPrimary, + primary: AppColors.civicBlue, + secondary: AppColors.civicOrange, ); return ThemeData( useMaterial3: true, - colorScheme: colorScheme, - scaffoldBackgroundColor: AppColors.neutral10, - canvasColor: AppColors.neutral10, + colorScheme: colorScheme.copyWith( + surfaceContainerLowest: AppColors.neutral10, + surfaceContainerLow: AppColors.neutral20, + surfaceContainer: AppColors.neutral20, + surfaceContainerHigh: AppColors.neutral30, + surfaceContainerHighest: AppColors.neutral40, + ), + scaffoldBackgroundColor: AppColors.surfaceGradient1, + canvasColor: AppColors.surfaceGradient1, appBarTheme: AppBarTheme( - backgroundColor: colorScheme.surface, + backgroundColor: Colors.transparent, foregroundColor: colorScheme.onSurface, elevation: 0, centerTitle: false, @@ -91,26 +122,30 @@ class AppThemes { fontSize: 20, fontWeight: FontWeight.w600, ), + shadowColor: Colors.transparent, ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: colorScheme.primary, foregroundColor: colorScheme.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), - textStyle: const TextStyle(fontWeight: FontWeight.w600), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.lg), + borderRadius: BorderRadius.circular(AppRadii.xl), ), + elevation: 2, + shadowColor: colorScheme.primary.withOpacity(0.25), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( foregroundColor: colorScheme.primary, - side: BorderSide(color: colorScheme.primary, width: 1.2), - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + side: BorderSide(color: colorScheme.primary, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.lg), + borderRadius: BorderRadius.circular(AppRadii.xl), ), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), ), ), textButtonTheme: TextButtonThemeData( @@ -120,6 +155,7 @@ class AppThemes { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadii.md), ), + textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16), ), ), inputDecorationTheme: InputDecorationTheme( @@ -340,8 +376,11 @@ class AppThemes { ), snackBarTheme: base.snackBarTheme.copyWith( backgroundColor: Colors.black, - contentTextStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700), + contentTextStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/report_card.dart b/lib/widgets/report_card.dart index af0ce9a..18db729 100644 --- a/lib/widgets/report_card.dart +++ b/lib/widgets/report_card.dart @@ -3,9 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../models/report.dart'; +import '../models/enums.dart' as enums; import '../services/storage.dart'; -import 'severity_badge.dart'; -import 'status_badge.dart'; import '../l10n/i18n.dart'; class ReportCard extends StatelessWidget { @@ -53,7 +52,7 @@ class ReportCard extends StatelessWidget { String _formatTime(String iso) { try { final dt = DateTime.parse(iso).toLocal(); - return '${dt.year}-${dt.month.toString().padLeft(2,'0')}-${dt.day.toString().padLeft(2,'0')} ${dt.hour.toString().padLeft(2,'0')}:${dt.minute.toString().padLeft(2,'0')}'; + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; } catch (_) { return iso; } @@ -66,8 +65,14 @@ class ReportCard extends StatelessWidget { title: Text(I18n.t('confirm.deleteReport.title')), content: Text(I18n.t('confirm.deleteReport.message')), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(I18n.t('btn.no'))), - TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(I18n.t('btn.yes'))), + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(I18n.t('btn.no')), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(I18n.t('btn.yes')), + ), ], ), ); @@ -75,66 +80,348 @@ class ReportCard extends StatelessWidget { if (ok == true) { final success = await StorageService.deleteReport(report.id); if (success) { - if (onDeleted != null) onDeleted!(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('toast.reportDeleted')))); + if (onDeleted != null) { + onDeleted!(); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(I18n.t('toast.reportDeleted'))), + ); + } } } } + Color _getStatusColor(enums.Status status) { + switch (status) { + case enums.Status.submitted: + return const Color(0xFF2563EB); + case enums.Status.inProgress: + return const Color(0xFF64748B); + case enums.Status.fixed: + return const Color(0xFF16A34A); + } + } + + IconData _getCategoryIcon(enums.Category category) { + switch (category) { + case enums.Category.pothole: + return Icons.warning; + case enums.Category.streetlight: + return Icons.lightbulb; + case enums.Category.signage: + return Icons.traffic; + case enums.Category.trash: + return Icons.delete; + case enums.Category.drainage: + return Icons.water; + case enums.Category.other: + return Icons.category; + } + } + + Color _getSeverityColorValue(enums.Severity severity) { + switch (severity) { + case enums.Severity.high: + return const Color(0xFFDC2626); + case enums.Severity.medium: + return const Color(0xFFF59E0B); + case enums.Severity.low: + return const Color(0xFF16A34A); + } + } + + IconData _getStatusIcon(enums.Status status) { + switch (status) { + case enums.Status.submitted: + return Icons.send; + case enums.Status.inProgress: + return Icons.build; + case enums.Status.fixed: + return Icons.check_circle; + } + } + Future _cycleStatus(BuildContext context) async { final next = report.status.next; - final updated = report.copyWith(status: next, updatedAt: DateTime.now().toIso8601String()); + final updated = report.copyWith( + status: next, + updatedAt: DateTime.now().toIso8601String(), + ); final ok = await StorageService.saveReport(updated); if (ok) { - if (onUpdated != null) onUpdated!(updated); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('btn.changeStatus')))); + if (onUpdated != null) { + onUpdated!(updated); + } + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(I18n.t('btn.changeStatus')))); + } } } @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: ListTile( - leading: _buildThumbnail(), - title: Text(report.category.displayName), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 6), - Wrap( - spacing: 8, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, + final cs = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onView, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SeverityBadge(severity: report.severity, small: true), - StatusBadge(status: report.status), - Text( - _formatTime(report.createdAt), - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + // Enhanced thumbnail + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: _buildThumbnail(), + ), + ), + const SizedBox(width: 16), + // Enhanced content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title with category icon + Row( + children: [ + Icon( + _getCategoryIcon( + enums.Category.values[report.category.index], + ), + size: 18, + color: cs.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + report.category.displayName, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Status indicators + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: _getSeverityColorValue( + report.severity, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getSeverityColorValue(report.severity), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning, + size: 14, + color: _getSeverityColorValue( + report.severity, + ), + ), + const SizedBox(width: 6), + Text( + report.severity.displayName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getSeverityColorValue( + report.severity, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: _getStatusColor( + report.status, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getStatusColor(report.status), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getStatusIcon(report.status), + size: 14, + color: _getStatusColor(report.status), + ), + const SizedBox(width: 6), + Text( + report.status.displayName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getStatusColor(report.status), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + // Time and location info + Row( + children: [ + Icon( + Icons.access_time, + size: 14, + color: cs.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 4), + Text( + _formatTime(report.createdAt), + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withOpacity(0.6), + ), + ), + const SizedBox(width: 16), + Icon( + Icons.location_on, + size: 14, + color: cs.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '${report.location.lat.toStringAsFixed(4)}, ${report.location.lng.toStringAsFixed(4)}', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withOpacity(0.6), + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + // Enhanced menu button + PopupMenuButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: cs.surface, + elevation: 4, + onSelected: (v) async { + if (v == 0) { + if (onView != null) onView!(); + } else if (v == 1) { + await _cycleStatus(context); + } else if (v == 2) { + await _confirmAndDelete(context); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: 0, + child: Row( + children: [ + Icon(Icons.visibility), + SizedBox(width: 8), + Text('View Details'), + ], + ), + ), + const PopupMenuItem( + value: 1, + child: Row( + children: [ + Icon(Icons.update), + SizedBox(width: 8), + Text('Update Status'), + ], + ), + ), + const PopupMenuItem( + value: 2, + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red), + SizedBox(width: 8), + Text('Delete', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.more_vert, + color: cs.onSurface.withOpacity(0.7), + ), + ), ), ], ), - ], - ), - isThreeLine: true, - trailing: PopupMenuButton( - onSelected: (v) async { - if (v == 0) { - if (onView != null) onView!(); - } else if (v == 1) { - await _cycleStatus(context); - } else if (v == 2) { - await _confirmAndDelete(context); - } - }, - itemBuilder: (_) => [ - PopupMenuItem(value: 0, child: Text(I18n.t('btn.view'))), - PopupMenuItem(value: 1, child: Text(I18n.t('btn.changeStatus'))), - PopupMenuItem(value: 2, child: Text(I18n.t('btn.delete'))), - ], + ), ), ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/report_card.dart.backup b/lib/widgets/report_card.dart.backup new file mode 100644 index 0000000..497a2a9 --- /dev/null +++ b/lib/widgets/report_card.dart.backup @@ -0,0 +1,390 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../models/report.dart'; +import '../models/enums.dart' as enums; +import '../services/storage.dart'; +import 'severity_badge.dart'; +import 'status_badge.dart'; +import '../l10n/i18n.dart'; + +class ReportCard extends StatelessWidget { + final Report report; + final VoidCallback? onView; + final VoidCallback? onDeleted; + final ValueChanged? onUpdated; + + const ReportCard({ + super.key, + required this.report, + this.onView, + this.onDeleted, + this.onUpdated, + }); + + Widget _buildThumbnail() { + if (kIsWeb && report.base64Photo != null) { + try { + final bytes = base64Decode(report.base64Photo!); + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory(bytes, width: 72, height: 72, fit: BoxFit.cover), + ); + } catch (_) {} + } else if (report.photoPath != null) { + final file = File(report.photoPath!); + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file(file, width: 72, height: 72, fit: BoxFit.cover), + ); + } + + return Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.image, color: Colors.grey.shade600), + ); + } + + String _formatTime(String iso) { + try { + final dt = DateTime.parse(iso).toLocal(); + return '${dt.year}-${dt.month.toString().padLeft(2,'0')}-${dt.day.toString().padLeft(2,'0')} ${dt.hour.toString().padLeft(2,'0')}:${dt.minute.toString().padLeft(2,'0')}'; + } catch (_) { + return iso; + } + } + + Future _confirmAndDelete(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(I18n.t('confirm.deleteReport.title')), + content: Text(I18n.t('confirm.deleteReport.message')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(I18n.t('btn.no'))), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(I18n.t('btn.yes'))), + ], + ), + ); + + if (ok == true) { + final success = await StorageService.deleteReport(report.id); + if (success) { + if (onDeleted != null) onDeleted!(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('toast.reportDeleted')))); + } + } + + Color _getStatusColor(enums.Status status) { + switch (status) { + case enums.Status.submitted: + return const Color(0xFF2563EB); + case enums.Status.inProgress: + return const Color(0xFF64748B); + case enums.Status.fixed: + return const Color(0xFF16A34A); + } + } + + IconData _getCategoryIcon(enums.Category category) { + switch (category) { + case enums.Category.pothole: + return Icons.warning; + case enums.Category.streetlight: + return Icons.lightbulb; + case enums.Category.signage: + return Icons.traffic; + case enums.Category.trash: + return Icons.delete; + case enums.Category.drainage: + return Icons.water; + case enums.Category.other: + return Icons.category; + } + } + + Color _getSeverityColorValue(enums.Severity severity) { + switch (severity) { + case enums.Severity.high: + return const Color(0xFFDC2626); + case enums.Severity.medium: + return const Color(0xFFF59E0B); + case enums.Severity.low: + return const Color(0xFF16A34A); + } + } + + IconData _getStatusIcon(enums.Status status) { + switch (status) { + case enums.Status.submitted: + return Icons.send; + case enums.Status.inProgress: + return Icons.build; + case enums.Status.fixed: + return Icons.check_circle; + } + } + + Future _cycleStatus(BuildContext context) async { + final next = report.status.next; + final updated = report.copyWith(status: next, updatedAt: DateTime.now().toIso8601String()); + final ok = await StorageService.saveReport(updated); + if (ok) { + if (onUpdated != null) onUpdated!(updated); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('btn.changeStatus')))); + } + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: cs.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onView, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Enhanced thumbnail + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: _buildThumbnail(), + ), + ), + const SizedBox(width: 16), + // Enhanced content + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title with category icon + Row( + children: [ + Icon( + _getCategoryIcon(enums.Category.values[report.category.index]), + size: 18, + color: cs.primary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + report.category.displayName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: cs.onSurface, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Status indicators + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getSeverityColorValue(report.severity).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getSeverityColorValue(report.severity), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning, + size: 14, + color: _getSeverityColorValue(report.severity), + ), + const SizedBox(width: 6), + Text( + report.severity.displayName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getSeverityColorValue(report.severity), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getStatusColor(report.status).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _getStatusColor(report.status), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getStatusIcon(report.status), + size: 14, + color: _getStatusColor(report.status), + ), + const SizedBox(width: 6), + Text( + report.status.displayName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getStatusColor(report.status), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + // Time and location info + Row( + children: [ + Icon( + Icons.access_time, + size: 14, + color: cs.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 4), + Text( + _formatTime(report.createdAt), + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withOpacity(0.6), + ), + ), + const SizedBox(width: 16), + Icon( + Icons.location_on, + size: 14, + color: cs.onSurface.withOpacity(0.6), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '${report.location.lat.toStringAsFixed(4)}, ${report.location.lng.toStringAsFixed(4)}', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withOpacity(0.6), + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + // Enhanced menu button + PopupMenuButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: cs.surface, + elevation: 4, + onSelected: (v) async { + if (v == 0) { + if (onView != null) onView!(); + } else if (v == 1) { + await _cycleStatus(context); + } else if (v == 2) { + await _confirmAndDelete(context); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: 0, + child: Row( + children: [ + Icon(Icons.visibility), + SizedBox(width: 8), + Text('View Details'), + ], + ), + ), + const PopupMenuItem( + value: 1, + child: Row( + children: [ + Icon(Icons.update), + SizedBox(width: 8), + Text('Update Status'), + ], + ), + ), + const PopupMenuItem( + value: 2, + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red), + SizedBox(width: 8), + Text('Delete', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.more_vert, + color: cs.onSurface.withOpacity(0.7), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file