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.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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
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/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')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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