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

@@ -78,5 +78,23 @@
"settings.theme.light": "Light", "settings.theme.light": "Light",
"settings.theme.dark": "Dark", "settings.theme.dark": "Dark",
"lang.en": "English", "lang.en": "English",
"lang.ms": "Bahasa Malaysia" "lang.ms": "Bahasa Malaysia",
"welcome.title": "Spot it. Snap it. Fix it.",
"welcome.subtitle": "Report city issues in seconds—help crews act faster.",
"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.",
"auth.title": "Sign in",
"auth.signInWithApple": "Sign in with Apple",
"auth.signInWithGoogle": "Sign in with Google",
"auth.comingSoon": "Coming soon"
} }

View File

@@ -78,5 +78,23 @@
"settings.theme.light": "Terang", "settings.theme.light": "Terang",
"settings.theme.dark": "Gelap", "settings.theme.dark": "Gelap",
"lang.en": "English", "lang.en": "English",
"lang.ms": "Bahasa Malaysia" "lang.ms": "Bahasa Malaysia",
"welcome.title": "Nampak. Tangkap. Baiki.",
"welcome.subtitle": "Lapor isu bandar dalam beberapa saat — bantu pasukan bertindak lebih pantas.",
"cta.continueGuest": "Teruskan sebagai tetamu",
"cta.signIn": "Log masuk",
"cta.skip": "Langkau",
"cta.next": "Seterusnya",
"cta.getStarted": "Mula",
"onboarding.header": "Selamat datang ke FixMate",
"onboarding.title1": "Tangkap pantas",
"onboarding.body1": "Ambil gambar dan hantar dalam kurang satu minit.",
"onboarding.title2": "Peta yang jelas",
"onboarding.body2": "Lihat isu di sekitar anda dengan pengelompokan pintar.",
"onboarding.title3": "Jejak kemajuan",
"onboarding.body3": "Ikuti kemas kini status apabila kerja disiapkan.",
"auth.title": "Log masuk",
"auth.signInWithApple": "Log masuk dengan Apple",
"auth.signInWithGoogle": "Log masuk dengan Google",
"auth.comingSoon": "Akan datang"
} }

View File

@@ -0,0 +1,174 @@
{
"meta": {
"name": "FixMate Design Tokens",
"version": "1.0.0",
"brand": "Civic Premium Citizen First"
},
"color": {
"palette": {
"brandPrimary": "#1E5CE0",
"brandPrimaryDark": "#1748AC",
"brandPrimaryLight": "#8CB0FF",
"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"
},
"semantic": {
"primary": "{color.palette.brandPrimary}",
"primaryDark": "{color.palette.brandPrimaryDark}",
"primaryLight": "{color.palette.brandPrimaryLight}",
"background": "{color.palette.neutral10}",
"surface": "{color.palette.neutral0}",
"surfaceVariant": "{color.palette.neutral20}",
"onPrimary": "#FFFFFF",
"onBackground": "{color.palette.neutral100}",
"onSurface": "{color.palette.neutral100}",
"outline": "{color.palette.neutral30}",
"success": "{color.palette.success}",
"onSuccess": "#FFFFFF",
"warning": "{color.palette.warning}",
"onWarning": "#FFFFFF",
"error": "{color.palette.error}",
"onError": "#FFFFFF",
"info": "{color.palette.info}",
"onInfo": "#FFFFFF"
}
},
"typography": {
"families": {
"display": "SF Pro Display, Inter, Roboto, system-ui, -apple-system",
"body": "SF Pro Text, Inter, Roboto, system-ui, -apple-system",
"mono": "SF Mono, Menlo, Roboto Mono, ui-monospace"
},
"weight": {
"regular": 400,
"medium": 500,
"semibold": 600,
"bold": 700
},
"scale": {
"xs": 12,
"sm": 14,
"md": 16,
"lg": 18,
"xl": 20,
"xxl": 24,
"displaySm": 28,
"displayMd": 32,
"displayLg": 40
},
"lineHeight": {
"tight": 1.15,
"standard": 1.35,
"loose": 1.5
}
},
"spacing": {
"x1": 4,
"x2": 8,
"x3": 12,
"x4": 16,
"x5": 20,
"x6": 24,
"x8": 32,
"x10": 40,
"x12": 48,
"x16": 64
},
"radii": {
"xs": 6,
"sm": 8,
"md": 12,
"lg": 16,
"xl": 24,
"pill": 100
},
"shadows": {
"sm": [
{"x":0,"y":1,"blur":2,"spread":0,"opacity":0.10}
],
"md": [
{"x":0,"y":2,"blur":6,"spread":0,"opacity":0.12},
{"x":0,"y":8,"blur":12,"spread":-6,"opacity":0.10}
],
"lg": [
{"x":0,"y":12,"blur":24,"spread":-6,"opacity":0.12},
{"x":0,"y":24,"blur":36,"spread":-12,"opacity":0.10}
]
},
"motion": {
"durations": {
"fast": 120,
"medium": 200,
"slow": 300,
"slower": 450
},
"easing": {
"standard": [0.2, 0.0, 0.0, 1.0],
"accelerate": [0.4, 0.0, 1.0, 1.0],
"decelerate": [0.0, 0.0, 0.2, 1.0],
"emphasize": [0.2, 0.0, 0.0, 1.0]
},
"recommendations": {
"screenTransitionMs": [200, 320],
"buttonTapMs": [80, 140],
"listReorderMs": [120, 200]
}
},
"themes": {
"light": {
"primary": "{color.palette.brandPrimary}",
"onPrimary": "#FFFFFF",
"background": "{color.palette.neutral10}",
"onBackground": "{color.palette.neutral100}",
"surface": "{color.palette.neutral0}",
"onSurface": "{color.palette.neutral100}",
"surfaceVariant": "{color.palette.neutral20}",
"onSurfaceVariant": "{color.palette.neutral80}"
},
"dark": {
"primary": "{color.palette.brandPrimaryLight}",
"onPrimary": "{color.palette.neutral100}",
"background": "{color.palette.neutral100}",
"onBackground": "{color.palette.neutral10}",
"surface": "{color.palette.neutral90}",
"onSurface": "{color.palette.neutral10}",
"surfaceVariant": "{color.palette.neutral80}",
"onSurfaceVariant": "{color.palette.neutral20}"
},
"highContrast": {
"primary": "{color.palette.brandPrimaryDark}",
"onPrimary": "#FFFFFF",
"background": "{color.palette.neutral0}",
"onBackground": "{color.palette.neutral100}",
"surface": "{color.palette.neutral0}",
"onSurface": "{color.palette.neutral100}",
"surfaceVariant": "{color.palette.neutral20}",
"onSurfaceVariant": "{color.palette.neutral100}"
}
},
"status": {
"severity": {
"high": "#D32F2F",
"medium": "#ED6C02",
"low": "#2E7D32"
},
"report": {
"submitted": "#1E5CE0",
"in_progress": "#7A859E",
"fixed": "#2E7D32"
}
}
}

View File

@@ -6,6 +6,8 @@ import 'screens/report_flow/capture_screen.dart';
import 'screens/map/map_screen.dart'; import 'screens/map/map_screen.dart';
import 'screens/my_reports/my_reports_screen.dart'; import 'screens/my_reports/my_reports_screen.dart';
import 'screens/settings/settings_screen.dart'; import 'screens/settings/settings_screen.dart';
import 'theme/themes.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FixMateApp extends StatelessWidget { class FixMateApp extends StatelessWidget {
const FixMateApp({super.key}); const FixMateApp({super.key});
@@ -16,17 +18,15 @@ class FixMateApp extends StatelessWidget {
builder: (context, localeProvider, child) { builder: (context, localeProvider, child) {
return MaterialApp( return MaterialApp(
title: I18n.t('app.name'), title: I18n.t('app.name'),
theme: ThemeData( theme: AppThemes.light(),
primarySwatch: Colors.blue, darkTheme: AppThemes.dark(),
brightness: Brightness.light, themeMode: ThemeMode.system,
useMaterial3: true,
),
locale: localeProvider.locale, locale: localeProvider.locale,
supportedLocales: const [ supportedLocales: const [
Locale('en', 'US'), Locale('en', 'US'),
Locale('ms', 'MY'), 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),
),
);
}
}