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:
124
lib/app.dart
124
lib/app.dart
@@ -80,7 +80,20 @@ class _MainScreenState extends State<MainScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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(
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
currentIndex: _selectedIndex,
|
currentIndex: _selectedIndex,
|
||||||
onTap: (index) {
|
onTap: (index) {
|
||||||
@@ -143,30 +156,49 @@ class _StartRouterState extends State<StartRouter> {
|
|||||||
bool _onboarded = false;
|
bool _onboarded = false;
|
||||||
|
|
||||||
static const String _kOnboardedKey = 'onboarded_v1';
|
static const String _kOnboardedKey = 'onboarded_v1';
|
||||||
|
static const String _kUserModeKey = 'user_mode';
|
||||||
|
|
||||||
|
String? _userMode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final flag = prefs.getBool(_kOnboardedKey) ?? false;
|
final flag = prefs.getBool(_kOnboardedKey) ?? false;
|
||||||
|
final mode = prefs.getString(_kUserModeKey) ?? '';
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_onboarded = flag;
|
_onboarded = flag;
|
||||||
|
_userMode = mode.isNotEmpty ? mode : null;
|
||||||
_loading = false;
|
_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();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_kOnboardedKey, true);
|
await prefs.setBool(_kOnboardedKey, true);
|
||||||
|
if (asGuest) {
|
||||||
|
await prefs.setString(_kUserModeKey, 'guest');
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_onboarded = true;
|
_onboarded = true;
|
||||||
|
if (asGuest) _userMode = 'guest';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,29 +206,62 @@ class _StartRouterState extends State<StartRouter> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
debugPrint('[i18n] StartRouter: hasMaterial=${Localizations.of<MaterialLocalizations>(context, MaterialLocalizations) != null} locale=${Localizations.localeOf(context)}');
|
debugPrint('[i18n] StartRouter: hasMaterial=${Localizations.of<MaterialLocalizations>(context, MaterialLocalizations) != null} locale=${Localizations.localeOf(context)}');
|
||||||
|
|
||||||
|
Widget screen;
|
||||||
|
String screenKey;
|
||||||
|
|
||||||
if (_loading) {
|
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(
|
return AnimatedSwitcher(
|
||||||
onContinue: () async {
|
duration: const Duration(milliseconds: 420),
|
||||||
final completed = await Navigator.push<bool>(
|
transitionBuilder: (child, animation) {
|
||||||
context,
|
return FadeTransition(
|
||||||
MaterialPageRoute(builder: (_) => const OnboardingFlow()),
|
opacity: animation,
|
||||||
);
|
child: SlideTransition(
|
||||||
if (completed == true) {
|
position: Tween<Offset>(begin: const Offset(0.0, 0.02), end: Offset.zero).animate(animation),
|
||||||
await _setOnboarded();
|
child: child,
|
||||||
}
|
),
|
||||||
},
|
|
||||||
onSignIn: () {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => const SignInScreen()),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onSkip: () async {
|
child: KeyedSubtree(
|
||||||
await _setOnboarded();
|
key: ValueKey(screenKey),
|
||||||
},
|
child: screen,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,7 +446,16 @@ class SignInScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
TextButton(
|
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')),
|
child: Text(I18n.t('cta.continueGuest')),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,19 +19,25 @@ class LocaleProvider extends ChangeNotifier {
|
|||||||
/// Initialize the locale provider
|
/// Initialize the locale provider
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_prefs = await SharedPreferences.getInstance();
|
_prefs = await SharedPreferences.getInstance();
|
||||||
final savedLanguage = _prefs.getString(_languageKey) ?? _defaultLanguage;
|
final saved = _prefs.getString(_languageKey) ?? _defaultLanguage;
|
||||||
_locale = Locale(savedLanguage);
|
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);
|
await I18n.init(_locale);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the locale and persist the change
|
/// Set the locale and persist the change
|
||||||
Future<void> setLocale(Locale locale) async {
|
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;
|
_locale = normalized;
|
||||||
await _prefs.setString(_languageKey, locale.languageCode);
|
final saveValue = _locale.languageCode;
|
||||||
await I18n.init(locale);
|
await _prefs.setString(_languageKey, saveValue);
|
||||||
|
await I18n.init(_locale);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ class LocaleProvider extends ChangeNotifier {
|
|||||||
Future<void> setEnglish() async {
|
Future<void> setEnglish() async {
|
||||||
await setLocale(const Locale('en'));
|
await setLocale(const Locale('en'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set language to Bahasa Malaysia
|
/// Set language to Bahasa Malaysia
|
||||||
Future<void> setMalay() async {
|
Future<void> setMalay() async {
|
||||||
await setLocale(const Locale('ms'));
|
await setLocale(const Locale('ms'));
|
||||||
|
|||||||
@@ -102,13 +102,17 @@ class ReportCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
SeverityBadge(severity: report.severity, small: true),
|
SeverityBadge(severity: report.severity, small: true),
|
||||||
const SizedBox(width: 8),
|
|
||||||
StatusBadge(status: report.status),
|
StatusBadge(status: report.status),
|
||||||
const SizedBox(width: 8),
|
Text(
|
||||||
Text(_formatTime(report.createdAt), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
_formatTime(report.createdAt),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user