feat: implement splash and onboarding flows, update app assets, and add project design documentation
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../core/app_info.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/di.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../auth/cubit/auth_cubit.dart';
|
||||
import '../../auth/cubit/auth_state.dart';
|
||||
import '../../../core/ui/tripz_scaffold.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _kOnboardingSeen = 'onboarding_seen';
|
||||
|
||||
late final AnimationController _revealController;
|
||||
late final Animation<double> _fade;
|
||||
late final Animation<double> _scale;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// حركة دخول واحدة هادئة: fade + scale ضمن أزمنة tokens (docs/29 §3.1)
|
||||
_revealController = AnimationController(vsync: this, duration: T.slow);
|
||||
final curved = CurvedAnimation(
|
||||
parent: _revealController,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
_fade = curved;
|
||||
_scale = Tween<double>(begin: 0.85, end: 1.0).animate(curved);
|
||||
_revealController.forward();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _route());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_revealController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _route() async {
|
||||
FlutterNativeSplash.remove();
|
||||
// Minimum 800ms for the native splash to fade out gracefully
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final authStatus = context.read<AuthCubit>().state.status;
|
||||
|
||||
if (authStatus != AuthStatus.authenticated) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (!mounted) return;
|
||||
final seen = prefs.getBool(_kOnboardingSeen) ?? false;
|
||||
if (!seen) {
|
||||
await prefs.setBool(_kOnboardingSeen, true);
|
||||
if (!mounted) return;
|
||||
context.go('/onboarding');
|
||||
} else {
|
||||
context.go('/phone');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final api = getIt<ApiClient>();
|
||||
|
||||
Future<Map<String, dynamic>?> pendingRating() async {
|
||||
try {
|
||||
final r = await api.dio
|
||||
.get('/trips/rating/pending')
|
||||
.timeout(const Duration(seconds: 3));
|
||||
final data = r.data;
|
||||
if (data is Map<String, dynamic> && data['pending'] != null) {
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasActiveTrip() async {
|
||||
try {
|
||||
final r = await api.dio
|
||||
.get('/trips/mine')
|
||||
.timeout(const Duration(seconds: 3));
|
||||
final data = r.data;
|
||||
if (data == null) return false;
|
||||
final items = (data['items'] ?? data) as List;
|
||||
if (items.isEmpty) return false;
|
||||
final status = items.first['status'] as String? ?? '';
|
||||
return [
|
||||
'searching',
|
||||
'assigned',
|
||||
'driver_arriving',
|
||||
'driver_arrived',
|
||||
'in_progress',
|
||||
].contains(status);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final results = await Future.wait([pendingRating(), hasActiveTrip()]);
|
||||
final rating = results[0] as Map<String, dynamic>?;
|
||||
final activeTrip = results[1] as bool;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (rating != null) {
|
||||
context.go('/trip-rating', extra: rating);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTrip) {
|
||||
context.go('/trip-tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
context.go('/home');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
// Just show the logo briefly — native splash handles the branded experience
|
||||
return TripzScaffold(
|
||||
backgroundColor: cs.surface,
|
||||
padding: false,
|
||||
safeAreaTop: false,
|
||||
safeAreaBottom: false,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _revealController,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: _fade.value,
|
||||
child: Transform.scale(scale: _scale.value, child: child),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s48),
|
||||
child: Image.asset(
|
||||
'assets/images/logo.png',
|
||||
width: 240,
|
||||
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.directions_car_filled_rounded,
|
||||
size: 120,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// مؤشر تحميل خفيف ورقم الإصدار أسفل الشاشة — يظهر بعد اكتمال حركة الشعار
|
||||
Positioned(
|
||||
bottom: T.s32,
|
||||
child: FadeTransition(
|
||||
opacity: _revealController,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (i) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s4),
|
||||
child: _PulsingDot(color: cs.primary, delay: i * 150),
|
||||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
Text(
|
||||
AppInfo.formattedVersion,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: cs.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// نقطة نابضة صغيرة — مؤشر تحميل زخرفي بلا شبكة، `CustomPaint`-free ويكفي هنا Container بسيط.
|
||||
class _PulsingDot extends StatefulWidget {
|
||||
const _PulsingDot({required this.color, required this.delay});
|
||||
|
||||
final Color color;
|
||||
final int delay;
|
||||
|
||||
@override
|
||||
State<_PulsingDot> createState() => _PulsingDotState();
|
||||
}
|
||||
|
||||
class _PulsingDotState extends State<_PulsingDot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(vsync: this, duration: T.slow * 3)
|
||||
..repeat(reverse: true);
|
||||
Future.delayed(Duration(milliseconds: widget.delay), () {
|
||||
if (mounted) _controller.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) => Opacity(
|
||||
opacity: 0.2 + (_controller.value * 0.6),
|
||||
child: Container(
|
||||
width: T.s8,
|
||||
height: T.s8,
|
||||
decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user