feat: implement core architecture, design system, localization, and trip features for both driver and rider apps.
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/l10n/app_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import '../../../core/design/tokens.dart';
|
||||
import '../../../core/ui/tripz_button.dart';
|
||||
import '../../trip/bloc/trip_bloc.dart';
|
||||
import '../../trip/bloc/trip_state.dart';
|
||||
import '../../trip/bloc/trip_event.dart';
|
||||
import '../cubit/quote_cubit.dart';
|
||||
import '../data/models/place_model.dart';
|
||||
import 'widgets/ride_class_card.dart';
|
||||
|
||||
/// شاشة معاينة المسار + اختيار فئة الرحلة + تأكيد الطلب.
|
||||
class RoutePreviewScreen extends StatefulWidget {
|
||||
final PlaceModel pickup;
|
||||
final PlaceModel destination;
|
||||
|
||||
const RoutePreviewScreen({
|
||||
super.key,
|
||||
required this.pickup,
|
||||
required this.destination,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RoutePreviewScreen> createState() => _RoutePreviewScreenState();
|
||||
}
|
||||
|
||||
class _RoutePreviewScreenState extends State<RoutePreviewScreen> {
|
||||
IntaleqMapController? _mapController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<QuoteCubit>().fetchQuote(
|
||||
pickupLat: widget.pickup.lat,
|
||||
pickupLng: widget.pickup.lng,
|
||||
pickupAddress: widget.pickup.name,
|
||||
dropoffLat: widget.destination.lat,
|
||||
dropoffLng: widget.destination.lng,
|
||||
dropoffAddress: widget.destination.name,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final pickupLoc = LatLng(widget.pickup.lat, widget.pickup.lng);
|
||||
final destLoc = LatLng(widget.destination.lat, widget.destination.lng);
|
||||
|
||||
return BlocListener<TripBloc, TripState>(
|
||||
listenWhen: (prev, cur) =>
|
||||
prev.requestStatus != cur.requestStatus &&
|
||||
cur.requestStatus == TripRequestStatus.searching,
|
||||
listener: (context, state) {
|
||||
// تم إنشاء الرحلة — انتقل للتتبع.
|
||||
context.pushReplacement('/trip-tracking');
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// الخريطة مع المسار
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Stack(
|
||||
children: [
|
||||
IntaleqMap(
|
||||
apiKey: '',
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: LatLng(
|
||||
(pickupLoc.latitude + destLoc.latitude) / 2,
|
||||
(pickupLoc.longitude + destLoc.longitude) / 2,
|
||||
),
|
||||
zoom: 14,
|
||||
),
|
||||
myLocationEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
compassEnabled: false,
|
||||
onMapCreated: (c) => _mapController = c,
|
||||
markers: {
|
||||
Marker(
|
||||
markerId: const MarkerId('pickup'),
|
||||
position: pickupLoc,
|
||||
icon: InlqBitmap.defaultMarkerWithHue(210),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
),
|
||||
Marker(
|
||||
markerId: const MarkerId('destination'),
|
||||
position: destLoc,
|
||||
icon: InlqBitmap.defaultMarkerWithHue(0),
|
||||
anchor: const Offset(0.5, 1.0),
|
||||
),
|
||||
},
|
||||
polylines: {
|
||||
Polyline(
|
||||
polylineId: const PolylineId('route'),
|
||||
points: [pickupLoc, destLoc],
|
||||
color: cs.primary,
|
||||
width: 4,
|
||||
),
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + T.s8,
|
||||
left: T.s12,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'back',
|
||||
backgroundColor: cs.surface,
|
||||
onPressed: () => context.pop(),
|
||||
child: const Icon(Icons.arrow_back_rounded),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + T.s8,
|
||||
right: T.s12,
|
||||
child: BlocBuilder<QuoteCubit, QuoteState>(
|
||||
builder: (context, state) {
|
||||
if (state.quote == null) return const SizedBox.shrink();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.s12, vertical: T.s8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
boxShadow: [T.shadowSm],
|
||||
),
|
||||
child: Text(
|
||||
l10n.routeSummary(
|
||||
state.quote!.distanceText ?? '',
|
||||
state.quote!.durationText ?? '',
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// قائمة فئات الرحلة
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Container(
|
||||
color: cs.surface,
|
||||
child: BlocBuilder<QuoteCubit, QuoteState>(
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.errorGeneric),
|
||||
const SizedBox(height: T.s12),
|
||||
TripzButton(
|
||||
label: l10n.retry,
|
||||
onPressed: () => context.read<QuoteCubit>().fetchQuote(
|
||||
pickupLat: widget.pickup.lat,
|
||||
pickupLng: widget.pickup.lng,
|
||||
pickupAddress: widget.pickup.name,
|
||||
dropoffLat: widget.destination.lat,
|
||||
dropoffLng: widget.destination.lng,
|
||||
dropoffAddress: widget.destination.name,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final classes = state.quote?.classes ?? [];
|
||||
if (classes.isEmpty) {
|
||||
return Center(child: Text(l10n.noResults));
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(T.pagePadding, T.s16, T.pagePadding, T.s8),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(l10n.rideClasses, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
|
||||
itemCount: classes.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: T.s8),
|
||||
itemBuilder: (context, index) {
|
||||
final cls = classes[index];
|
||||
return RideClassCard(
|
||||
quoteClass: cls,
|
||||
selected: state.selectedClass == cls.vehicleClass,
|
||||
onTap: () => context.read<QuoteCubit>().selectClass(cls.vehicleClass),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
children: [
|
||||
_PaymentRow(
|
||||
method: state.paymentMethod,
|
||||
onChanged: (m) => context.read<QuoteCubit>().setPaymentMethod(m),
|
||||
),
|
||||
const SizedBox(height: T.s12),
|
||||
// زر طلب الرحلة — يستخدم TripBloc
|
||||
BlocBuilder<TripBloc, TripState>(
|
||||
builder: (context, tripState) {
|
||||
final isLoading = tripState.requestStatus == TripRequestStatus.loading;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: TripzButton(
|
||||
label: isLoading ? l10n.loading : l10n.requestRide,
|
||||
onPressed: (state.selectedClass != null && !isLoading)
|
||||
? () => _requestTrip(context, state)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _requestTrip(BuildContext context, QuoteState state) {
|
||||
if (state.selectedClass == null) return;
|
||||
context.read<TripBloc>().add(TripRequested(
|
||||
pickupLat: widget.pickup.lat,
|
||||
pickupLng: widget.pickup.lng,
|
||||
pickupAddress: widget.pickup.name,
|
||||
dropoffLat: widget.destination.lat,
|
||||
dropoffLng: widget.destination.lng,
|
||||
dropoffAddress: widget.destination.name,
|
||||
vehicleClass: state.selectedClass!,
|
||||
paymentMethod: state.paymentMethod,
|
||||
couponCode: state.couponCode,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _PaymentRow extends StatelessWidget {
|
||||
final String method;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const _PaymentRow({required this.method, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding, vertical: T.s12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
method == 'wallet' ? Icons.account_balance_wallet_outlined : Icons.money_rounded,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: T.s12),
|
||||
Text(l10n.paymentMethod, style: Theme.of(context).textTheme.bodyMedium),
|
||||
const Spacer(),
|
||||
DropdownButton<String>(
|
||||
value: method,
|
||||
underline: const SizedBox.shrink(),
|
||||
isDense: true,
|
||||
items: [
|
||||
DropdownMenuItem(value: 'cash', child: Text(l10n.cash)),
|
||||
DropdownMenuItem(value: 'wallet', child: Text(l10n.walletPayment)),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user