feat: implement core architecture, design system, localization, and trip features for both driver and rider apps.
This commit is contained in:
@@ -1,76 +1,260 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../../auth/cubit/auth_cubit.dart';
|
||||
import '../../auth/cubit/auth_state.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../trip/bloc/trip_bloc.dart';
|
||||
import '../../trip/bloc/trip_state.dart';
|
||||
import '../../trip/bloc/trip_event.dart';
|
||||
import '../cubit/home_cubit.dart';
|
||||
import '../data/models/place_model.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
IntaleqMapController? _mapController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// فحص رحلة نشطة عند فتح الشاشة.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<TripBloc>().add(const TripCheckActive());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final center = const LatLng(31.9539, 35.9106); // عمّان
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tripz'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => context.read<AuthCubit>().logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
options: MapOptions(initialCenter: center, initialZoom: 13),
|
||||
children: [
|
||||
// TODO: استبدال بلاطات انطلق (Martin) بدل OSM
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.mobileapp.store.ride',
|
||||
),
|
||||
MarkerLayer(markers: [
|
||||
Marker(
|
||||
point: center,
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: const Icon(Icons.location_pin, color: Colors.red, size: 40),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 24,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: BlocBuilder<AuthCubit, AuthState>(
|
||||
builder: (context, state) {
|
||||
final name = state.user?.name ?? state.user?.phone ?? '';
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('مرحباً $name',
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.local_taxi),
|
||||
label: const Text('اطلب رحلة (قريباً)'),
|
||||
),
|
||||
],
|
||||
);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return BlocListener<TripBloc, TripState>(
|
||||
listenWhen: (prev, cur) => prev.trip?.status != cur.trip?.status,
|
||||
listener: (context, state) {
|
||||
if (state.trip != null && !state.isCompleted) {
|
||||
// رحلة نشطة — انتقل للتتبع.
|
||||
context.push('/trip-tracking');
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// الخريطة
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
final center = state.currentLocation ?? const LatLng(31.9539, 35.9106);
|
||||
return IntaleqMap(
|
||||
apiKey: '',
|
||||
initialCameraPosition: CameraPosition(target: center, zoom: 15),
|
||||
myLocationEnabled: true,
|
||||
zoomControlsEnabled: false,
|
||||
compassEnabled: false,
|
||||
onMapCreated: (controller) => _mapController = controller,
|
||||
markers: _buildMarkers(state),
|
||||
onCameraMove: (position) {
|
||||
context.read<HomeCubit>().updateCameraTarget(position.target);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// زر إعادة التوسيط
|
||||
Positioned(
|
||||
right: T.pagePadding,
|
||||
bottom: 180,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'recenter',
|
||||
backgroundColor: cs.surface,
|
||||
onPressed: () {
|
||||
final loc = context.read<HomeCubit>().state.currentLocation;
|
||||
if (loc != null) {
|
||||
_mapController?.animateCamera(CameraUpdate.newLatLngZoom(loc, 15));
|
||||
}
|
||||
},
|
||||
child: Icon(Icons.my_location_rounded, color: cs.onSurface),
|
||||
),
|
||||
),
|
||||
|
||||
// الشريط السفلي
|
||||
Positioned(
|
||||
left: T.pagePadding,
|
||||
right: T.pagePadding,
|
||||
bottom: T.pagePadding,
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BlocBuilder<AuthCubit, AuthState>(
|
||||
builder: (context, authState) {
|
||||
final name = authState.user?.name ?? '';
|
||||
if (name.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: T.s12),
|
||||
child: Text(
|
||||
l10n.welcome(name),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
if (state.destination != null)
|
||||
_DestinationPreview(
|
||||
destination: state.destination!,
|
||||
onTap: () => _openDestinationSearch(context),
|
||||
onClear: () => context.read<HomeCubit>().clearTripData(),
|
||||
)
|
||||
else
|
||||
TripzButton(
|
||||
label: l10n.whereTo,
|
||||
style: TripzButtonStyle.secondary,
|
||||
icon: Icons.search_rounded,
|
||||
onPressed: () => _openDestinationSearch(context),
|
||||
),
|
||||
if (state.destination != null) ...[
|
||||
const SizedBox(height: T.s12),
|
||||
TripzButton(
|
||||
label: l10n.requestRide,
|
||||
onPressed: () => _openRoutePreview(context, state),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// حالة التحميل
|
||||
BlocBuilder<HomeCubit, HomeState>(
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return Container(
|
||||
color: Colors.black26,
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Set<Marker> _buildMarkers(HomeState state) {
|
||||
final markers = <Marker>{};
|
||||
if (state.pickup != null) {
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('pickup'),
|
||||
position: LatLng(state.pickup!.lat, state.pickup!.lng),
|
||||
icon: InlqBitmap.defaultMarkerWithHue(210),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
zIndex: 2,
|
||||
));
|
||||
}
|
||||
if (state.destination != null) {
|
||||
markers.add(Marker(
|
||||
markerId: const MarkerId('destination'),
|
||||
position: LatLng(state.destination!.lat, state.destination!.lng),
|
||||
icon: InlqBitmap.defaultMarkerWithHue(0),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
zIndex: 1,
|
||||
));
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
void _openDestinationSearch(BuildContext context) {
|
||||
context.push<PlaceModel>('/destination').then((place) {
|
||||
if (place != null && mounted) {
|
||||
context.read<HomeCubit>().setDestination(place);
|
||||
_openRoutePreview(context, context.read<HomeCubit>().state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _openRoutePreview(BuildContext context, HomeState state) {
|
||||
if (state.pickup == null || state.destination == null) return;
|
||||
context.push('/route-preview', extra: {
|
||||
'pickup': state.pickup,
|
||||
'destination': state.destination,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _DestinationPreview extends StatelessWidget {
|
||||
final PlaceModel destination;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onClear;
|
||||
|
||||
const _DestinationPreview({
|
||||
required this.destination,
|
||||
required this.onTap,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(T.s12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.location_on_outlined, color: cs.error, size: 20),
|
||||
const SizedBox(width: T.s8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
destination.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (destination.address != null)
|
||||
Text(
|
||||
destination.address!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onClear,
|
||||
icon: Icon(Icons.close_rounded, color: cs.onSurfaceVariant, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user