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
This commit is contained in:
2025-09-26 13:57:11 +08:00
parent ee9a9200b6
commit 6d6e9300f6
3 changed files with 120 additions and 36 deletions

View File

@@ -80,7 +80,20 @@ class _MainScreenState extends State<MainScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: _screens[_selectedIndex],
body: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
final offsetAnimation = Tween<Offset>(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<int>(_selectedIndex),
child: _screens[_selectedIndex],
),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex,
onTap: (index) {
@@ -143,6 +156,9 @@ class _StartRouterState extends State<StartRouter> {
bool _onboarded = false;
static const String _kOnboardedKey = 'onboarded_v1';
static const String _kUserModeKey = 'user_mode';
String? _userMode;
@override
void initState() {
@@ -153,20 +169,36 @@ class _StartRouterState extends State<StartRouter> {
Future<void> _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<void> _setOnboarded() async {
Future<void> _setGuestMode() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kUserModeKey, 'guest');
if (mounted) {
setState(() {
_userMode = 'guest';
});
}
}
Future<void> _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<StartRouter> {
@override
Widget build(BuildContext context) {
debugPrint('[i18n] StartRouter: hasMaterial=${Localizations.of<MaterialLocalizations>(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<bool>(
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<bool>(
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<Offset>(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')),
),
],

View File

@@ -19,19 +19,25 @@ class LocaleProvider extends ChangeNotifier {
/// Initialize the locale provider
Future<void> 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<void> 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();
}

View File

@@ -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),
),
],
),
],