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:
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')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user