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:
@@ -78,5 +78,23 @@
|
||||
"settings.theme.light": "Light",
|
||||
"settings.theme.dark": "Dark",
|
||||
"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"
|
||||
}
|
||||
@@ -78,5 +78,23 @@
|
||||
"settings.theme.light": "Terang",
|
||||
"settings.theme.dark": "Gelap",
|
||||
"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"
|
||||
}
|
||||
174
assets/tokens/design-tokens.json
Normal file
174
assets/tokens/design-tokens.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
272
lib/app.dart
272
lib/app.dart
@@ -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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
lib/providers/theme_provider.dart
Normal file
62
lib/providers/theme_provider.dart
Normal 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
347
lib/theme/themes.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user