From 6d6e9300f62b2085d4eb9c8af826f9c85789c0ce Mon Sep 17 00:00:00 2001 From: Zahar Date: Fri, 26 Sep 2025 13:57:11 +0800 Subject: [PATCH] feat(ui): add animated screen transitions and guest onboarding Improve app UX by adding animated transitions for screen switches and introducing a persisted guest onboarding flow. - Add AnimatedSwitcher-based fade+slide transitions for screens and tab switches for smoother navigation. - Persist a 'user_mode' SharedPreferences key and provide guest helpers to mark users as guest and influence routing. - Update SignInScreen continue-as-guest action to set onboarding and guest flags and navigate to MainScreen. - Normalize locales in LocaleProvider to language-only values and persist normalized language codes to ensure compatibility with Flutter localization delegates. - Replace Row with Wrap in ReportCard to improve spacing and responsiveness. No breaking changes --- lib/app.dart | 124 +++++++++++++++++++++++++++------- lib/l10n/locale_provider.dart | 20 ++++-- lib/widgets/report_card.dart | 12 ++-- 3 files changed, 120 insertions(+), 36 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 611ad3a..4675304 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -80,7 +80,20 @@ class _MainScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: _screens[_selectedIndex], + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + final offsetAnimation = Tween(begin: const Offset(0.0, 0.02), end: Offset.zero).animate(animation); + return FadeTransition( + opacity: animation, + child: SlideTransition(position: offsetAnimation, child: child), + ); + }, + child: KeyedSubtree( + key: ValueKey(_selectedIndex), + child: _screens[_selectedIndex], + ), + ), bottomNavigationBar: BottomNavigationBar( currentIndex: _selectedIndex, onTap: (index) { @@ -143,30 +156,49 @@ class _StartRouterState extends State { bool _onboarded = false; static const String _kOnboardedKey = 'onboarded_v1'; - + static const String _kUserModeKey = 'user_mode'; + + String? _userMode; + @override void initState() { super.initState(); _load(); } - + Future _load() async { final prefs = await SharedPreferences.getInstance(); final flag = prefs.getBool(_kOnboardedKey) ?? false; + final mode = prefs.getString(_kUserModeKey) ?? ''; if (mounted) { setState(() { _onboarded = flag; + _userMode = mode.isNotEmpty ? mode : null; _loading = false; }); } } - - Future _setOnboarded() async { + + Future _setGuestMode() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kUserModeKey, 'guest'); + if (mounted) { + setState(() { + _userMode = 'guest'; + }); + } + } + + Future _setOnboarded({bool asGuest = false}) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_kOnboardedKey, true); + if (asGuest) { + await prefs.setString(_kUserModeKey, 'guest'); + } if (mounted) { setState(() { _onboarded = true; + if (asGuest) _userMode = 'guest'; }); } } @@ -174,29 +206,62 @@ class _StartRouterState extends State { @override Widget build(BuildContext context) { debugPrint('[i18n] StartRouter: hasMaterial=${Localizations.of(context, MaterialLocalizations) != null} locale=${Localizations.localeOf(context)}'); + + Widget screen; + String screenKey; + if (_loading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + screen = const Scaffold(body: Center(child: CircularProgressIndicator())); + screenKey = 'loading'; + } else if (_userMode == 'guest') { + // If user is known to be in guest mode, take them to sign-in / sign-up first + screen = const SignInScreen(); + screenKey = 'signin'; + } else if (!_onboarded) { + screen = WelcomeScreen( + onContinue: () async { + // Mark guest mode before continuing through onboarding flow + await _setGuestMode(); + final completed = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const OnboardingFlow()), + ); + if (completed == true) { + await _setOnboarded(); + } + }, + onSignIn: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SignInScreen()), + ); + }, + onSkip: () async { + // Mark as onboarded and guest so next app start opens sign-up/sign-in + await _setOnboarded(asGuest: true); + }, + ); + screenKey = 'welcome'; + } else { + screen = const MainScreen(); + screenKey = 'main'; } - if (_onboarded) return const MainScreen(); - return WelcomeScreen( - onContinue: () async { - final completed = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const OnboardingFlow()), - ); - if (completed == true) { - await _setOnboarded(); - } - }, - onSignIn: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SignInScreen()), + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 420), + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween(begin: const Offset(0.0, 0.02), end: Offset.zero).animate(animation), + child: child, + ), ); }, - onSkip: () async { - await _setOnboarded(); - }, + child: KeyedSubtree( + key: ValueKey(screenKey), + child: screen, + ), ); } } @@ -381,7 +446,16 @@ class SignInScreen extends StatelessWidget { ), const Spacer(), TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('onboarded_v1', true); + await prefs.setString('user_mode', 'guest'); + if (context.mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const MainScreen()), + ); + } + }, child: Text(I18n.t('cta.continueGuest')), ), ], diff --git a/lib/l10n/locale_provider.dart b/lib/l10n/locale_provider.dart index 5ceeef2..ca6b4e3 100644 --- a/lib/l10n/locale_provider.dart +++ b/lib/l10n/locale_provider.dart @@ -19,19 +19,25 @@ class LocaleProvider extends ChangeNotifier { /// Initialize the locale provider Future init() async { _prefs = await SharedPreferences.getInstance(); - final savedLanguage = _prefs.getString(_languageKey) ?? _defaultLanguage; - _locale = Locale(savedLanguage); + final saved = _prefs.getString(_languageKey) ?? _defaultLanguage; + final parts = saved.split(RegExp(r'[-_]')); + // Normalize and keep language-only locales so they match Global*Localizations delegates + final language = parts.isNotEmpty && parts[0].isNotEmpty ? parts[0] : _defaultLanguage; + _locale = Locale(language); await I18n.init(_locale); notifyListeners(); } /// Set the locale and persist the change Future setLocale(Locale locale) async { - if (_locale == locale) return; + // Normalize to language-only locale to ensure compatibility with delegates + final normalized = Locale(locale.languageCode); + if (_locale == normalized) return; - _locale = locale; - await _prefs.setString(_languageKey, locale.languageCode); - await I18n.init(locale); + _locale = normalized; + final saveValue = _locale.languageCode; + await _prefs.setString(_languageKey, saveValue); + await I18n.init(_locale); notifyListeners(); } @@ -45,7 +51,7 @@ class LocaleProvider extends ChangeNotifier { Future setEnglish() async { await setLocale(const Locale('en')); } - + /// Set language to Bahasa Malaysia Future setMalay() async { await setLocale(const Locale('ms')); diff --git a/lib/widgets/report_card.dart b/lib/widgets/report_card.dart index 659a501..af0ce9a 100644 --- a/lib/widgets/report_card.dart +++ b/lib/widgets/report_card.dart @@ -102,13 +102,17 @@ class ReportCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 6), - Row( + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, children: [ SeverityBadge(severity: report.severity, small: true), - const SizedBox(width: 8), StatusBadge(status: report.status), - const SizedBox(width: 8), - Text(_formatTime(report.createdAt), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), + Text( + _formatTime(report.createdAt), + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), ], ), ],