feat(ui): add onboarding and token-based themes

Introduce StartRouter to gate first run with onboarding. Persist state in
SharedPreferences (onboarded_v1). Add WelcomeScreen, a 3-step
OnboardingFlow, and a SignInScreen placeholder with Apple/Google buttons
and "coming soon" toasts plus continue as guest.

Apply new AppThemes (light/dark, high-contrast option) built from design
tokens and wire into MaterialApp (theme/darkTheme, system mode). Add
ThemeProvider to persist ThemeMode and high-contrast flags (not yet
hooked into the tree).

Add EN/MS i18n strings for welcome, onboarding, auth, and CTAs. Include
assets/tokens/design-tokens.json for design tooling parity.
This commit is contained in:
2025-09-25 21:44:10 +08:00
parent 6518df8ac1
commit 8d69c9c846
6 changed files with 887 additions and 8 deletions

View File

@@ -6,6 +6,8 @@ import 'screens/report_flow/capture_screen.dart';
import 'screens/map/map_screen.dart';
import 'screens/my_reports/my_reports_screen.dart';
import 'screens/settings/settings_screen.dart';
import 'theme/themes.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FixMateApp extends StatelessWidget {
const FixMateApp({super.key});
@@ -16,17 +18,15 @@ class FixMateApp extends StatelessWidget {
builder: (context, localeProvider, child) {
return MaterialApp(
title: I18n.t('app.name'),
theme: ThemeData(
primarySwatch: Colors.blue,
brightness: Brightness.light,
useMaterial3: true,
),
theme: AppThemes.light(),
darkTheme: AppThemes.dark(),
themeMode: ThemeMode.system,
locale: localeProvider.locale,
supportedLocales: const [
Locale('en', 'US'),
Locale('ms', 'MY'),
],
home: const MainScreen(),
home: const StartRouter(),
);
},
);
@@ -108,4 +108,264 @@ class PlaceholderScreen extends StatelessWidget {
),
);
}
}
/// Router that decides whether to show onboarding or main app
class StartRouter extends StatefulWidget {
const StartRouter({super.key});
@override
State<StartRouter> createState() => _StartRouterState();
}
class _StartRouterState extends State<StartRouter> {
bool _loading = true;
bool _onboarded = false;
static const String _kOnboardedKey = 'onboarded_v1';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final flag = prefs.getBool(_kOnboardedKey) ?? false;
if (mounted) {
setState(() {
_onboarded = flag;
_loading = false;
});
}
}
Future<void> _setOnboarded() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kOnboardedKey, true);
if (mounted) {
setState(() {
_onboarded = true;
});
}
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (_onboarded) return const MainScreen();
return WelcomeScreen(
onContinue: () async {
final completed = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (_) => const OnboardingFlow()),
);
if (completed == true) {
await _setOnboarded();
}
},
onSignIn: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SignInScreen()),
);
},
onSkip: () async {
await _setOnboarded();
},
);
}
}
/// Branded welcome screen (in-app splash handoff)
class WelcomeScreen extends StatelessWidget {
final VoidCallback onContinue;
final VoidCallback onSignIn;
final VoidCallback onSkip;
const WelcomeScreen({
super.key,
required this.onContinue,
required this.onSignIn,
required this.onSkip,
});
@override
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),
),
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 SizedBox(height: 8),
TextButton(
onPressed: onSkip,
child: Text(I18n.t('cta.skip')),
),
],
),
),
),
);
}
}
/// Three-step onboarding flow with concise benefits
class OnboardingFlow extends StatefulWidget {
const OnboardingFlow({super.key});
@override
State<OnboardingFlow> createState() => _OnboardingFlowState();
}
class _OnboardingFlowState extends State<OnboardingFlow> {
final PageController _pc = PageController();
int _index = 0;
void _next() {
if (_index < 2) {
_pc.nextPage(duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic);
} else {
Navigator.pop(context, true);
}
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
Widget _page(String titleKey, String bodyKey, IconData icon) {
return Padding(
padding: const EdgeInsets.all(24),
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),
],
),
);
}
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),
],
),
),
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')),
),
],
),
),
],
),
);
}
}
/// Sign-in placeholder with SSO buttons; supports continue as guest
class SignInScreen extends StatelessWidget {
const SignInScreen({super.key});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: Text(I18n.t('auth.title'))),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
ElevatedButton.icon(
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),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(I18n.t('auth.comingSoon')))),
icon: const Icon(Icons.g_mobiledata),
label: Text(I18n.t('auth.signInWithGoogle')),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(I18n.t('cta.continueGuest')),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// ThemeProvider manages ThemeMode and High-Contrast flag with persistence.
class ThemeProvider extends ChangeNotifier {
static const _kThemeModeKey = 'ui.theme_mode'; // 'system' | 'light' | 'dark'
static const _kHighContrastKey = 'ui.high_contrast'; // bool
ThemeMode _mode = ThemeMode.system;
bool _highContrast = false;
ThemeMode get themeMode => _mode;
bool get highContrast => _highContrast;
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final modeStr = prefs.getString(_kThemeModeKey) ?? 'system';
_mode = _decodeMode(modeStr);
_highContrast = prefs.getBool(_kHighContrastKey) ?? false;
notifyListeners();
}
Future<void> setThemeMode(ThemeMode mode) async {
if (_mode == mode) return;
_mode = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kThemeModeKey, _encodeMode(mode));
notifyListeners();
}
Future<void> setHighContrast(bool value) async {
if (_highContrast == value) return;
_highContrast = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kHighContrastKey, value);
notifyListeners();
}
String _encodeMode(ThemeMode m) {
switch (m) {
case ThemeMode.light:
return 'light';
case ThemeMode.dark:
return 'dark';
case ThemeMode.system:
default:
return 'system';
}
}
ThemeMode _decodeMode(String s) {
switch (s) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
case 'system':
default:
return ThemeMode.system;
}
}
}

347
lib/theme/themes.dart Normal file
View File

@@ -0,0 +1,347 @@
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);
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 success = Color(0xFF2E7D32);
static const Color warning = Color(0xFFED6C02);
static const Color error = Color(0xFFD32F2F);
static const Color info = Color(0xFF0288D1);
}
class AppRadii {
static const double xs = 6;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 24;
static const double pill = 100;
}
class AppSpacing {
static const double x1 = 4;
static const double x2 = 8;
static const double x3 = 12;
static const double x4 = 16;
static const double x5 = 20;
static const double x6 = 24;
static const double x8 = 32;
static const double x10 = 40;
static const double x12 = 48;
static const double x16 = 64;
}
class AppMotion {
static const Duration fast = Duration(milliseconds: 120);
static const Duration medium = Duration(milliseconds: 200);
static const Duration slow = Duration(milliseconds: 300);
static const Duration slower = Duration(milliseconds: 450);
static const Curve standard = Curves.easeOutCubic;
static const Curve accelerate = Curves.easeInCubic;
static const Curve decelerate = Curves.easeOutCubic;
static const Curve emphasize = Cubic(0.2, 0.0, 0.0, 1.0);
}
class AppThemes {
static const PageTransitionsTheme transitions = PageTransitionsTheme(
builders: {
TargetPlatform.android: ZoomPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
},
);
static ThemeData light() {
final colorScheme = ColorScheme.fromSeed(
seedColor: AppColors.brandPrimary,
brightness: Brightness.light,
primary: AppColors.brandPrimary,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.neutral10,
canvasColor: AppColors.neutral10,
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
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),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.primary,
side: BorderSide(color: colorScheme.primary, width: 1.2),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.md),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: AppColors.neutral30),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: AppColors.neutral30),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: colorScheme.primary, width: 1.6),
),
contentPadding: const EdgeInsets.symmetric(
vertical: AppSpacing.x4,
horizontal: AppSpacing.x4,
),
),
cardTheme: CardThemeData(
color: colorScheme.surface,
elevation: 1,
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
),
),
chipTheme: ChipThemeData(
backgroundColor: colorScheme.surfaceVariant,
selectedColor: colorScheme.primary.withOpacity(0.14),
secondarySelectedColor: colorScheme.primary.withOpacity(0.14),
labelStyle: TextStyle(color: colorScheme.onSurface),
secondaryLabelStyle: TextStyle(color: colorScheme.onSurface),
brightness: Brightness.light,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.md),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
dialogTheme: DialogThemeData(
backgroundColor: colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.xl),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.neutral90,
contentTextStyle: const TextStyle(color: Colors.white),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: colorScheme.surface,
selectedItemColor: colorScheme.primary,
unselectedItemColor: AppColors.neutral60,
type: BottomNavigationBarType.fixed,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w600),
),
pageTransitionsTheme: transitions,
visualDensity: VisualDensity.standard,
);
}
static ThemeData dark() {
final colorScheme = ColorScheme.fromSeed(
seedColor: AppColors.brandPrimary,
brightness: Brightness.dark,
primary: AppColors.brandPrimaryLight,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.neutral100,
canvasColor: AppColors.neutral100,
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
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),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.primary,
side: BorderSide(color: colorScheme.primary, width: 1.2),
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: AppColors.neutral80),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: AppColors.neutral80),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: colorScheme.primary, width: 1.6),
),
contentPadding: const EdgeInsets.symmetric(
vertical: AppSpacing.x4,
horizontal: AppSpacing.x4,
),
),
cardTheme: CardThemeData(
color: colorScheme.surface,
elevation: 1,
margin: const EdgeInsets.all(0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
),
),
chipTheme: ChipThemeData(
backgroundColor: colorScheme.surfaceVariant.withOpacity(0.5),
selectedColor: colorScheme.primary.withOpacity(0.22),
secondarySelectedColor: colorScheme.primary.withOpacity(0.22),
labelStyle: TextStyle(color: colorScheme.onSurface),
secondaryLabelStyle: TextStyle(color: colorScheme.onSurface),
brightness: Brightness.dark,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.md),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
dialogTheme: DialogThemeData(
backgroundColor: colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.xl),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.neutral90,
contentTextStyle: const TextStyle(color: Colors.white),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: colorScheme.surface,
selectedItemColor: colorScheme.primary,
unselectedItemColor: AppColors.neutral60,
type: BottomNavigationBarType.fixed,
selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w600),
),
pageTransitionsTheme: transitions,
visualDensity: VisualDensity.standard,
);
}
static ThemeData highContrast() {
// High-contrast light theme with stronger borders and higher on-color contrast
final base = light();
final cs = base.colorScheme;
final highCs = cs.copyWith(
primary: AppColors.brandPrimaryDark,
onPrimary: Colors.white,
surface: AppColors.neutral0,
onSurface: AppColors.neutral100,
surfaceTint: Colors.transparent,
);
return base.copyWith(
colorScheme: highCs,
inputDecorationTheme: base.inputDecorationTheme.copyWith(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: AppColors.neutral80, width: 2),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: AppColors.neutral80, width: 2),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.lg),
borderSide: BorderSide(color: highCs.primary, width: 2.2),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: highCs.primary,
foregroundColor: highCs.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 18),
textStyle: const TextStyle(fontWeight: FontWeight.w700),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
side: BorderSide(color: highCs.primary, width: 2),
),
),
snackBarTheme: base.snackBarTheme.copyWith(
backgroundColor: Colors.black,
contentTextStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700),
),
);
}
}