first commit

This commit is contained in:
2026-08-11 13:16:33 +05:30
parent 18531bf46b
commit c8350563d9
388 changed files with 69656 additions and 0 deletions

View File

@@ -0,0 +1,741 @@
import 'package:flutter/material.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'dart:async';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:slide_to_submit_button/slide_to_submit_button.dart';
import 'package:image_picker/image_picker.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
/// ------------------------------
/// MAIN WIDGET WITH TWO BUTTONS
/// ------------------------------
class OrderStatusRow extends StatefulWidget {
final String currentStatus;
final Future<bool> Function(
String newStatus, {
String? notes,
String? proofImagePath,
})
onStatusChange;
final bool enabled;
/// Secondary per-booking actions rendered as compact icon buttons at the
/// start of the action bar, so the card shows a single row of controls
/// (Call · Details · Reject · Slide-to-Accept) instead of two stacked rows.
/// Null hides that button. They stay tappable even while the primary
/// slide/accept is disabled (e.g. multi-select mode) — a rider still needs to
/// call the customer or read the parcel details.
final VoidCallback? onCall;
final VoidCallback? onDetails;
const OrderStatusRow({
super.key,
required this.currentStatus,
required this.onStatusChange,
this.enabled = true,
this.onCall,
this.onDetails,
});
@override
State<OrderStatusRow> createState() => _OrderStatusRowState();
}
class _OrderStatusRowState extends State<OrderStatusRow> {
bool _isProcessing = false;
String? _overrideStatus;
@override
void didUpdateWidget(covariant OrderStatusRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentStatus != widget.currentStatus &&
mounted &&
_overrideStatus != null &&
widget.currentStatus == _overrideStatus) {
setState(() {
_overrideStatus = null;
});
} else if (oldWidget.currentStatus != widget.currentStatus &&
_overrideStatus != null) {
_overrideStatus = null;
}
}
String get _effectiveStatus => _overrideStatus ?? widget.currentStatus;
Future<bool> _onStatusChanged(
String newStatus, {
String? notes,
String? proofImagePath,
}) async {
debugPrint(
'[OSR] _onStatusChanged: $newStatus, proofImagePath: $proofImagePath',
);
if (_isProcessing) return false;
// Optimistic UI: flip status immediately to avoid visible lag.
setState(() {
_isProcessing = true;
_overrideStatus = newStatus;
});
bool success = false;
bool timedOut = false;
try {
// Hard cap wait time to keep UI from spinning indefinitely.
success = await widget
.onStatusChange(
newStatus,
notes: notes,
proofImagePath: proofImagePath,
)
.timeout(
const Duration(seconds: 3),
onTimeout: () {
timedOut = true;
// Assume success on timeout to avoid UI rollback; data will
// refresh from server on next fetch.
return true;
},
);
} catch (_) {
success = false;
}
if (!mounted) return success;
setState(() {
_isProcessing = false;
// If API failed, revert the optimistic status.
// If timed out, keep the optimistic status (server likely completed).
_overrideStatus = (success || timedOut) ? newStatus : null;
});
return success;
}
// ------------------------------
// REJECT SHEET
// ------------------------------
void _showRejectSheet(BuildContext context) {
showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
String selectedReason = "";
bool isLoading = false;
final List<String> reasons = [
"Customer not reachable",
"Wrong address",
"Out of service area",
"Other reason",
];
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Reject booking",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
IconButton(
tooltip: 'Close',
icon: Icon(
Icons.close_rounded,
color: ColorConstants.onSurfaceVariant,
),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 10),
for (String reason in reasons)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onTap: () => setModalState(() {
selectedReason = reason;
}),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: ButtonSizes.primary,
width: double.infinity,
decoration: BoxDecoration(
color: selectedReason == reason
? ColorConstants.primary
: ColorConstants.neutralLight,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(
color: selectedReason == reason
? ColorConstants.primary
: ColorConstants.dividerColor,
),
),
alignment: Alignment.center,
child: Text(
reason,
style: TextStyle(
color: selectedReason == reason
? Colors.white
: ColorConstants.onSurface,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
fontSize: 15,
),
),
),
),
),
const SizedBox(height: 20),
MilerButton(
label: "Reject booking",
color: ColorConstants.error,
loading: isLoading,
onPressed: selectedReason.isEmpty
? null
: () async {
setModalState(() => isLoading = true);
final success = await _onStatusChanged(
"REJECTED",
notes: selectedReason,
);
if (context.mounted) {
Navigator.pop(context, success);
}
},
),
],
),
);
},
),
);
},
);
}
// ------------------------------
// CANCEL SHEET
// ------------------------------
void _showCancelSheet(BuildContext context) {
showModalBottomSheet(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
bool confirmCancel = false;
bool isLoading = false;
return SafeArea(
top: false,
left: false,
right: false,
bottom: true,
child: StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Cancel this Booking?",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: FontConstants.fontFamily,
),
),
const SizedBox(height: 10),
const Text(
"Once cancelled, this Booking will return to pending state.",
textAlign: TextAlign.center,
),
const SizedBox(height: 25),
CheckboxListTile(
value: confirmCancel,
onChanged: (value) =>
setModalState(() => confirmCancel = value ?? false),
title: const Text("I confirm to cancel this Booking"),
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 20),
MilerButton(
label: "Confirm cancel",
color: ColorConstants.warning,
loading: isLoading,
onPressed: !confirmCancel
? null
: () async {
setModalState(() => isLoading = true);
// Capture the messenger BEFORE popping — using the
// sheet's context after pop looks up a deactivated
// widget and can crash.
final messenger = ScaffoldMessenger.of(context);
// Actually cancel via the real status-change path
// instead of just showing a fake success snackbar.
final success = await _onStatusChanged(
"CANCELLED",
notes: "Cancelled by rider",
);
if (context.mounted) {
Navigator.pop(context, success);
}
messenger.showSnackBar(
SnackBar(
content: Text(
success
? "Booking cancelled"
: "Couldn't cancel. Please try again.",
),
),
);
},
),
],
),
);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
final String currentStatus = _effectiveStatus;
final bool showCancel =
currentStatus == "ARRIVED" || currentStatus == "PICKED";
return Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 12),
child: Stack(
children: [
// Single action bar: [Call] [Details] [Reject/Cancel] [Slide → …].
// The three leading actions are compact icon buttons; the slider keeps
// the remaining width because it's the primary action and needs room
// to be draggable.
Row(
children: [
if (widget.onCall != null) ...[
_IconAction(
icon: Icons.phone_rounded,
color: ColorConstants.acceptGreen,
tooltip: 'Call customer',
onTap: widget.onCall,
),
const SizedBox(width: 8),
],
if (widget.onDetails != null) ...[
_IconAction(
icon: Icons.receipt_long_rounded,
color: ColorConstants.onSurfaceVariant,
tooltip: 'Booking details',
onTap: widget.onDetails,
),
const SizedBox(width: 8),
],
// LEFT PRIMARY (Reject / Cancel) — compact tonal destructive
_IconAction(
icon: showCancel ? Icons.undo_rounded : Icons.close_rounded,
color: showCancel
? ColorConstants.warning
: ColorConstants.error,
tooltip: showCancel ? 'Cancel pickup' : 'Reject booking',
onTap: () {
if (showCancel) {
_showCancelSheet(context);
} else {
_showRejectSheet(context);
}
},
),
const SizedBox(width: 10),
// RIGHT BUTTON (ACCEPT / ARRIVED / PICKED) — takes remaining width
OrderStatusButton(
key: ValueKey(currentStatus),
currentStatus: currentStatus,
onStatusChange: (status, {proofImagePath}) =>
_onStatusChanged(status, proofImagePath: proofImagePath),
enabled: widget.enabled && !_isProcessing,
),
],
),
if (_isProcessing)
Positioned.fill(
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: Center(
child: Container(
height: 46,
width: 46,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(10),
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
ColorConstants.primary,
),
),
),
),
),
),
),
],
),
);
}
}
/// Reusable rounded pill action button used by the booking status row.
/// [filled] gives a solid brand-colored button (Accept side); otherwise it
/// renders as a soft tonal button with an outline (Reject / Cancel side).
class StatusPill extends StatelessWidget {
final String label;
final IconData icon;
final Color color;
final bool filled;
final bool enabled;
final VoidCallback? onTap;
const StatusPill({
super.key,
required this.label,
required this.icon,
required this.color,
this.filled = true,
this.enabled = true,
this.onTap,
});
@override
Widget build(BuildContext context) {
final Color fg = !enabled
? ColorConstants.disabledFill
: (filled ? Colors.white : color);
final Color bg = !enabled
? ColorConstants.neutralLight
: (filled ? color : color.withValues(alpha: 0.10));
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Ink(
height: ButtonSizes.secondary,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: filled
? null
: Border.all(
color: color.withValues(alpha: enabled ? 0.22 : 0.0),
),
boxShadow: (filled && enabled)
? [
BoxShadow(
color: color.withValues(alpha: 0.28),
blurRadius: 12,
offset: const Offset(0, 5),
),
]
: null,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: fg, size: 18),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 14.5,
color: fg,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
),
);
}
}
/// Compact square icon button (50×50) used for the secondary actions in the
/// booking action bar — Call, Details, Reject/Cancel. Tonal fill + the action's
/// colour, so the destructive Reject reads red without a full-width label
/// eating horizontal space the slider needs.
class _IconAction extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback? onTap;
final String? tooltip;
const _IconAction({
required this.icon,
required this.color,
this.onTap,
this.tooltip,
});
@override
Widget build(BuildContext context) {
final Widget button = Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Ink(
height: ButtonSizes.secondary,
width: ButtonSizes.secondary,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
border: Border.all(color: color.withValues(alpha: 0.20)),
),
child: Icon(icon, color: color, size: 20),
),
),
);
if (tooltip == null) return button;
return Tooltip(message: tooltip!, child: button);
}
}
/// ------------------------------
/// STATUS BUTTON LOGIC (NO PICKUP)
/// ------------------------------
class OrderStatusButton extends StatefulWidget {
final String currentStatus;
final Future<bool> Function(String newStatus, {String? proofImagePath})
onStatusChange;
final bool enabled;
const OrderStatusButton({
super.key,
required this.currentStatus,
required this.onStatusChange,
this.enabled = true,
});
@override
State<OrderStatusButton> createState() => _OrderStatusButtonState();
}
class _OrderStatusButtonState extends State<OrderStatusButton> {
String get _buttonText => widget.currentStatus;
Color _getButtonColor() {
switch (_buttonText) {
case "ARRIVED":
return ColorConstants.warning;
case "PICKED":
return ColorConstants.primary;
default:
return ColorConstants.acceptGreen;
}
}
IconData _iconForStatus() {
switch (_buttonText) {
case "ARRIVED":
return Icons.inventory_2_rounded;
case "PICKED":
return Icons.task_alt_rounded;
case "ACCEPTED":
return Icons.directions_bike_rounded;
default:
return Icons.check_rounded;
}
}
// Single-step next status. Null = terminal (PICKED) or unknown — nothing to
// advance to.
String? get _nextStatus {
switch (_buttonText) {
case "ACCEPT":
return "ACCEPTED";
case "ACCEPTED":
return "ARRIVED";
case "ARRIVED":
return "PICKED";
default:
return null;
}
}
// Friendly action verb shown on the slider, e.g. "Slide → Accept" instead of
// the raw backend status token ("ACCEPTED").
String get _actionLabel {
switch (_nextStatus) {
case "ACCEPTED":
return "Accept";
case "ARRIVED":
return "Arrived";
case "PICKED":
return "Pick Up";
default:
return _nextStatus ?? "";
}
}
bool _advancing = false;
/// Advances one step. PICKED requires a proof photo. Runs the same logic the
/// old bottom-sheet slider did — just triggered inline, in one gesture.
Future<void> _performAdvance(dynamic sliderController) async {
final next = _nextStatus;
if (next == null || _advancing) return;
_advancing = true;
try {
if (next == "PICKED") {
final XFile? photo = await ImagePicker().pickImage(
source: ImageSource.camera,
imageQuality: 50,
);
if (photo != null) {
await widget.onStatusChange(next, proofImagePath: photo.path);
}
} else {
await widget.onStatusChange(next);
}
} finally {
_advancing = false;
try {
sliderController?.reset();
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
final next = _nextStatus;
final color = _getButtonColor();
// Terminal / unknown status → a non-interactive pill (nothing to swipe).
if (next == null) {
return Expanded(
child: StatusPill(
label: _buttonText,
icon: _iconForStatus(),
color: color,
filled: true,
enabled: false,
),
);
}
// Inline slide-to-advance — one gesture on the card, no bottom sheet.
return Expanded(
child: IgnorePointer(
ignoring: !widget.enabled,
child: Opacity(
opacity: widget.enabled ? 1 : 0.5,
child: SizedBox(
height: ButtonSizes.secondary,
child: SlideToSubmit.custom(
height: ButtonSizes.secondary,
sliderWidth: 38,
padding: const EdgeInsets.all(5),
backgroundDecoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
foregroundDecoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
slider: Center(
child: Container(
height: 40,
width: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.arrow_forward_rounded,
size: 18,
color: color,
),
),
),
hint: Align(
alignment: Alignment.center,
child: Text(
'Slide → $_actionLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
fontFamily: FontConstants.fontFamily,
letterSpacing: 0.2,
color: color,
),
),
),
onSubmit: (controller) async {
await _performAdvance(controller);
},
),
),
),
),
);
}
}