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.
This commit is contained in:
2025-09-26 14:38:27 +08:00
parent 6d6e9300f6
commit 7085a54290
9 changed files with 2643 additions and 738 deletions

View File

@@ -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<MainScreen> {
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
final offsetAnimation = Tween<Offset>(begin: const Offset(0.0, 0.02), end: Offset.zero).animate(animation);
final offsetAnimation = Tween<Offset>(
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<MainScreen> {
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<StartRouter> {
static const String _kOnboardedKey = 'onboarded_v1';
static const String _kUserModeKey = 'user_mode';
String? _userMode;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final flag = prefs.getBool(_kOnboardedKey) ?? false;
@@ -178,7 +244,7 @@ class _StartRouterState extends State<StartRouter> {
});
}
}
Future<void> _setGuestMode() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kUserModeKey, 'guest');
@@ -188,7 +254,7 @@ class _StartRouterState extends State<StartRouter> {
});
}
}
Future<void> _setOnboarded({bool asGuest = false}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kOnboardedKey, true);
@@ -205,11 +271,13 @@ class _StartRouterState extends State<StartRouter> {
@override
Widget build(BuildContext context) {
debugPrint('[i18n] StartRouter: hasMaterial=${Localizations.of<MaterialLocalizations>(context, MaterialLocalizations) != null} locale=${Localizations.localeOf(context)}');
debugPrint(
'[i18n] StartRouter: hasMaterial=${Localizations.of<MaterialLocalizations>(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<StartRouter> {
screen = const MainScreen();
screenKey = 'main';
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 420),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(begin: const Offset(0.0, 0.02), end: Offset.zero).animate(animation),
position: Tween<Offset>(
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<OnboardingFlow> {
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<OnboardingFlow> {
@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 {
),
);
}
}
}