Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +0,0 @@
|
||||
class ApiConstants {
|
||||
static const String baseUrl = 'https://api.doormile.com/api/v1';
|
||||
|
||||
// ---- Auth ----
|
||||
static const String register = '$baseUrl/customer/register';
|
||||
static const String login = '$baseUrl/customer/login';
|
||||
static const String verifyPin = '$baseUrl/customer/verify-pin';
|
||||
static const String resetPin = '$baseUrl/customer/reset-pin';
|
||||
static const String logout = '$baseUrl/customer/logout';
|
||||
|
||||
// ---- Profile ----
|
||||
static const String profile = '$baseUrl/customer/profile';
|
||||
|
||||
// ---- Pickup Locations ----
|
||||
static const String locations = '$baseUrl/customer/locations';
|
||||
static String updateLocation(String id) => '$baseUrl/customer/locations/$id';
|
||||
static String deleteLocation(String id) => '$baseUrl/customer/locations/$id';
|
||||
|
||||
// ---- Pickups (Bookings) ----
|
||||
static const String createPickup = '$baseUrl/customer/bookings';
|
||||
static const String getPickups = '$baseUrl/customer/bookings';
|
||||
static String getPickupDetails(String bookingId) => '$baseUrl/customer/bookings/$bookingId';
|
||||
static String cancelPickup(String bookingId) => '$baseUrl/customer/bookings/$bookingId/cancel';
|
||||
|
||||
// ---- Pricing ----
|
||||
static const String checkPrice = '$baseUrl/pricing/check';
|
||||
|
||||
// ---- Tracking ----
|
||||
static String trackPickup(String trackingNo) => '$baseUrl/customer/track/$trackingNo';
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// Backgrounds & Surfaces
|
||||
static const surface = Color(0xFFF7F4F2);
|
||||
static const surfaceDim = Color(0xFFE8DED8);
|
||||
static const surfaceContainerLowest = Color(0xFFFFFFFF);
|
||||
static const surfaceContainerLow = Color(0xFFFCFAF8);
|
||||
static const surfaceContainer = Color(0xFFF0E8E3);
|
||||
static const surfaceContainerHigh = Color(0xFFE6DAD3);
|
||||
static const surfaceContainerHighest = Color(0xFFD8C8BF);
|
||||
static const surfaceVariant = Color(0xFFEDE3DD);
|
||||
|
||||
// Text Colors
|
||||
static const onSurface = Color(0xFF271D1B);
|
||||
static const onSurfaceVariant = Color(0xFF756762);
|
||||
static const outline = Color(0xFFA9958D);
|
||||
static const outlineVariant = Color(0xFFD8C8BF);
|
||||
static const inverseSurface = Color(0xFF271D1B);
|
||||
|
||||
// Primary (brand red)
|
||||
static const primary = Color(0xFFC31723);
|
||||
static const primaryContainer = Color(0xFF7D1017);
|
||||
static const onPrimary = Color(0xFFFFFFFF);
|
||||
static const onPrimaryContainer = Color(0xFFFFEDEE);
|
||||
static const primaryFixed = Color(0xFFFFE9E7);
|
||||
static const primaryFixedDim = Color(0xFFFFCFCB);
|
||||
|
||||
// Secondary (blue)
|
||||
static const secondary = Color(0xFF126E82);
|
||||
static const secondaryContainer = Color(0xFFDDF4F4);
|
||||
static const secondaryFixed = Color(0xFFC6ECEC);
|
||||
static const secondaryFixedDim = Color(0xFF9FDADB);
|
||||
static const onSecondaryFixedVariant = Color(0xFF064A5A);
|
||||
|
||||
// Tertiary (eco green)
|
||||
static const tertiary = Color(0xFF16824E);
|
||||
static const tertiaryContainer = Color(0xFFE7F7ED);
|
||||
static const tertiaryFixed = Color(0xFFDCFCE7);
|
||||
static const tertiaryFixedDim = Color(0xFFBBF7D0);
|
||||
static const onTertiaryFixedVariant = Color(0xFF14532D);
|
||||
|
||||
// Status / Warning
|
||||
static const error = Color(0xFFDC2626);
|
||||
static const errorContainer = Color(0xFFFEE2E2);
|
||||
static const onErrorContainer = Color(0xFF7F1D1D);
|
||||
static const warning = Color(0xFFFF7A00); // Orange
|
||||
static const success = Color(0xFF16A34A); // Green
|
||||
|
||||
// Journey accents (brand narrative)
|
||||
static const firstMile = Color(0xFFEA580C); // orange — pickup
|
||||
static const midMile = Color(0xFF0E7C7B); // teal — transit
|
||||
static const lastMile = Color(0xFF1D4EDB); // blue — delivery
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
|
||||
/// Central theme + reusable text styles based on the Doormile design system.
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const fontFamily = 'Public Sans';
|
||||
|
||||
static ThemeData get light {
|
||||
final base = ThemeData.light(useMaterial3: true);
|
||||
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: AppColors.surface,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: AppColors.primary,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
primaryContainer: AppColors.primaryContainer,
|
||||
onPrimaryContainer: AppColors.onPrimaryContainer,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: Colors.white,
|
||||
tertiary: AppColors.tertiary,
|
||||
onTertiary: Colors.white,
|
||||
error: AppColors.error,
|
||||
surface: AppColors.surface,
|
||||
onSurface: AppColors.onSurface,
|
||||
surfaceContainerHighest: AppColors.surfaceContainerHighest,
|
||||
outline: AppColors.outline,
|
||||
outlineVariant: AppColors.outlineVariant,
|
||||
),
|
||||
textTheme: base.textTheme.apply(
|
||||
fontFamily: fontFamily,
|
||||
bodyColor: AppColors.onSurface,
|
||||
displayColor: AppColors.onSurface,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
foregroundColor: AppColors.primary,
|
||||
centerTitle: false,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceContainerLowest,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
hintStyle: bodyMd.copyWith(color: AppColors.outline),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.4),
|
||||
),
|
||||
),
|
||||
splashFactory: InkRipple.splashFactory,
|
||||
);
|
||||
}
|
||||
|
||||
static const _f = fontFamily;
|
||||
|
||||
/// Builds a style backed by the variable font's true `wght` axis so weights
|
||||
/// render at their real glyph widths (no synthetic-bold widening).
|
||||
static TextStyle _ts(double size, int wght, double lineHeight, [Color? color, double? letterSpacing]) {
|
||||
return TextStyle(
|
||||
fontFamily: _f,
|
||||
fontSize: size,
|
||||
height: lineHeight / size,
|
||||
letterSpacing: letterSpacing,
|
||||
color: color ?? AppColors.onSurface,
|
||||
fontWeight: FontWeight.values[(wght ~/ 100) - 1],
|
||||
fontVariations: [FontVariation('wght', wght.toDouble())],
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Typography helpers (mirror DESIGN.md scale) ----
|
||||
static final TextStyle headlineLg = _ts(22, 800, 28, null, -0.5);
|
||||
static final TextStyle headlineMd = _ts(17, 700, 24, null, -0.3);
|
||||
static final TextStyle headlineSm = _ts(15, 700, 22, null, -0.2);
|
||||
static final TextStyle bodyLg = _ts(15, 400, 22);
|
||||
static final TextStyle bodyMd = _ts(13, 400, 20);
|
||||
static final TextStyle labelMd = _ts(13, 700, 20);
|
||||
static final TextStyle caption = _ts(11, 500, 16, AppColors.onSurfaceVariant);
|
||||
static final TextStyle button = _ts(14, 700, 20, null, 0.2);
|
||||
|
||||
/// Ambient card shadow (Level 1 elevation from the design brief).
|
||||
static List<BoxShadow> get cardShadow => const [
|
||||
BoxShadow(color: Color(0x12000000), blurRadius: 28, offset: Offset(0, 12)),
|
||||
BoxShadow(color: Color(0x08C31723), blurRadius: 8, offset: Offset(0, 2)),
|
||||
];
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
|
||||
class SnackbarUtils {
|
||||
static void showError(BuildContext context, String rawError) {
|
||||
String message = _getProfessionalErrorMessage(rawError);
|
||||
|
||||
final snackBar = SnackBar(
|
||||
content: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded, color: Colors.white, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: AppTheme.bodyMd.copyWith(color: Colors.white, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
elevation: 4,
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
static void showSuccess(BuildContext context, String message) {
|
||||
final snackBar = SnackBar(
|
||||
content: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline_rounded, color: Colors.white, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: AppTheme.bodyMd.copyWith(color: Colors.white, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: const Color(0xFF4CAF50), // Green success color
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
elevation: 4,
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
static String _getProfessionalErrorMessage(String raw) {
|
||||
final lowerRaw = raw.toLowerCase();
|
||||
|
||||
// Check for network errors
|
||||
if (lowerRaw.contains('socketexception') ||
|
||||
lowerRaw.contains('connection refused') ||
|
||||
lowerRaw.contains('failed host lookup') ||
|
||||
lowerRaw.contains('network') ||
|
||||
lowerRaw.contains('timeout')) {
|
||||
return 'Please check your internet connection and try again.';
|
||||
}
|
||||
|
||||
// Common auth and business errors
|
||||
if (lowerRaw.contains('invalid otp') || lowerRaw.contains('incorrect otp')) {
|
||||
return 'The OTP you entered is incorrect. Please try again.';
|
||||
}
|
||||
if (lowerRaw.contains('user not found') || lowerRaw.contains('not registered')) {
|
||||
return 'Account not found. Please check your details.';
|
||||
}
|
||||
if (lowerRaw.contains('already registered') || lowerRaw.contains('exists')) {
|
||||
return 'This phone number is already registered.';
|
||||
}
|
||||
if (lowerRaw.contains('pin')) {
|
||||
return 'Incorrect PIN. Please try again.';
|
||||
}
|
||||
if (lowerRaw.contains('unauthorized') || lowerRaw.contains('expired')) {
|
||||
return 'Your session has expired. Please log in again.';
|
||||
}
|
||||
|
||||
// Default generic server / unknown error
|
||||
return 'Something went wrong. Please try again later.';
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
|
||||
/// White rounded card with the design-system ambient shadow.
|
||||
class AppCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final VoidCallback? onTap;
|
||||
final Color color;
|
||||
final BorderRadius? radius;
|
||||
final Border? border;
|
||||
|
||||
const AppCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.onTap,
|
||||
this.color = AppColors.surfaceContainerLowest,
|
||||
this.radius,
|
||||
this.border,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final br = radius ?? BorderRadius.circular(18);
|
||||
return Material(
|
||||
color: color,
|
||||
borderRadius: br,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: br,
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: br,
|
||||
border: border ?? Border.all(color: AppColors.surfaceContainer.withValues(alpha: 0.7)),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Padding(padding: padding, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary filled action button (min height 52, 12px radius).
|
||||
class PrimaryButton extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final IconData? trailingIcon;
|
||||
final bool loading;
|
||||
final Color color;
|
||||
final double height;
|
||||
|
||||
const PrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.trailingIcon,
|
||||
this.loading = false,
|
||||
this.color = AppColors.primary,
|
||||
this.height = 52,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
child: ElevatedButton(
|
||||
onPressed: loading ? null : onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: color.withValues(alpha: 0.6),
|
||||
disabledForegroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shadowColor: AppColors.primary.withValues(alpha: 0.2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.4, color: Colors.white),
|
||||
)
|
||||
: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(label,
|
||||
style: AppTheme.button,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1),
|
||||
),
|
||||
if (trailingIcon != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(trailingIcon, size: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary tinted button (pink bg, red text).
|
||||
class SecondaryButton extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
const SecondaryButton({super.key, required this.label, this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
foregroundColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.outlineVariant),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
),
|
||||
child: Text(label, style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pill-shaped status / category chip.
|
||||
class StatusChip extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color bg;
|
||||
final IconData? icon;
|
||||
const StatusChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.bg,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.12)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[Icon(icon, size: 14, color: color), const SizedBox(width: 4)],
|
||||
Text(label,
|
||||
style: AppTheme.caption.copyWith(color: color, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal stepper progress bar with a fraction filled.
|
||||
class JourneyProgressBar extends StatelessWidget {
|
||||
final double value; // 0..1
|
||||
final bool gradient;
|
||||
const JourneyProgressBar({super.key, required this.value, this.gradient = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(height: 6, color: AppColors.surfaceContainer),
|
||||
FractionallySizedBox(
|
||||
widthFactor: value.clamp(0, 1),
|
||||
child: Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: gradient ? null : AppColors.primary,
|
||||
gradient: gradient
|
||||
? const LinearGradient(colors: [
|
||||
AppColors.firstMile,
|
||||
AppColors.midMile,
|
||||
AppColors.lastMile,
|
||||
])
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Eco / EV reassurance banner used across screens.
|
||||
class EcoBanner extends StatelessWidget {
|
||||
final String text;
|
||||
const EcoBanner({super.key, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiaryFixed.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.tertiary.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.eco, color: AppColors.tertiary, size: 22),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.tertiary, height: 1.35)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Warm page backdrop with restrained route/map texture.
|
||||
class AppBackground extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
const AppBackground({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = EdgeInsets.zero,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFFFBF8), AppColors.surface],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned(
|
||||
top: 240,
|
||||
left: -70,
|
||||
child: _SoftCircle(size: 180, color: Color(0x1A126E82)),
|
||||
),
|
||||
Padding(padding: padding, child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SoftCircle extends StatelessWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
const _SoftCircle({required this.size, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable immersive red header used by primary tabs.
|
||||
class ImmersiveHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Widget? trailing;
|
||||
final bool showBack;
|
||||
|
||||
const ImmersiveHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
this.trailing,
|
||||
this.showBack = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.primaryContainer, AppColors.primary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)),
|
||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -20,
|
||||
bottom: -30,
|
||||
child: Icon(icon, size: 140, color: Colors.white.withValues(alpha: 0.04)),
|
||||
),
|
||||
Positioned(
|
||||
right: 100,
|
||||
top: -10,
|
||||
child: Transform.rotate(
|
||||
angle: 0.2,
|
||||
child: Icon(Icons.auto_awesome_rounded, size: 60, color: Colors.white.withValues(alpha: 0.03)),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
if (showBack) ...[
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
padding: EdgeInsets.zero,
|
||||
alignment: Alignment.centerLeft,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTheme.headlineLg.copyWith(color: Colors.white)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTheme.bodyMd.copyWith(color: Colors.white.withValues(alpha: 0.8))),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small circular icon avatar with a tinted background.
|
||||
class IconBadge extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color bg;
|
||||
final Color color;
|
||||
final double size;
|
||||
final double iconSize;
|
||||
final bool circle;
|
||||
const IconBadge({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.bg,
|
||||
required this.color,
|
||||
this.size = 48,
|
||||
this.iconSize = 22,
|
||||
this.circle = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(circle ? size : 12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Title row with an optional trailing action ("View All").
|
||||
class SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String? action;
|
||||
final VoidCallback? onAction;
|
||||
const SectionHeader({super.key, required this.title, this.action, this.onAction});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(title,
|
||||
style: AppTheme.headlineSm, overflow: TextOverflow.ellipsis, maxLines: 1),
|
||||
),
|
||||
if (action != null)
|
||||
GestureDetector(
|
||||
onTap: onAction,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(action!,
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
|
||||
/// Stylised Doormile badge mark — a rounded pin holding a package glyph.
|
||||
class DoormileMark extends StatelessWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
final Color glyphColor;
|
||||
const DoormileMark({
|
||||
super.key,
|
||||
this.size = 48,
|
||||
this.color = AppColors.primary,
|
||||
this.glyphColor = Colors.white,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(size * 0.28),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.25),
|
||||
blurRadius: size * 0.3,
|
||||
offset: Offset(0, size * 0.12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(Icons.local_shipping_rounded, color: glyphColor, size: size * 0.55),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full wordmark: badge + "Doormile" text.
|
||||
class DoormileWordmark extends StatelessWidget {
|
||||
final double markSize;
|
||||
final double fontSize;
|
||||
final Color textColor;
|
||||
final Color markColor;
|
||||
final Color glyphColor;
|
||||
const DoormileWordmark({
|
||||
super.key,
|
||||
this.markSize = 40,
|
||||
this.fontSize = 24,
|
||||
this.textColor = AppColors.primary,
|
||||
this.markColor = AppColors.primary,
|
||||
this.glyphColor = Colors.white,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DoormileMark(size: markSize, color: markColor, glyphColor: glyphColor),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Doormile',
|
||||
style: AppTheme.headlineLg.copyWith(color: textColor, fontSize: fontSize),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
476
lib/data/api_client.dart
Normal file
476
lib/data/api_client.dart
Normal file
@@ -0,0 +1,476 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api_exception.dart';
|
||||
import 'app_config.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
/// One decoded response: the `data` payload plus the envelope's metadata.
|
||||
class ApiResponse {
|
||||
const ApiResponse({
|
||||
this.data,
|
||||
this.etag,
|
||||
this.total,
|
||||
this.nextCursor,
|
||||
this.notModified = false,
|
||||
this.requestId,
|
||||
});
|
||||
|
||||
/// The `data` member of the envelope. `null` on a 304 or a 204.
|
||||
final dynamic data;
|
||||
|
||||
/// `ETag` from the response, to be replayed as `If-None-Match`.
|
||||
final String? etag;
|
||||
|
||||
/// Total rows behind a filtered list, when the endpoint sends one.
|
||||
final int? total;
|
||||
|
||||
/// Keyset cursor for the next page. Null on the last page.
|
||||
final String? nextCursor;
|
||||
|
||||
/// True when the server answered `304` — the caller keeps what it has.
|
||||
final bool notModified;
|
||||
|
||||
final String? requestId;
|
||||
|
||||
Map<String, dynamic> get map =>
|
||||
data is Map<String, dynamic> ? data as Map<String, dynamic> : const {};
|
||||
|
||||
/// The rows of a list endpoint.
|
||||
///
|
||||
/// `data` is the list itself on most of this surface, but a paged endpoint
|
||||
/// wraps it — `data: { items: [...], nextCursor }` — and the contract does
|
||||
/// not say which of the two spellings a given route uses. Reading through the
|
||||
/// wrapper costs one lookup; guessing wrong shows the customer an empty
|
||||
/// Orders tab and no error at all, which is the failure that cannot be
|
||||
/// diagnosed from a screenshot.
|
||||
List<Map<String, dynamic>> get rows =>
|
||||
(_list ?? const []).whereType<Map<String, dynamic>>().toList();
|
||||
|
||||
List<dynamic>? get _list {
|
||||
final payload = data;
|
||||
if (payload is List) return payload;
|
||||
if (payload is Map<String, dynamic>) {
|
||||
for (final key in _listKeys) {
|
||||
final value = payload[key];
|
||||
if (value is List) return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static const List<String> _listKeys = [
|
||||
'items',
|
||||
'results',
|
||||
'rows',
|
||||
'data',
|
||||
'bookings',
|
||||
'orders',
|
||||
'states',
|
||||
'districts',
|
||||
'slots',
|
||||
'places',
|
||||
'locations',
|
||||
];
|
||||
}
|
||||
|
||||
/// The app's only HTTP caller.
|
||||
///
|
||||
/// Everything the contract asks for on the wire lives here and nowhere else:
|
||||
/// the `data` envelope, the nine error codes, bearer auth with silent refresh,
|
||||
/// the client identity headers, idempotency keys, `ETag` revalidation, keyset
|
||||
/// paging and the retry policy. A screen that wanted to add a header would have
|
||||
/// to come through this class, which is the point.
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
http.Client? httpClient,
|
||||
SessionStore? sessions,
|
||||
this.onSessionLost,
|
||||
}) : _http = httpClient ?? http.Client(),
|
||||
_sessions = sessions ?? SecureSessionStore();
|
||||
|
||||
final http.Client _http;
|
||||
final SessionStore _sessions;
|
||||
|
||||
/// Called when a refresh fails and the session is dropped, so [AppState] can
|
||||
/// return the customer to sign-in rather than leaving them on a dead screen.
|
||||
/// Settable because the state that handles it is built after this client.
|
||||
void Function()? onSessionLost;
|
||||
|
||||
SessionStore get sessions => _sessions;
|
||||
|
||||
Session? _session;
|
||||
bool _restored = false;
|
||||
|
||||
/// Single-flight refresh: ten calls hitting 401 together must produce one
|
||||
/// refresh, not ten — the token rotates, so the other nine would be replaying
|
||||
/// a token the server has already revoked and would kill the whole chain.
|
||||
Future<Session?>? _refreshing;
|
||||
|
||||
final Random _rng = Random();
|
||||
|
||||
// ------------------------------------------------------------------ session
|
||||
|
||||
Future<Session?> currentSession() async {
|
||||
if (!_restored) {
|
||||
_session = await _sessions.read();
|
||||
_restored = true;
|
||||
}
|
||||
return _session;
|
||||
}
|
||||
|
||||
Future<void> adoptSession(Session session) async {
|
||||
_session = session;
|
||||
_restored = true;
|
||||
await _sessions.write(session);
|
||||
}
|
||||
|
||||
Future<void> dropSession() async {
|
||||
_session = null;
|
||||
_restored = true;
|
||||
await _sessions.clear();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ requests
|
||||
|
||||
Future<ApiResponse> get(
|
||||
String path, {
|
||||
Map<String, String>? query,
|
||||
String? ifNoneMatch,
|
||||
bool authenticated = true,
|
||||
Duration? timeout,
|
||||
}) => _send(
|
||||
'GET',
|
||||
path,
|
||||
query: query,
|
||||
ifNoneMatch: ifNoneMatch,
|
||||
authenticated: authenticated,
|
||||
timeout: timeout,
|
||||
// A GET carries no side effect, so replaying one is always safe.
|
||||
retries: 2,
|
||||
);
|
||||
|
||||
Future<ApiResponse> post(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? query,
|
||||
String? idempotencyKey,
|
||||
bool authenticated = true,
|
||||
Duration? timeout,
|
||||
}) => _send(
|
||||
'POST',
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
idempotencyKey: idempotencyKey,
|
||||
authenticated: authenticated,
|
||||
timeout: timeout ?? AppConfig.writeTimeout,
|
||||
);
|
||||
|
||||
Future<ApiResponse> patch(
|
||||
String path, {
|
||||
Object? body,
|
||||
bool authenticated = true,
|
||||
}) => _send('PATCH', path, body: body, authenticated: authenticated);
|
||||
|
||||
Future<ApiResponse> put(
|
||||
String path, {
|
||||
Object? body,
|
||||
bool authenticated = true,
|
||||
}) => _send('PUT', path, body: body, authenticated: authenticated);
|
||||
|
||||
Future<ApiResponse> delete(String path, {bool authenticated = true}) =>
|
||||
_send('DELETE', path, authenticated: authenticated);
|
||||
|
||||
/// A fresh idempotency key. Held by the caller across retries of the *same*
|
||||
/// intent — a new key means a new booking.
|
||||
String newIdempotencyKey() {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
|
||||
final salt = List.generate(
|
||||
10,
|
||||
(_) => alphabet[_rng.nextInt(alphabet.length)],
|
||||
).join();
|
||||
return '$now-$salt';
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ internals
|
||||
|
||||
Future<ApiResponse> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? query,
|
||||
String? ifNoneMatch,
|
||||
String? idempotencyKey,
|
||||
bool authenticated = true,
|
||||
Duration? timeout,
|
||||
int retries = 0,
|
||||
bool allowRefresh = true,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
AppConfig.url(path),
|
||||
).replace(queryParameters: (query?.isEmpty ?? true) ? null : query);
|
||||
|
||||
ApiException? lastTransient;
|
||||
|
||||
for (var attempt = 0; attempt <= retries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future<void>.delayed(Duration(milliseconds: 300 * attempt * attempt));
|
||||
}
|
||||
try {
|
||||
final response = await _once(
|
||||
method,
|
||||
uri,
|
||||
body: body,
|
||||
ifNoneMatch: ifNoneMatch,
|
||||
idempotencyKey: idempotencyKey,
|
||||
authenticated: authenticated,
|
||||
timeout: timeout,
|
||||
);
|
||||
return response;
|
||||
} on ApiException catch (e) {
|
||||
// 401 once, then refresh and try again — but never on the refresh call
|
||||
// itself, which would recurse.
|
||||
if (e.isAuthFailure && authenticated && allowRefresh) {
|
||||
final refreshed = await _refreshSession();
|
||||
if (refreshed != null) {
|
||||
return _send(
|
||||
method,
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
ifNoneMatch: ifNoneMatch,
|
||||
idempotencyKey: idempotencyKey,
|
||||
authenticated: authenticated,
|
||||
timeout: timeout,
|
||||
retries: 0,
|
||||
allowRefresh: false,
|
||||
);
|
||||
}
|
||||
await dropSession();
|
||||
onSessionLost?.call();
|
||||
rethrow;
|
||||
}
|
||||
if (!e.isTransient || attempt == retries) rethrow;
|
||||
lastTransient = e;
|
||||
}
|
||||
}
|
||||
throw lastTransient ??
|
||||
ApiException(ApiException.network, 'We could not reach Doormile');
|
||||
}
|
||||
|
||||
Future<ApiResponse> _once(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Object? body,
|
||||
String? ifNoneMatch,
|
||||
String? idempotencyKey,
|
||||
required bool authenticated,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
final headers = <String, String>{
|
||||
'Accept': 'application/json',
|
||||
'X-Client': AppConfig.clientHeader,
|
||||
'X-Platform': AppConfig.platformHeader,
|
||||
if (body != null) 'Content-Type': 'application/json',
|
||||
'If-None-Match': ?ifNoneMatch,
|
||||
'Idempotency-Key': ?idempotencyKey,
|
||||
};
|
||||
|
||||
if (authenticated) {
|
||||
final session = await currentSession();
|
||||
if (session != null) {
|
||||
// Refresh proactively rather than spending a 401 to discover it.
|
||||
final usable = session.isExpired
|
||||
? (await _refreshSession() ?? session)
|
||||
: session;
|
||||
headers['Authorization'] = 'Bearer ${usable.accessToken}';
|
||||
}
|
||||
}
|
||||
|
||||
final request = http.Request(method, uri)..headers.addAll(headers);
|
||||
if (body != null) request.body = jsonEncode(body);
|
||||
|
||||
http.Response response;
|
||||
try {
|
||||
final streamed = await _http
|
||||
.send(request)
|
||||
.timeout(timeout ?? AppConfig.requestTimeout);
|
||||
response = await http.Response.fromStream(streamed);
|
||||
} on TimeoutException {
|
||||
throw ApiException(
|
||||
ApiException.network,
|
||||
'Doormile took too long to answer',
|
||||
);
|
||||
} on SocketException {
|
||||
throw ApiException(ApiException.network, 'We could not reach Doormile');
|
||||
} on http.ClientException {
|
||||
throw ApiException(ApiException.network, 'We could not reach Doormile');
|
||||
}
|
||||
|
||||
return _decode(response, uri);
|
||||
}
|
||||
|
||||
ApiResponse _decode(http.Response response, Uri uri) {
|
||||
final requestId = response.headers['x-request-id'];
|
||||
final status = response.statusCode;
|
||||
|
||||
if (status == 304) {
|
||||
return ApiResponse(
|
||||
notModified: true,
|
||||
etag: response.headers['etag'],
|
||||
requestId: requestId,
|
||||
);
|
||||
}
|
||||
|
||||
dynamic decoded;
|
||||
if (response.body.isNotEmpty) {
|
||||
try {
|
||||
decoded = jsonDecode(response.body);
|
||||
} catch (_) {
|
||||
// An HTML error page or a truncated body. The contract forbids both,
|
||||
// so treat it as the server being unreachable rather than guessing.
|
||||
_log(uri, status, requestId, 'unparseable body');
|
||||
throw ApiException(
|
||||
status >= 500 ? ApiException.serverError : ApiException.network,
|
||||
'Something went wrong',
|
||||
status,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final envelope = decoded is Map<String, dynamic> ? decoded : const {};
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
// `success: false` with a 200 would be a contract violation, but reading
|
||||
// it costs nothing and beats rendering a failure as data.
|
||||
if (envelope['success'] == false) {
|
||||
throw _errorFrom(envelope, status, requestId, uri);
|
||||
}
|
||||
final payload = envelope.containsKey('data') ? envelope['data'] : decoded;
|
||||
// Keyset metadata sits beside `data` on some routes and inside it on the
|
||||
// paged ones. Both are read, envelope first.
|
||||
final inner = payload is Map<String, dynamic>
|
||||
? payload
|
||||
: const <String, dynamic>{};
|
||||
return ApiResponse(
|
||||
data: payload,
|
||||
etag: response.headers['etag'],
|
||||
total: (envelope['total'] ?? inner['total']) is num
|
||||
? ((envelope['total'] ?? inner['total']) as num).toInt()
|
||||
: null,
|
||||
nextCursor:
|
||||
(envelope['nextCursor'] ?? inner['nextCursor']) as String?,
|
||||
requestId: requestId,
|
||||
);
|
||||
}
|
||||
|
||||
throw _errorFrom(envelope, status, requestId, uri);
|
||||
}
|
||||
|
||||
ApiException _errorFrom(
|
||||
Map<dynamic, dynamic> envelope,
|
||||
int status,
|
||||
String? requestId,
|
||||
Uri uri,
|
||||
) {
|
||||
final error = envelope['error'];
|
||||
final serverCode = (error is Map && error['code'] is String)
|
||||
? error['code'] as String
|
||||
: '';
|
||||
final code = ApiException.normalise(
|
||||
serverCode,
|
||||
() => _codeForStatus(status),
|
||||
);
|
||||
final message = envelope['message'] is String &&
|
||||
(envelope['message'] as String).trim().isNotEmpty
|
||||
? envelope['message'] as String
|
||||
: (error is Map && error['message'] is String
|
||||
? error['message'] as String
|
||||
: _messageForStatus(status));
|
||||
_log(uri, status, requestId, serverCode.isEmpty ? code : serverCode);
|
||||
return ApiException(code, message, status, requestId, serverCode);
|
||||
}
|
||||
|
||||
static String _codeForStatus(int status) => switch (status) {
|
||||
400 => ApiException.invalid,
|
||||
401 => ApiException.unauthorized,
|
||||
403 => ApiException.forbidden,
|
||||
404 => ApiException.notFound,
|
||||
409 => ApiException.conflict,
|
||||
422 => ApiException.unserviceable,
|
||||
429 => ApiException.rateLimited,
|
||||
_ => status >= 500 ? ApiException.serverError : ApiException.invalid,
|
||||
};
|
||||
|
||||
static String _messageForStatus(int status) => switch (status) {
|
||||
401 => 'Please sign in again',
|
||||
403 => 'You do not have access to this',
|
||||
404 => 'We could not find that',
|
||||
409 => 'That is no longer available',
|
||||
429 => 'Too many attempts. Try again in a minute',
|
||||
_ => 'Something went wrong',
|
||||
};
|
||||
|
||||
void _log(Uri uri, int status, String? requestId, String detail) {
|
||||
debugPrint(
|
||||
'[API] $status ${uri.path} · $detail'
|
||||
'${requestId == null ? '' : ' · req $requestId'}',
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ refresh
|
||||
|
||||
/// Rotates the session. Returns null when the refresh itself failed, which
|
||||
/// means the chain is dead and the customer must sign in again.
|
||||
Future<Session?> _refreshSession() {
|
||||
final inFlight = _refreshing;
|
||||
if (inFlight != null) return inFlight;
|
||||
|
||||
final future = _doRefresh();
|
||||
_refreshing = future;
|
||||
return future.whenComplete(() => _refreshing = null);
|
||||
}
|
||||
|
||||
Future<Session?> _doRefresh() async {
|
||||
final session = await currentSession();
|
||||
if (session == null) return null;
|
||||
try {
|
||||
final response = await _send(
|
||||
'POST',
|
||||
'/auth/refresh',
|
||||
body: {'refreshToken': session.refreshToken},
|
||||
authenticated: false,
|
||||
allowRefresh: false,
|
||||
);
|
||||
final data = response.map;
|
||||
final access = data['accessToken'] as String?;
|
||||
if (access == null || access.isEmpty) return null;
|
||||
final rotated = Session(
|
||||
accessToken: access,
|
||||
// A server that does not rotate returns no new refresh token; keeping
|
||||
// the old one is then correct. If it does rotate, this is the only
|
||||
// copy that still works, so it is persisted before anything uses it.
|
||||
refreshToken: (data['refreshToken'] as String?) ?? session.refreshToken,
|
||||
expiresAt: DateTime.now().add(
|
||||
Duration(seconds: (data['expiresIn'] as num?)?.toInt() ?? 3600),
|
||||
),
|
||||
customer: session.customer,
|
||||
);
|
||||
await adoptSession(rotated);
|
||||
return rotated;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[API] refresh failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
}
|
||||
106
lib/data/api_exception.dart
Normal file
106
lib/data/api_exception.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
/// Thrown for every failed call so the UI can show one consistent error state.
|
||||
///
|
||||
/// [code] is the contract's machine-readable key and is the **only** thing
|
||||
/// callers may branch on. [message] is customer-safe English written by the
|
||||
/// server and is rendered verbatim — never parsed.
|
||||
class ApiException implements Exception {
|
||||
ApiException(
|
||||
this.code, [
|
||||
String? message,
|
||||
this.status,
|
||||
this.requestId,
|
||||
String? serverCode,
|
||||
]) : message = message ?? 'Something went wrong',
|
||||
serverCode = serverCode ?? code;
|
||||
|
||||
final String code;
|
||||
final String message;
|
||||
|
||||
/// Exactly what the server put in `error.code` — `SLOT_CAPACITY_FULL`,
|
||||
/// `UNSERVICEABLE_PINCODE`, and so on. [code] is that value translated into
|
||||
/// the vocabulary below; this one is kept for the log line and for a support
|
||||
/// report, and nothing branches on it.
|
||||
final String serverCode;
|
||||
|
||||
/// HTTP status, when there was one. Null for a transport failure.
|
||||
final int? status;
|
||||
|
||||
/// The server's `X-Request-Id`, so a support report can be correlated.
|
||||
final String? requestId;
|
||||
|
||||
/// The client vocabulary: the contract's codes translated by [normalise],
|
||||
/// plus the ones the client raises itself.
|
||||
static const String network = 'network';
|
||||
static const String invalid = 'invalid';
|
||||
static const String invalidName = 'invalid_name';
|
||||
static const String invalidOtp = 'invalid_otp';
|
||||
static const String unauthorized = 'unauthorized';
|
||||
static const String forbidden = 'forbidden';
|
||||
static const String notFound = 'not_found';
|
||||
static const String conflict = 'conflict';
|
||||
static const String unserviceable = 'unserviceable';
|
||||
static const String rateLimited = 'rate_limited';
|
||||
static const String serverError = 'server_error';
|
||||
|
||||
/// The slot is gone — filled up, or its window passed. Its own code because
|
||||
/// the recovery is specific: re-read the slots and go back to picking one.
|
||||
static const String slotUnavailable = 'slot_unavailable';
|
||||
|
||||
/// The Miler has already arrived, so the cancel window has closed.
|
||||
static const String notCancellable = 'not_cancellable';
|
||||
|
||||
/// Translates the contract's `error.code` into the vocabulary above.
|
||||
///
|
||||
/// The wire spells its codes in capitals; the client's are lowercase, and
|
||||
/// mapping them is not cosmetic — an untranslated `UNAUTHORIZED` does not
|
||||
/// satisfy [isAuthFailure], which is what triggers the token refresh, so a
|
||||
/// signed-in customer would have been dropped at the first expired token
|
||||
/// instead of silently getting a new one.
|
||||
///
|
||||
/// A code that is already lowercase is passed through untouched, and an
|
||||
/// unrecognised one falls back to whatever the HTTP status means.
|
||||
static String normalise(String raw, String Function() fromStatus) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) return fromStatus();
|
||||
if (trimmed != trimmed.toUpperCase()) return trimmed;
|
||||
return switch (trimmed) {
|
||||
'UNAUTHORIZED' || 'TOKEN_EXPIRED' => unauthorized,
|
||||
'FORBIDDEN' => forbidden,
|
||||
'NOT_FOUND' => notFound,
|
||||
'INVALID_INPUT' || 'VALIDATION_ERROR' => invalid,
|
||||
'SLOT_UNAVAILABLE' || 'SLOT_EXPIRED' || 'SLOT_CAPACITY_FULL' =>
|
||||
slotUnavailable,
|
||||
'BOOKING_NOT_CANCELLABLE' => notCancellable,
|
||||
'UNSERVICEABLE_PINCODE' => unserviceable,
|
||||
'RATE_LIMITED' => rateLimited,
|
||||
'INTERNAL_ERROR' => serverError,
|
||||
_ => fromStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
/// True when retrying the identical request could plausibly succeed.
|
||||
bool get isTransient =>
|
||||
code == network || code == serverError || code == rateLimited;
|
||||
|
||||
/// The session is gone; the customer has to sign in again.
|
||||
bool get isAuthFailure => code == unauthorized;
|
||||
|
||||
/// The slot the customer picked is no longer bookable — either it filled up
|
||||
/// or its window has passed. Both recover the same way: re-fetch the slots
|
||||
/// and put them back on slot selection.
|
||||
///
|
||||
/// The server distinguishes them (`409 conflict` for a genuine capacity race,
|
||||
/// `400 invalid` for a window that has passed) but the customer's next action
|
||||
/// is identical, so the client does not.
|
||||
bool get needsFreshSlots =>
|
||||
code == slotUnavailable ||
|
||||
code == conflict ||
|
||||
(code == invalid && message.toLowerCase().contains('pickup'));
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ApiException($code'
|
||||
'${serverCode == code ? '' : '/$serverCode'}'
|
||||
'${status == null ? '' : ' $status'}): $message'
|
||||
'${requestId == null ? '' : ' [req $requestId]'}';
|
||||
}
|
||||
278
lib/data/app_config.dart
Normal file
278
lib/data/app_config.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Which backend this build talks to.
|
||||
///
|
||||
/// Set at compile time: `flutter run --dart-define=DM_ENV=prod`.
|
||||
enum DoormileEnvironment { dev, staging, prod }
|
||||
|
||||
/// Build-time configuration: which backend this build talks to, and the
|
||||
/// identity it sends on every request.
|
||||
///
|
||||
/// ── The one bypass, and why it is not the Miler app's bypass ──
|
||||
///
|
||||
/// The Miler app shipped a `USE_NEW_API=false` flag that silently swapped
|
||||
/// authentication for a bypass — any four digits logged you in as a fake rider,
|
||||
/// with invented earnings, in a build that could ship.
|
||||
///
|
||||
/// This app has one API implementation that talks to a server, and exactly one
|
||||
/// exception to it: [useDevData], now the **default in a debug build** because
|
||||
/// staging has no SMS gateway and the login screen cannot otherwise be walked.
|
||||
/// Three things keep it from becoming the flag that was deleted — it cannot
|
||||
/// exist in a release build (`!kReleaseMode`, not a define), it is named on the
|
||||
/// Account screen every time it is on, and nothing it does reaches a server.
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
// ---------------------------------------------------------------- environment
|
||||
|
||||
static const String _envName = String.fromEnvironment(
|
||||
'DM_ENV',
|
||||
defaultValue: 'staging',
|
||||
);
|
||||
|
||||
static DoormileEnvironment get environment => switch (_envName) {
|
||||
'prod' || 'production' => DoormileEnvironment.prod,
|
||||
'dev' || 'development' => DoormileEnvironment.dev,
|
||||
_ => DoormileEnvironment.staging,
|
||||
};
|
||||
|
||||
static bool get isProd => environment == DoormileEnvironment.prod;
|
||||
|
||||
// ------------------------------------------------------------------- base URL
|
||||
|
||||
/// Overrides the per-environment default. The staging host is not yet
|
||||
/// confirmed by the backend team, so staging builds are expected to pass
|
||||
/// `--dart-define=DM_API_BASE=https://<staging host>/api/v1`.
|
||||
static const String _baseOverride = String.fromEnvironment('DM_API_BASE');
|
||||
|
||||
static const String _prodBase = 'https://api.doormile.com/api/v1';
|
||||
|
||||
/// Placeholder until the backend team names the staging host. A staging build
|
||||
/// without `DM_API_BASE` therefore talks to production, which is wrong but
|
||||
/// loud — it is better than silently talking to a host that does not exist.
|
||||
static const String _stagingBase = _prodBase;
|
||||
|
||||
static String get baseUrl {
|
||||
if (_baseOverride.isNotEmpty) return _stripTrailingSlash(_baseOverride);
|
||||
return switch (environment) {
|
||||
DoormileEnvironment.prod => _prodBase,
|
||||
DoormileEnvironment.staging => _stagingBase,
|
||||
DoormileEnvironment.dev => _stripTrailingSlash(
|
||||
const String.fromEnvironment(
|
||||
'DM_API_BASE_DEV',
|
||||
defaultValue: 'http://10.0.2.2:8080/api/v1',
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static String _stripTrailingSlash(String s) =>
|
||||
s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
||||
|
||||
/// Everything in this document lives under one namespace.
|
||||
static String url(String path) =>
|
||||
'$baseUrl/customer${path.startsWith('/') ? path : '/$path'}';
|
||||
|
||||
// ---------------------------------------------------------- development data
|
||||
//
|
||||
// ── The one exception to "no invented data", and it is now the debug default ──
|
||||
//
|
||||
// The shipping app has one API implementation and talks to a server; a build
|
||||
// that cannot reach one shows its error state rather than inventing a
|
||||
// booking, a slot or a price. [useDevData] is the single, loud exception.
|
||||
//
|
||||
// **Off by default again.** It was briefly the debug default so the app could
|
||||
// be opened at all, and the cost showed up immediately: a booking made in
|
||||
// that mode reaches no server and never appears in the admin console, which
|
||||
// looks exactly like a broken integration. Skipping the login screen is worth
|
||||
// a flag; faking every booking behind it is not.
|
||||
//
|
||||
// For a session without typing a code, use [autoLoginIdentifier] instead —
|
||||
// it signs in against the **real** API, so everything after it is real.
|
||||
//
|
||||
// --dart-define=DM_MOCK=true offline fake, for UI work with no server
|
||||
// --dart-define=DM_DEV_LOGIN=false keep the fake, but walk the entrance
|
||||
//
|
||||
// The guard has not moved: `!kReleaseMode`, so no define puts this in a
|
||||
// release build. When it is on the app talks to [DevDoormileApi] instead of
|
||||
// the network, `9876543210` + any 4-digit code signs in, and the Account
|
||||
// screen says `DEV DATA (offline)`. **Nothing it does reaches a server or the
|
||||
// admin console** — a booking made in this mode is not a booking.
|
||||
static const bool _devDataRequested = bool.fromEnvironment(
|
||||
'DM_MOCK',
|
||||
defaultValue: false,
|
||||
);
|
||||
|
||||
/// True under `flutter test`, where the widget tests drive the whole journey
|
||||
/// against [DevDoormileApi] and there is no server to reach.
|
||||
static bool get isTest {
|
||||
if (kIsWeb) return false;
|
||||
try {
|
||||
return Platform.environment.containsKey('FLUTTER_TEST');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// True only when dev data was asked for — or we are under test — **and**
|
||||
/// this is not a release build. There is no define that can turn it on in a
|
||||
/// release.
|
||||
static bool get useDevData => (_devDataRequested || isTest) && !kReleaseMode;
|
||||
|
||||
/// Opens the app already signed in, skipping the login screen.
|
||||
///
|
||||
/// On by default wherever [useDevData] is, which is the point of the pair:
|
||||
/// one `flutter run` and you are looking at Home. Only meaningful alongside
|
||||
/// dev data — there is no one to sign in as without it, so a build talking to
|
||||
/// the real API still signs in properly.
|
||||
///
|
||||
/// To walk the entrance itself — the sign-in, sign-up and verify screens —
|
||||
/// turn this off and keep the dev data: `--dart-define=DM_DEV_LOGIN=false`,
|
||||
/// where `9876543210` and any 4-digit code gets you in.
|
||||
///
|
||||
/// Never under `flutter test`: the widget tests drive the real entrance —
|
||||
/// phone, code, and the guard that refuses to open the code screen when no
|
||||
/// code was sent — and a session waiting for them at launch would delete
|
||||
/// that coverage silently.
|
||||
static bool get devAutoLogin =>
|
||||
useDevData &&
|
||||
!isTest &&
|
||||
const bool.fromEnvironment('DM_DEV_LOGIN', defaultValue: true);
|
||||
|
||||
// --------------------------------------------------------------- auto sign-in
|
||||
//
|
||||
// ── Skipping the login SCREEN without faking the login ──
|
||||
//
|
||||
// This is the flag to reach for when you want the app to open on Home and
|
||||
// still be a real customer: at launch, with no stored session, the app runs
|
||||
// the actual `POST /customer/auth/otp/request` + `/verify` pair for you. The
|
||||
// token it gets back is the server's, every call after it is authorised
|
||||
// normally, and a booking made in this build lands in `pickupbookings` and
|
||||
// shows up in the admin console.
|
||||
//
|
||||
// --dart-define=DM_LOGIN_AS=9876543210 --dart-define=DM_LOGIN_CODE=1234
|
||||
//
|
||||
// The code has to be one the server will accept. Two ways to have one:
|
||||
// set `CX_STAGING_OTP` on a non-production backend (a fixed code, refused
|
||||
// outright when ENV=production), or read the code the backend logged — with
|
||||
// no SMS gateway registered its `logSender` writes every code to the
|
||||
// application log and reports the send as successful.
|
||||
//
|
||||
// It only has to work once per install: the session is persisted, so later
|
||||
// launches restore it and these defines stop mattering.
|
||||
//
|
||||
// `!kReleaseMode`, like everything else here, and named on the Account
|
||||
// screen whenever it is on.
|
||||
|
||||
/// The phone number or email to sign in as.
|
||||
static String get autoLoginIdentifier =>
|
||||
kReleaseMode ? '' : const String.fromEnvironment('DM_LOGIN_AS');
|
||||
|
||||
/// The verification code that identifier will accept.
|
||||
static String get autoLoginCode =>
|
||||
kReleaseMode ? '' : const String.fromEnvironment('DM_LOGIN_CODE');
|
||||
|
||||
static bool get hasAutoLogin =>
|
||||
autoLoginIdentifier.isNotEmpty && autoLoginCode.isNotEmpty;
|
||||
|
||||
// ------------------------------------------------------------- ops QA only
|
||||
|
||||
/// Mirrors the backend's own `CX_ALLOW_STAGE_OVERRIDE`: the QA helper at
|
||||
/// `POST /customer/ops/bookings/{reference}/stage` exists only off
|
||||
/// production, and only when ops has turned it on.
|
||||
///
|
||||
/// Turning it on here does not create the endpoint — a build that asks for
|
||||
/// it against a server that has it disabled gets a refusal, which is the
|
||||
/// right outcome. It only decides whether the control is offered.
|
||||
static bool get allowStageOverride =>
|
||||
!kReleaseMode &&
|
||||
!isProd &&
|
||||
const bool.fromEnvironment(
|
||||
'DM_ALLOW_STAGE_OVERRIDE',
|
||||
defaultValue: false,
|
||||
);
|
||||
|
||||
/// Whether the tracking screen offers its stage stepper. On a dev build it
|
||||
/// walks [DevDoormileApi]'s in-memory booking; on staging it drives the real
|
||||
/// QA endpoint.
|
||||
static bool get showStageStepper => useDevData || allowStageOverride;
|
||||
|
||||
// ------------------------------------------------------- development access
|
||||
//
|
||||
// ── The dev token is not a login bypass ──
|
||||
//
|
||||
// The Miler app shipped one of those: `USE_NEW_API=false` handed you a fake
|
||||
// "Demo Rider" on any four digits, with invented earnings. It was deleted,
|
||||
// and the reason is worth restating — a build flag that swaps authentication
|
||||
// for a bypass is not a development convenience, because the flag ships with
|
||||
// the binary and nothing in the UI says which mode you are in.
|
||||
//
|
||||
// So neither switch below authenticates anybody:
|
||||
//
|
||||
// [devToken] carries a **real, server-issued token** you already hold. The
|
||||
// server still authorises every request; this only spares you the OTP round
|
||||
// trip that staging cannot complete without an SMS gateway. It is not a
|
||||
// sign-in bypass — a revoked token signs the build straight back out.
|
||||
//
|
||||
// It is `!kReleaseMode`, so no define can put it into a release build, and it
|
||||
// is named on the Account screen whenever it is on.
|
||||
|
||||
/// A real access token, pasted in at build time:
|
||||
/// `--dart-define=DM_DEV_TOKEN=eyJ...`
|
||||
static String get devToken =>
|
||||
kReleaseMode ? '' : const String.fromEnvironment('DM_DEV_TOKEN');
|
||||
|
||||
/// The matching refresh token, if you have one. Without it the session
|
||||
/// simply dies when the access token expires, and you paste a fresh one.
|
||||
static String get devRefreshToken =>
|
||||
kReleaseMode ? '' : const String.fromEnvironment('DM_DEV_REFRESH_TOKEN');
|
||||
|
||||
static bool get hasDevToken => devToken.isNotEmpty;
|
||||
|
||||
/// True when this build got its session from somewhere other than a real
|
||||
/// sign-in — a pasted dev token, or dev data where any code is accepted.
|
||||
static bool get authBypassed => hasDevToken || useDevData || hasAutoLogin;
|
||||
|
||||
// -------------------------------------------------------------- client identity
|
||||
|
||||
/// `X-Client: doormile-cx/1.0.0+1`. Passed in at build time because reading
|
||||
/// it at runtime would mean another plugin for one string.
|
||||
static const String appVersion = String.fromEnvironment(
|
||||
'DM_APP_VERSION',
|
||||
defaultValue: '1.0.0+1',
|
||||
);
|
||||
|
||||
static String get clientHeader => 'doormile-cx/$appVersion';
|
||||
|
||||
static String get platformHeader {
|
||||
if (kIsWeb) return 'web';
|
||||
try {
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isIOS) return 'ios';
|
||||
return Platform.operatingSystem;
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ timeouts
|
||||
|
||||
static const Duration requestTimeout = Duration(seconds: 15);
|
||||
|
||||
/// Booking creation is allowed longer: the backend's own budget for it is
|
||||
/// 1.2s p95, but it writes across several tables and we would rather wait
|
||||
/// than orphan a booking the server did create.
|
||||
static const Duration writeTimeout = Duration(seconds: 30);
|
||||
|
||||
/// A one-line summary for the Account screen and for bug reports.
|
||||
///
|
||||
/// Names the dev token whenever one is in use: a build whose session did not
|
||||
/// come from a sign-in must say so on screen.
|
||||
static String get describe =>
|
||||
'${environment.name} · ${useDevData ? 'DEV DATA (offline)' : baseUrl}'
|
||||
'${allowStageOverride ? ' · STAGE OVERRIDE' : ''}'
|
||||
'${hasDevToken ? ' · DEV TOKEN' : ''}'
|
||||
'${hasAutoLogin ? ' · AUTO LOGIN ($autoLoginIdentifier)' : ''}';
|
||||
}
|
||||
789
lib/data/dev_doormile_api.dart
Normal file
789
lib/data/dev_doormile_api.dart
Normal file
@@ -0,0 +1,789 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'doormile_api.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// In-memory backend for **development and tests only**.
|
||||
///
|
||||
/// It answers every call from data it holds itself — serviceability, slots, a
|
||||
/// fare, and bookings it stores in memory — and it makes no network request, so
|
||||
/// there is no token to obtain and nothing to 401. Any 4-digit code signs in,
|
||||
/// which is the whole point of the dev bypass: `9876543210` + `1234` lands you
|
||||
/// in a working app while the real backend has no SMS gateway.
|
||||
///
|
||||
/// **It cannot reach a release build.** [DoormileApi.instance] returns it only
|
||||
/// when [AppConfig.useDevData] is set, and that getter is `false` in a release
|
||||
/// whatever the defines say — the same guard the original mock shipped behind.
|
||||
/// A booking made here lives on this device and reaches no server or admin
|
||||
/// console: developing the flow offline is all it is for.
|
||||
class DevDoormileApi extends DoormileApi {
|
||||
DevDoormileApi();
|
||||
|
||||
/// Behaviour switches for exercising the hard-to-reach states. They live on
|
||||
/// this class, not on the interface, so nothing on the live path sees them.
|
||||
final DevFlags flags = DevFlags();
|
||||
|
||||
final Random _rng = Random();
|
||||
|
||||
static const _fast = Duration(milliseconds: 380);
|
||||
static const _slow = Duration(milliseconds: 1600);
|
||||
|
||||
Duration get _latency => flags.slow ? _slow : _fast;
|
||||
|
||||
/// Resolves like a network call would; throws when the error flag is on.
|
||||
Future<T> _respond<T>(
|
||||
T Function() producer, {
|
||||
bool networkSensitive = true,
|
||||
}) async {
|
||||
await Future<void>.delayed(_latency);
|
||||
if (flags.networkError && networkSensitive) {
|
||||
throw ApiException('network', 'We could not reach Doormile');
|
||||
}
|
||||
return producer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mock data
|
||||
|
||||
/// Stands in for `GET /serviceability` — the backend owns this entirely.
|
||||
static const List<Map<String, Object>> _serviceAreas = [
|
||||
{
|
||||
'code': 'TN',
|
||||
'name': 'Tamil Nadu',
|
||||
'transitTag': 'Ultra-fast transit',
|
||||
'districts': [
|
||||
{'code': 'TN-CBE', 'name': 'Coimbatore', 'available': true,
|
||||
'hub': 'Coimbatore Central Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 11.0168, 'lng': 76.9558},
|
||||
{'code': 'TN-MAA', 'name': 'Chennai', 'available': true,
|
||||
'hub': 'Chennai Guindy Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 13.0827, 'lng': 80.2707},
|
||||
{'code': 'TN-ERD', 'name': 'Erode', 'available': true,
|
||||
'hub': 'Erode Perundurai Hub', 'promise': '2-day delivery',
|
||||
'lat': 11.341, 'lng': 77.7172},
|
||||
{'code': 'TN-SLM', 'name': 'Salem', 'available': true,
|
||||
'hub': 'Salem Fairlands Hub', 'promise': '2-day delivery',
|
||||
'lat': 11.6643, 'lng': 78.146},
|
||||
{'code': 'TN-TUP', 'name': 'Tiruppur', 'available': true,
|
||||
'hub': 'Tiruppur Avinashi Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 11.1085, 'lng': 77.3411},
|
||||
{'code': 'TN-TRZ', 'name': 'Tiruchirappalli', 'available': true,
|
||||
'hub': 'Trichy Central Hub', 'promise': '2-day delivery',
|
||||
'lat': 10.7905, 'lng': 78.7047},
|
||||
{'code': 'TN-MDU', 'name': 'Madurai', 'available': false, 'note': 'Opening soon',
|
||||
'lat': 9.9252, 'lng': 78.1198},
|
||||
],
|
||||
},
|
||||
{
|
||||
'code': 'KL',
|
||||
'name': 'Kerala',
|
||||
'transitTag': 'Next-day transit',
|
||||
'districts': [
|
||||
{'code': 'KL-PKD', 'name': 'Palakkad', 'available': true,
|
||||
'hub': 'Palakkad Town Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 10.7867, 'lng': 76.6548},
|
||||
{'code': 'KL-TSR', 'name': 'Thrissur', 'available': true,
|
||||
'hub': 'Thrissur Round Hub', 'promise': '2-day delivery',
|
||||
'lat': 10.5276, 'lng': 76.2144},
|
||||
{'code': 'KL-EKM', 'name': 'Ernakulam', 'available': true,
|
||||
'hub': 'Kochi Kakkanad Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 9.9312, 'lng': 76.2673},
|
||||
{'code': 'KL-KKD', 'name': 'Kozhikode', 'available': false, 'note': 'Paused this week',
|
||||
'lat': 11.2588, 'lng': 75.7804},
|
||||
],
|
||||
},
|
||||
{
|
||||
'code': 'KA',
|
||||
'name': 'Karnataka',
|
||||
'transitTag': '2-day transit',
|
||||
'districts': [
|
||||
{'code': 'KA-BLR', 'name': 'Bengaluru Urban', 'available': true,
|
||||
'hub': 'Bengaluru Whitefield Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 12.9716, 'lng': 77.5946},
|
||||
{'code': 'KA-MYS', 'name': 'Mysuru', 'available': true,
|
||||
'hub': 'Mysuru Hebbal Hub', 'promise': '2-day delivery',
|
||||
'lat': 12.2958, 'lng': 76.6394},
|
||||
{'code': 'KA-MNG', 'name': 'Mangaluru', 'available': false, 'note': 'Opening soon',
|
||||
'lat': 12.9141, 'lng': 74.856},
|
||||
],
|
||||
},
|
||||
{
|
||||
// A serviceable state with no open district yet.
|
||||
'code': 'PY',
|
||||
'name': 'Puducherry',
|
||||
'transitTag': 'Opening soon',
|
||||
'districts': <Map<String, Object>>[],
|
||||
},
|
||||
];
|
||||
|
||||
/// Stands in for `GET /pickup-slots?lat=&lng=`.
|
||||
static const List<PickupSlot> _pickupSlots = [
|
||||
PickupSlot(
|
||||
id: 'slot_t_1', day: 'Today', window: '2:00 – 4:00 PM', available: true,
|
||||
tag: 'Fastest pickup', milersNearby: 4, caption: 'Arriving in approx. 45 mins',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_t_2', day: 'Today', window: '4:00 – 6:00 PM', available: true,
|
||||
milersNearby: 2, caption: 'Relaxed evening handover',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_t_3', day: 'Today', window: '6:00 – 8:00 PM', available: false,
|
||||
note: 'Fully booked',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_m_1', day: 'Tomorrow', window: '9:00 – 11:00 AM', available: true,
|
||||
milersNearby: 5, caption: 'Early first dispatch',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_m_2', day: 'Tomorrow', window: '11:00 AM – 1:00 PM', available: true,
|
||||
milersNearby: 3, caption: 'Mid-morning route',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_m_3', day: 'Tomorrow', window: '2:00 – 4:00 PM', available: true,
|
||||
milersNearby: 3, caption: 'Post-lunch route',
|
||||
),
|
||||
];
|
||||
|
||||
static const List<Place> _places = [
|
||||
Place(
|
||||
title: '12 Nehru Street',
|
||||
sub: 'Gandhipuram, Coimbatore 641012',
|
||||
lat: 11.0183,
|
||||
lng: 76.9725,
|
||||
),
|
||||
Place(
|
||||
title: 'Doormile Hub — RS Puram',
|
||||
sub: 'Trichy Road, Coimbatore 641002',
|
||||
lat: 11.0043,
|
||||
lng: 76.9611,
|
||||
),
|
||||
Place(
|
||||
title: 'Brookefields Mall',
|
||||
sub: 'Brookebond Road, Coimbatore 641001',
|
||||
lat: 10.9987,
|
||||
lng: 76.9628,
|
||||
),
|
||||
Place(
|
||||
title: 'Office — Tidel Park',
|
||||
sub: 'Peelamedu, Coimbatore 641014',
|
||||
lat: 11.0286,
|
||||
lng: 77.0021,
|
||||
),
|
||||
Place(
|
||||
title: 'Home — 4B Lake View Apartments',
|
||||
sub: 'Race Course, Coimbatore 641018',
|
||||
lat: 10.9968,
|
||||
lng: 76.9711,
|
||||
),
|
||||
];
|
||||
|
||||
// --------------------------------------------------------------------- auth
|
||||
|
||||
@override
|
||||
Future<OtpChallenge> sendOtp(String identifier) => _respond(
|
||||
() => const OtpChallenge(codeLength: 4, resendAfterSeconds: 30),
|
||||
networkSensitive: true,
|
||||
);
|
||||
|
||||
/// Registers a new customer, then sends the verification code.
|
||||
@override
|
||||
Future<OtpChallenge> signUp({
|
||||
required String name,
|
||||
required String phone,
|
||||
String? email,
|
||||
}) => _respond(() {
|
||||
if (name.trim().length < 2) {
|
||||
throw ApiException('invalid_name', 'Enter your full name');
|
||||
}
|
||||
return const OtpChallenge(codeLength: 4, resendAfterSeconds: 30);
|
||||
});
|
||||
|
||||
/// [name] is set when verifying a freshly created account.
|
||||
@override
|
||||
Future<Customer> verifyOtp(
|
||||
String identifier,
|
||||
String code, {
|
||||
String? name,
|
||||
}) => _respond(() {
|
||||
if (code.length < 4) {
|
||||
throw ApiException('invalid_otp', 'That code did not match');
|
||||
}
|
||||
final isEmail = identifier.contains('@');
|
||||
return Customer(
|
||||
id: 'cust_10241',
|
||||
name: name ?? 'Joe Oommen',
|
||||
phone: isEmail ? '+91 98765 43210' : identifier,
|
||||
email: isEmail ? identifier : 'joe@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------- serviceability
|
||||
|
||||
@override
|
||||
Future<List<ServiceArea>> getServiceableStates() => _respond(() {
|
||||
if (flags.emptyServiceAreas) return <ServiceArea>[];
|
||||
return _serviceAreas.map((s) {
|
||||
final districts = (s['districts'] as List).cast<Map<String, Object>>();
|
||||
return ServiceArea(
|
||||
code: s['code'] as String,
|
||||
name: s['name'] as String,
|
||||
transitTag: s['transitTag'] as String?,
|
||||
districtCount: districts.where((d) => d['available'] == true).length,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<District>> getServiceableDistricts(String stateCode) => _respond(
|
||||
() {
|
||||
final state = _serviceAreas
|
||||
.where((s) => s['code'] == stateCode)
|
||||
.firstOrNull;
|
||||
if (state == null) {
|
||||
throw ApiException('not_found', 'That state is no longer serviceable');
|
||||
}
|
||||
return (state['districts'] as List)
|
||||
.cast<Map<String, Object>>()
|
||||
.map(
|
||||
(d) => District(
|
||||
code: d['code'] as String,
|
||||
name: d['name'] as String,
|
||||
available: d['available'] as bool,
|
||||
note: d['note'] as String?,
|
||||
hub: d['hub'] as String?,
|
||||
promise: d['promise'] as String?,
|
||||
lat: d['lat'] as double?,
|
||||
lng: d['lng'] as double?,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------- slots
|
||||
|
||||
@override
|
||||
Future<List<PickupSlot>> getPickupSlots({Place? pickup}) =>
|
||||
_respond(() => List<PickupSlot>.from(_pickupSlots));
|
||||
|
||||
@override
|
||||
PickupSlot? slotById(String? id) =>
|
||||
id == null ? null : _pickupSlots.where((s) => s.id == id).firstOrNull;
|
||||
|
||||
// ----------------------------------------------------------------- location
|
||||
|
||||
/// Turns a map pin into an address.
|
||||
///
|
||||
/// The mock derives a plausible address from the coordinates so dragging the
|
||||
/// map visibly changes the result — the real endpoint is
|
||||
/// `GET /places/reverse-geocode?lat=&lng=` and returns the same [Place].
|
||||
@override
|
||||
Future<Place> reverseGeocode({double? lat, double? lng}) => _respond(() {
|
||||
if (lat == null || lng == null) return _places.first;
|
||||
|
||||
// Standing on a known place? Return it by name.
|
||||
for (final place in _places) {
|
||||
if (place.hasLocation &&
|
||||
_metresBetween(lat, lng, place.lat!, place.lng!) < 160) {
|
||||
return Place(title: place.title, sub: place.sub, lat: lat, lng: lng);
|
||||
}
|
||||
}
|
||||
|
||||
final area = _nearestArea(lat, lng);
|
||||
final door = 2 + ((lat * 1e4).round() + (lng * 1e4).round()).abs() % 118;
|
||||
final street = _streets[((lng * 1e5).round()).abs() % _streets.length];
|
||||
return Place(
|
||||
title: '$door $street',
|
||||
sub: '${area.area}, ${area.city} ${area.pin}',
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
);
|
||||
}, networkSensitive: false);
|
||||
|
||||
/// Neighbourhoods the mock geocoder names. Real geocoding replaces this.
|
||||
static const List<({double lat, double lng, String area, String city, String pin})>
|
||||
_areas = [
|
||||
(lat: 11.0183, lng: 76.9725, area: 'Gandhipuram', city: 'Coimbatore', pin: '641012'),
|
||||
(lat: 11.0043, lng: 76.9611, area: 'R.S. Puram', city: 'Coimbatore', pin: '641002'),
|
||||
(lat: 11.0286, lng: 77.0021, area: 'Peelamedu', city: 'Coimbatore', pin: '641014'),
|
||||
(lat: 10.9968, lng: 76.9711, area: 'Race Course', city: 'Coimbatore', pin: '641018'),
|
||||
(lat: 11.0281, lng: 76.9455, area: 'Saibaba Colony', city: 'Coimbatore', pin: '641011'),
|
||||
(lat: 11.0069, lng: 77.0281, area: 'Singanallur', city: 'Coimbatore', pin: '641005'),
|
||||
(lat: 10.9878, lng: 76.9600, area: 'Ukkadam', city: 'Coimbatore', pin: '641001'),
|
||||
(lat: 11.0294, lng: 76.9048, area: 'Vadavalli', city: 'Coimbatore', pin: '641041'),
|
||||
(lat: 13.0827, lng: 80.2707, area: 'Guindy', city: 'Chennai', pin: '600032'),
|
||||
(lat: 12.9716, lng: 77.5946, area: 'Indiranagar', city: 'Bengaluru', pin: '560038'),
|
||||
(lat: 9.9312, lng: 76.2673, area: 'Kakkanad', city: 'Kochi', pin: '682030'),
|
||||
];
|
||||
|
||||
static const List<String> _streets = [
|
||||
'Nehru Street',
|
||||
'Bharathi Road',
|
||||
'Kamaraj Road',
|
||||
'4th Cross Street',
|
||||
'Mettupalayam Road',
|
||||
'Trichy Road',
|
||||
'Avinashi Road',
|
||||
'Lake View Road',
|
||||
'2nd Main Road',
|
||||
];
|
||||
|
||||
static ({double lat, double lng, String area, String city, String pin})
|
||||
_nearestArea(double lat, double lng) {
|
||||
var best = _areas.first;
|
||||
var bestMetres = double.infinity;
|
||||
for (final area in _areas) {
|
||||
final d = _metresBetween(lat, lng, area.lat, area.lng);
|
||||
if (d < bestMetres) {
|
||||
bestMetres = d;
|
||||
best = area;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Great-circle distance, good enough for pin snapping and route summaries.
|
||||
static double _metresBetween(
|
||||
double lat1,
|
||||
double lng1,
|
||||
double lat2,
|
||||
double lng2,
|
||||
) {
|
||||
const earth = 6371000.0;
|
||||
double rad(double d) => d * pi / 180;
|
||||
final dLat = rad(lat2 - lat1);
|
||||
final dLng = rad(lng2 - lng1);
|
||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(rad(lat1)) * cos(rad(lat2)) * sin(dLng / 2) * sin(dLng / 2);
|
||||
return earth * 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
}
|
||||
|
||||
/// Synchronous district lookup, so a screen can resolve the hub it already
|
||||
/// has a code for without another round trip. Backed by whatever
|
||||
/// [getServiceableDistricts] last returned in production.
|
||||
@override
|
||||
District? districtByCode(String? code) {
|
||||
if (code == null) return null;
|
||||
for (final state in _serviceAreas) {
|
||||
for (final d in (state['districts'] as List).cast<Map<String, Object>>()) {
|
||||
if (d['code'] == code) {
|
||||
return District(
|
||||
code: d['code'] as String,
|
||||
name: d['name'] as String,
|
||||
available: d['available'] as bool,
|
||||
note: d['note'] as String?,
|
||||
hub: d['hub'] as String?,
|
||||
promise: d['promise'] as String?,
|
||||
lat: d['lat'] as double?,
|
||||
lng: d['lng'] as double?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Place search — `GET /customer/places/search?q=&lat=&lng=`.
|
||||
///
|
||||
/// An empty query returns the customer's recent or saved places, which is
|
||||
/// what the search sheet shows the moment it opens.
|
||||
@override
|
||||
Future<List<Place>> searchPlaces(
|
||||
String query, {
|
||||
double? lat,
|
||||
double? lng,
|
||||
}) => _respond(() {
|
||||
if (query.trim().isEmpty) return _places.take(4).toList();
|
||||
final needle = query.toLowerCase();
|
||||
return _places
|
||||
.where((p) => '${p.title} ${p.sub}'.toLowerCase().contains(needle))
|
||||
.toList();
|
||||
}, networkSensitive: false);
|
||||
|
||||
// ------------------------------------------------------------------ limits
|
||||
|
||||
/// Caps for one pickup. Backend-owned so they can change per city or tier.
|
||||
@override
|
||||
Future<BookingLimits> getBookingLimits({Place? pickup}) =>
|
||||
_respond(() => const BookingLimits(maxPackages: 20, maxDestinations: 5));
|
||||
|
||||
// --------------------------------------------------------------------- fare
|
||||
|
||||
/// Indicative price for a route. Confirmed at pickup once the Miler weighs
|
||||
/// and measures the parcel.
|
||||
/// Combined indicative price for everything collected in one visit. The
|
||||
/// final amount is settled once the Miler verifies the parcels.
|
||||
@override
|
||||
Future<FareEstimate> estimateFare({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
}) => _respond(() {
|
||||
final packages = destinations.fold<int>(0, (n, d) => n + d.packageCount);
|
||||
final stops = destinations.length;
|
||||
return FareEstimate(
|
||||
min: 49 * packages + 20 * (stops - 1),
|
||||
max: 79 * packages + 30 * (stops - 1),
|
||||
paymentMethod: 'UPI · Cash at doorstep',
|
||||
parcel: packages == 1
|
||||
? 'Standard box (up to 3 kg)'
|
||||
: '$packages boxes (up to 3 kg each)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ------------------------------------------------------------------ booking
|
||||
|
||||
/// Creates the pickup **booking**. No shipment, no tracking number yet —
|
||||
/// those are generated by the backend once the Miler completes pickup.
|
||||
/// Creates the pickup **booking**. No shipment and no tracking number yet —
|
||||
/// those are generated per destination once the Miler completes pickup.
|
||||
@override
|
||||
Future<Booking> createBooking({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? idempotencyKey,
|
||||
}) => _respond(() {
|
||||
if (destinations.isEmpty ||
|
||||
destinations.any((d) => !d.destination.isComplete)) {
|
||||
throw ApiException(
|
||||
'invalid',
|
||||
'Every destination needs a serviceable state and district',
|
||||
);
|
||||
}
|
||||
if (slotId == null) throw ApiException('invalid', 'Pick a pickup slot');
|
||||
|
||||
final booking = Booking(
|
||||
reference: newReference(),
|
||||
pickup: pickup,
|
||||
destinations: destinations.map((d) => d.copy()).toList(),
|
||||
slotId: slotId,
|
||||
createdAt: DateTime.now(),
|
||||
fare: fare,
|
||||
history: [StageEvent(JourneyStage.booked, DateTime.now())],
|
||||
);
|
||||
_bookings.insert(0, booking);
|
||||
return booking;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> cancelBooking(String reference, String? reason) =>
|
||||
_respond<void>(() {
|
||||
final booking = _find(reference);
|
||||
if (booking == null) return;
|
||||
booking
|
||||
..status = BookingStatus.cancelled
|
||||
..cancelReason = reason
|
||||
..cancellable = false;
|
||||
});
|
||||
|
||||
/// The customer's bookings — `GET /customer/bookings`.
|
||||
///
|
||||
/// This fake owns the list, exactly as the backend does. [AppState] adopts
|
||||
/// whatever comes back, so a test that wants a booking on screen creates one
|
||||
/// here rather than reaching into app state.
|
||||
@override
|
||||
Future<BookingPage> getBookingPage({
|
||||
BookingStatus? status,
|
||||
String? cursor,
|
||||
int limit = 20,
|
||||
}) => _respond(() {
|
||||
// One page: the fake never has enough bookings to need a second, and a
|
||||
// cursor that led nowhere would only make the paging tests lie.
|
||||
final rows = status == null
|
||||
? _bookings
|
||||
: _bookings.where((b) => b.status == status).toList();
|
||||
return BookingPage(bookings: List.of(rows));
|
||||
});
|
||||
|
||||
/// One booking, fresh — `GET /customer/bookings/{reference}`.
|
||||
@override
|
||||
Future<Booking?> getBooking(String reference) =>
|
||||
_respond<Booking?>(() => _find(reference));
|
||||
|
||||
/// Backend policy decides this; the UI only asks.
|
||||
@override
|
||||
bool isCancellable(JourneyStage stage) =>
|
||||
stage.index < JourneyStage.arrived.index;
|
||||
|
||||
/// The QA stage helper — `POST /customer/ops/bookings/{ref}/stage`.
|
||||
@override
|
||||
Future<void> setStage(String reference, JourneyStage stage) =>
|
||||
_respond<void>(() {
|
||||
final booking = _find(reference);
|
||||
if (booking != null) {
|
||||
applyStage(booking, stage, DateTime.now(),
|
||||
backfillGap: const Duration(minutes: 9));
|
||||
}
|
||||
});
|
||||
|
||||
Booking? _find(String reference) {
|
||||
for (final b in _bookings) {
|
||||
if (b.reference == reference) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- ids
|
||||
|
||||
String newReference() => 'DM-${100000 + _rng.nextInt(899999)}';
|
||||
|
||||
String newTrackingId() => 'DMX${10000000 + _rng.nextInt(89999999)}';
|
||||
|
||||
// ------------------------------------------------------------- stage machine
|
||||
|
||||
/// Everything the **backend** does as a booking moves — assigning a Miler,
|
||||
/// minting a tracking number per destination, weighing the parcels, settling
|
||||
/// the amount paid.
|
||||
///
|
||||
/// It lives here because it is the server's work. It used to live in
|
||||
/// `AppState`, where a stepper in the UI drove it, which meant the app itself
|
||||
/// could mint a tracking number the backend had never issued.
|
||||
void applyStage(
|
||||
Booking booking,
|
||||
JourneyStage target,
|
||||
DateTime now, {
|
||||
required Duration backfillGap,
|
||||
}) {
|
||||
booking.stage = target;
|
||||
|
||||
// Rebuild history so stepping backwards drops the later entries.
|
||||
booking.history = [
|
||||
for (var i = 0; i <= target.index; i++)
|
||||
StageEvent(
|
||||
JourneyStage.at(i),
|
||||
now.subtract(backfillGap * (target.index - i)),
|
||||
),
|
||||
];
|
||||
|
||||
if (target.index >= JourneyStage.assigned.index) {
|
||||
booking.miler ??= const Person(
|
||||
name: 'Arun Kumar',
|
||||
vehicle: 'TN 37 BX 4412',
|
||||
phone: '+91 90000 11223',
|
||||
);
|
||||
}
|
||||
|
||||
switch (target) {
|
||||
case JourneyStage.onTheWay:
|
||||
booking.milerDistanceKm = 1.4;
|
||||
booking.milerEtaMinutes = 12;
|
||||
case JourneyStage.arrived:
|
||||
booking.milerDistanceKm = 0;
|
||||
booking.milerEtaMinutes = 0;
|
||||
case _:
|
||||
booking.milerDistanceKm = null;
|
||||
booking.milerEtaMinutes = null;
|
||||
}
|
||||
|
||||
// The parcels are weighed and photographed at pickup, which is also when
|
||||
// the price settles.
|
||||
if (target.index >= JourneyStage.pickedUp.index) {
|
||||
booking.amountPaid ??= booking.fare?.max ?? 64;
|
||||
for (final group in booking.destinations) {
|
||||
group.verification ??= ParcelVerification(
|
||||
weightKg: 1.2 * group.packageCount + 0.4,
|
||||
photos: [
|
||||
for (var i = 1; i <= group.packageCount; i++)
|
||||
'${group.destination.districtCode}-$i',
|
||||
],
|
||||
capturedAt: now,
|
||||
capturedBy: booking.miler?.name,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
booking.amountPaid = null;
|
||||
for (final group in booking.destinations) {
|
||||
group.verification = null;
|
||||
}
|
||||
}
|
||||
|
||||
booking.deliveredAt =
|
||||
target == JourneyStage.delivered ? (booking.deliveredAt ?? now) : null;
|
||||
|
||||
// The backend turns the pickup into one order per destination here. Only
|
||||
// at this point do the destinations become separately trackable.
|
||||
if (target.index >= JourneyStage.orderCreated.index) {
|
||||
booking.expectedDelivery ??= 'Thu, 12 Sep';
|
||||
for (var i = 0; i < booking.destinations.length; i++) {
|
||||
final group = booking.destinations[i];
|
||||
group.trackingId ??= newTrackingId();
|
||||
group.details.recipientName ??= 'Collected at pickup';
|
||||
group.details.street ??= 'Confirmed by Miler at pickup';
|
||||
// Later destinations trail slightly, so independent journeys are
|
||||
// visible. Real stages come from the backend per order.
|
||||
group.stage = JourneyStage.at(
|
||||
(target.index - i).clamp(
|
||||
JourneyStage.orderCreated.index,
|
||||
JourneyStage.delivered.index,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (final group in booking.destinations) {
|
||||
group.trackingId = null;
|
||||
group.stage = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (target.index >= JourneyStage.outForDelivery.index) {
|
||||
booking.deliveryAgent ??= const Person(
|
||||
name: 'Vishal R',
|
||||
vehicle: 'KL 07 CD 8890',
|
||||
phone: '+91 90000 44556',
|
||||
);
|
||||
}
|
||||
|
||||
booking.status = target == JourneyStage.delivered
|
||||
? BookingStatus.completed
|
||||
: BookingStatus.active;
|
||||
booking.cancellable = isCancellable(target);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- seeded rows
|
||||
|
||||
late final List<Booking> _bookings = _seed();
|
||||
|
||||
/// Clears the account back to its seeded state.
|
||||
void reset() {
|
||||
_bookings
|
||||
..clear()
|
||||
..addAll(_seed());
|
||||
}
|
||||
|
||||
/// One booking per interesting stage, so every tracking state is reachable
|
||||
/// without stepping a booking through the whole journey.
|
||||
List<Booking> _seed() {
|
||||
final now = DateTime.now();
|
||||
|
||||
DestinationGroup group(
|
||||
String stateCode,
|
||||
String stateName,
|
||||
String districtCode,
|
||||
String districtName, {
|
||||
int packages = 1,
|
||||
DeliveryDetails? details,
|
||||
}) => DestinationGroup(
|
||||
destination: Destination(
|
||||
stateCode: stateCode,
|
||||
stateName: stateName,
|
||||
districtCode: districtCode,
|
||||
districtName: districtName,
|
||||
),
|
||||
packageCount: packages,
|
||||
details: details,
|
||||
)..district = districtByCode(districtCode);
|
||||
|
||||
Booking make({
|
||||
required String reference,
|
||||
required JourneyStage stage,
|
||||
required DateTime createdAt,
|
||||
required String slotId,
|
||||
required List<DestinationGroup> destinations,
|
||||
BookingStatus? status,
|
||||
}) {
|
||||
final b = Booking(
|
||||
reference: reference,
|
||||
pickup: const Place(
|
||||
title: '12 Nehru Street',
|
||||
sub: 'Gandhipuram, Coimbatore 641012',
|
||||
),
|
||||
destinations: destinations,
|
||||
slotId: slotId,
|
||||
createdAt: createdAt,
|
||||
fare: const FareEstimate(
|
||||
min: 49,
|
||||
max: 64,
|
||||
paymentMethod: 'UPI · Cash at doorstep',
|
||||
),
|
||||
);
|
||||
applyStage(b, stage, createdAt, backfillGap: const Duration(minutes: 22));
|
||||
if (status != null) {
|
||||
b.status = status;
|
||||
b.cancellable = false;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
return [
|
||||
make(
|
||||
reference: 'DM-482913',
|
||||
stage: JourneyStage.assigned,
|
||||
createdAt: now.subtract(const Duration(minutes: 40)),
|
||||
slotId: 'slot_t_1',
|
||||
destinations: [
|
||||
group(
|
||||
'TN', 'Tamil Nadu', 'TN-MAA', 'Chennai',
|
||||
details: DeliveryDetails(
|
||||
recipientName: 'Meera S',
|
||||
recipientPhone: '9884412210',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// A multi-destination pickup: one visit, three places.
|
||||
make(
|
||||
reference: 'DM-471200',
|
||||
stage: JourneyStage.inTransit,
|
||||
createdAt: now.subtract(const Duration(hours: 26)),
|
||||
slotId: 'slot_m_1',
|
||||
destinations: [
|
||||
group('KL', 'Kerala', 'KL-EKM', 'Ernakulam', packages: 2),
|
||||
group('TN', 'Tamil Nadu', 'TN-CBE', 'Coimbatore'),
|
||||
],
|
||||
),
|
||||
make(
|
||||
reference: 'DM-466118',
|
||||
stage: JourneyStage.outForDelivery,
|
||||
createdAt: now.subtract(const Duration(hours: 50)),
|
||||
slotId: 'slot_t_2',
|
||||
destinations: [
|
||||
group(
|
||||
'KA', 'Karnataka', 'KA-BLR', 'Bengaluru Urban',
|
||||
details: DeliveryDetails(
|
||||
street: 'Indiranagar 12th Main',
|
||||
recipientName: 'Divya P',
|
||||
recipientPhone: '9845567712',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
make(
|
||||
reference: 'DM-451007',
|
||||
stage: JourneyStage.delivered,
|
||||
createdAt: now.subtract(const Duration(days: 5)),
|
||||
slotId: 'slot_m_2',
|
||||
destinations: [
|
||||
group(
|
||||
'TN', 'Tamil Nadu', 'TN-SLM', 'Salem',
|
||||
details: DeliveryDetails(
|
||||
street: 'Fairlands',
|
||||
recipientName: 'Karthik M',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
make(
|
||||
reference: 'DM-449220',
|
||||
stage: JourneyStage.booked,
|
||||
createdAt: now.subtract(const Duration(days: 8)),
|
||||
slotId: 'slot_t_1',
|
||||
destinations: [group('TN', 'Tamil Nadu', 'TN-ERD', 'Erode')],
|
||||
status: BookingStatus.cancelled,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Behaviour switches for [DevDoormileApi]. These used to sit on the
|
||||
/// `DoormileApi` interface, where shipping code could read them; they are
|
||||
/// dev/test-only now.
|
||||
class DevFlags {
|
||||
bool emptyServiceAreas = false;
|
||||
bool networkError = false;
|
||||
bool slow = false;
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
177
lib/data/doormile_api.dart
Normal file
177
lib/data/doormile_api.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'app_config.dart';
|
||||
import 'dev_doormile_api.dart';
|
||||
import 'live_doormile_api.dart';
|
||||
import 'models.dart';
|
||||
|
||||
export 'api_exception.dart';
|
||||
|
||||
/// The service surface the app is written against.
|
||||
///
|
||||
/// **A shipping build has one implementation: [LiveDoormileApi],** against
|
||||
/// `/customer/*` on `api.doormile.com`. When the server cannot be reached the
|
||||
/// screens show their designed error state, which is the only honest answer.
|
||||
///
|
||||
/// The single exception is [DevDoormileApi], reached only when
|
||||
/// [AppConfig.useDevData] is set — a `!kReleaseMode` guard, so no define swaps
|
||||
/// invented data into a release. It exists because the real backend has no SMS
|
||||
/// gateway, so there is otherwise no way to sign in and exercise the flow.
|
||||
///
|
||||
/// Screens never see this type at all — they call [AppState], which holds it.
|
||||
///
|
||||
/// A method the backend owns but no screen drives yet has a default here that
|
||||
/// does nothing rather than throwing.
|
||||
abstract class DoormileApi {
|
||||
DoormileApi();
|
||||
|
||||
static DoormileApi? _instance;
|
||||
|
||||
/// The API this build talks to: the live backend, or [DevDoormileApi] when
|
||||
/// [AppConfig.useDevData] allows it — which is never in a release build.
|
||||
static DoormileApi get instance =>
|
||||
_instance ??= AppConfig.useDevData ? DevDoormileApi() : LiveDoormileApi();
|
||||
|
||||
/// Replaces the singleton. Tests inject their double through here.
|
||||
static void overrideInstance(DoormileApi? api) => _instance = api;
|
||||
|
||||
/// Called when the refresh chain dies and the customer must sign in again.
|
||||
/// Set by [AppState].
|
||||
void Function()? onSessionLost;
|
||||
|
||||
// --------------------------------------------------------------------- auth
|
||||
|
||||
/// Asks for a code. The answer describes the challenge — how many digits it
|
||||
/// has and how long before it can be resent — so the screen is built from
|
||||
/// what the server said rather than from constants.
|
||||
Future<OtpChallenge> sendOtp(String identifier);
|
||||
|
||||
/// Creates the account **and** sends the first code, so it answers with the
|
||||
/// same challenge [sendOtp] does.
|
||||
Future<OtpChallenge> signUp({
|
||||
required String name,
|
||||
required String phone,
|
||||
String? email,
|
||||
});
|
||||
|
||||
/// [name] is set when verifying a freshly created account.
|
||||
Future<Customer> verifyOtp(String identifier, String code, {String? name});
|
||||
|
||||
/// Restores a persisted session at launch, or null when there is none.
|
||||
Future<Customer?> restoreSession() async => null;
|
||||
|
||||
/// Revokes the refresh token and unregisters the push token.
|
||||
Future<void> signOut() async {}
|
||||
|
||||
/// Re-reads the signed-in customer — `GET /customer/auth/me`.
|
||||
Future<Customer?> me() async => null;
|
||||
|
||||
// ----------------------------------------------------------- serviceability
|
||||
|
||||
Future<List<ServiceArea>> getServiceableStates();
|
||||
|
||||
Future<List<District>> getServiceableDistricts(String stateCode);
|
||||
|
||||
/// Synchronous district lookup for a code we already hold, backed by
|
||||
/// whatever [getServiceableDistricts] last returned.
|
||||
District? districtByCode(String? code);
|
||||
|
||||
// -------------------------------------------------------------------- slots
|
||||
|
||||
Future<List<PickupSlot>> getPickupSlots({Place? pickup});
|
||||
|
||||
PickupSlot? slotById(String? id);
|
||||
|
||||
// ----------------------------------------------------------------- location
|
||||
|
||||
Future<Place> reverseGeocode({double? lat, double? lng});
|
||||
|
||||
/// An empty query returns the customer's saved and recent places, which is
|
||||
/// what the search sheet shows the moment it opens.
|
||||
///
|
||||
/// [lat]/[lng] bias the results towards where the customer is looking —
|
||||
/// "MG Road" is in most Indian cities, and the one they mean is the near one.
|
||||
Future<List<Place>> searchPlaces(String query, {double? lat, double? lng});
|
||||
|
||||
// ------------------------------------------------------------------- limits
|
||||
|
||||
Future<BookingLimits> getBookingLimits({Place? pickup});
|
||||
|
||||
// --------------------------------------------------------------------- fare
|
||||
|
||||
Future<FareEstimate> estimateFare({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ booking
|
||||
|
||||
/// Creates the pickup booking. No tracking number yet — those are minted per
|
||||
/// destination when the Miler completes the pickup.
|
||||
///
|
||||
/// [idempotencyKey] must be **held across retries of the same intent**. A new
|
||||
/// key is a new booking, which is exactly what a double tap must not create.
|
||||
Future<Booking> createBooking({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? idempotencyKey,
|
||||
});
|
||||
|
||||
Future<void> cancelBooking(String reference, String? reason);
|
||||
|
||||
/// One page of the customer's bookings, newest first.
|
||||
Future<BookingPage> getBookingPage({
|
||||
BookingStatus? status,
|
||||
String? cursor,
|
||||
int limit = 20,
|
||||
});
|
||||
|
||||
/// One booking, fresh. Null means the server answered `304` — nothing has
|
||||
/// changed since the last read, so the caller keeps what it has.
|
||||
Future<Booking?> getBooking(String reference);
|
||||
|
||||
/// One order by its tracking number — for a push deep link.
|
||||
Future<Booking?> getOrder(String trackingId) async => null;
|
||||
|
||||
/// Adds the optional address and recipient after booking, up to collection.
|
||||
Future<void> updateDestinationDetails(
|
||||
String reference,
|
||||
int index,
|
||||
DeliveryDetails details,
|
||||
) async {}
|
||||
|
||||
/// Backend policy decides this; the UI only asks.
|
||||
bool isCancellable(JourneyStage stage);
|
||||
|
||||
// ------------------------------------------------------------------ ops QA
|
||||
|
||||
/// Moves a booking to [stage] through the backend's own non-production QA
|
||||
/// helper — `POST /customer/ops/bookings/{reference}/stage`.
|
||||
///
|
||||
/// It exists so the tracking screen's stepper can walk a **real** staging
|
||||
/// booking. The stage that then appears on screen is the one the server
|
||||
/// recorded, not one the client wished for. Refused on production.
|
||||
Future<void> setStage(String reference, JourneyStage stage) async {}
|
||||
|
||||
// ------------------------------------------------------------------ devices
|
||||
|
||||
Future<void> registerDevice(String token) async {}
|
||||
|
||||
Future<void> unregisterDevice(String token) async {}
|
||||
|
||||
// ------------------------------------------------------- profile & addresses
|
||||
|
||||
Future<Customer?> getProfile() async => null;
|
||||
|
||||
Future<Customer?> updateProfile({String? name, String? email}) async => null;
|
||||
|
||||
Future<List<SavedPlace>> getSavedLocations() async => const [];
|
||||
|
||||
Future<SavedPlace?> addSavedLocation(Place place, {String? label}) async =>
|
||||
null;
|
||||
|
||||
Future<SavedPlace?> updateSavedLocation(SavedPlace place) async => null;
|
||||
|
||||
Future<void> deleteSavedLocation(String id) async {}
|
||||
|
||||
}
|
||||
603
lib/data/live_doormile_api.dart
Normal file
603
lib/data/live_doormile_api.dart
Normal file
@@ -0,0 +1,603 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'api_client.dart';
|
||||
import 'app_config.dart';
|
||||
import 'doormile_api.dart';
|
||||
import 'models.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
/// The real customer API — `/customer/*` on `api.doormile.com/api/v1`.
|
||||
///
|
||||
/// Every method is a thin translation between the app's models and the wire
|
||||
/// contract. The transport concerns — envelope, error codes, bearer auth and
|
||||
/// its refresh, idempotency, `ETag` revalidation, retries — all live in
|
||||
/// [ApiClient], so what is left here is the shape of each endpoint and nothing
|
||||
/// else.
|
||||
class LiveDoormileApi extends DoormileApi {
|
||||
LiveDoormileApi({ApiClient? client}) : client = client ?? ApiClient() {
|
||||
// Forwarded rather than passed in: `onSessionLost` is set on the interface
|
||||
// by AppState, which does not exist yet at this point.
|
||||
this.client.onSessionLost = () => onSessionLost?.call();
|
||||
}
|
||||
|
||||
final ApiClient client;
|
||||
|
||||
// ── Caches that exist to answer synchronous questions ────────────────────
|
||||
//
|
||||
// Two screens ask for a district or a slot by the code they already hold,
|
||||
// without a round trip. Neither is a substitute for the fetch: each is
|
||||
// populated only by a real response, so an entry can never be invented.
|
||||
|
||||
final Map<String, District> _districtsByCode = {};
|
||||
final Map<String, PickupSlot> _slotsById = {};
|
||||
|
||||
/// `ETag`s keyed by path, so a revalidated read costs a 304 and no body.
|
||||
final Map<String, String> _etags = {};
|
||||
final Map<String, List<ServiceArea>> _statesCache = {};
|
||||
final Map<String, List<District>> _districtCache = {};
|
||||
final Map<String, List<PickupSlot>> _slotCache = {};
|
||||
|
||||
// --------------------------------------------------------------------- auth
|
||||
|
||||
@override
|
||||
Future<OtpChallenge> sendOtp(String identifier) async {
|
||||
final response = await client.post(
|
||||
'/auth/otp/request',
|
||||
body: {'identifier': _normalisePhone(identifier)},
|
||||
authenticated: false,
|
||||
);
|
||||
return OtpChallenge.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OtpChallenge> signUp({
|
||||
required String name,
|
||||
required String phone,
|
||||
String? email,
|
||||
}) async {
|
||||
final response = await client.post(
|
||||
'/auth/signup',
|
||||
body: {
|
||||
'name': name.trim(),
|
||||
'phone': _normalisePhone(phone),
|
||||
if (email != null && email.trim().isNotEmpty) 'email': email.trim(),
|
||||
},
|
||||
authenticated: false,
|
||||
);
|
||||
return OtpChallenge.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer> verifyOtp(
|
||||
String identifier,
|
||||
String code, {
|
||||
String? name,
|
||||
}) async {
|
||||
final response = await client.post(
|
||||
'/auth/otp/verify',
|
||||
// The field is `code`, NOT `otp`. The written contract says `otp`, but
|
||||
// the server reads `json:"code"` (CxVerifyOtp) and rejects a body without
|
||||
// it as an empty code — "Enter the code we sent you", a 400, on every
|
||||
// single sign-in. Verified against production: `otp` returns that 400,
|
||||
// `code` returns 401 "That code did not match" for a wrong code and a
|
||||
// session for a right one. Follow the server, not the document.
|
||||
//
|
||||
// The name is deliberately not sent: `POST /auth/signup` has already
|
||||
// recorded it, and it is not part of this request.
|
||||
body: {'identifier': _normalisePhone(identifier), 'code': code},
|
||||
authenticated: false,
|
||||
// Verification mints a session. A retry over a flaky network must return
|
||||
// the same one rather than a second, or the first is orphaned.
|
||||
idempotencyKey: client.newIdempotencyKey(),
|
||||
);
|
||||
|
||||
final data = response.map;
|
||||
final access = data['accessToken'] as String?;
|
||||
final refresh = data['refreshToken'] as String?;
|
||||
if (access == null || refresh == null) {
|
||||
throw ApiException(
|
||||
ApiException.serverError,
|
||||
'Something went wrong',
|
||||
response.data == null ? null : 200,
|
||||
response.requestId,
|
||||
);
|
||||
}
|
||||
|
||||
final customer = Customer.fromJson(
|
||||
(data['customer'] as Map<String, dynamic>?) ?? const {},
|
||||
);
|
||||
|
||||
await client.adoptSession(
|
||||
Session(
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiresAt: DateTime.now().add(
|
||||
Duration(seconds: (data['expiresIn'] as num?)?.toInt() ?? 3600),
|
||||
),
|
||||
customer: customer,
|
||||
),
|
||||
);
|
||||
return customer;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer?> restoreSession() async {
|
||||
await _adoptDevTokenIfGiven();
|
||||
final session = await client.currentSession();
|
||||
if (session == null) return null;
|
||||
// A stored session is a claim, not a fact — the refresh token may have been
|
||||
// revoked from another device. Ask the server who this is; a 401 here means
|
||||
// the chain is dead and ApiClient has already cleared it.
|
||||
try {
|
||||
final me = await this.me();
|
||||
return me ?? session.customer;
|
||||
} on ApiException catch (e) {
|
||||
if (e.isAuthFailure) return null;
|
||||
// Offline at launch is not a signed-out customer. Trust the stored copy
|
||||
// and let the first real call settle it.
|
||||
return session.customer;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer?> me() async {
|
||||
final response = await client.get('/auth/me');
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
final data = response.map;
|
||||
final customer = Customer.fromJson(
|
||||
(data['customer'] as Map<String, dynamic>?) ?? data,
|
||||
);
|
||||
final session = await client.currentSession();
|
||||
if (session != null) {
|
||||
await client.adoptSession(session.copyWith(customer: customer));
|
||||
}
|
||||
return customer;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
final session = await client.currentSession();
|
||||
try {
|
||||
await client.post(
|
||||
'/auth/logout',
|
||||
body: {'refreshToken': ?session?.refreshToken},
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
// Signing out locally must always succeed. A server that refuses only
|
||||
// means the refresh token outlives the device, which its 60-day TTL
|
||||
// settles anyway.
|
||||
debugPrint('[AUTH] logout call failed, clearing locally: $e');
|
||||
} finally {
|
||||
await client.dropSession();
|
||||
_districtsByCode.clear();
|
||||
_slotsById.clear();
|
||||
_etags.clear();
|
||||
_statesCache.clear();
|
||||
_districtCache.clear();
|
||||
_slotCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopts a token handed in at build time, when there is no stored session.
|
||||
///
|
||||
/// The token is real and server-issued — this skips the OTP round trip that
|
||||
/// staging cannot complete without an SMS gateway, not the authorisation.
|
||||
/// Every request it signs is still checked server-side, and a revoked token
|
||||
/// signs the build straight back out.
|
||||
///
|
||||
/// A stored session always wins: a real sign-in on the device must not be
|
||||
/// silently replaced by whatever was compiled in weeks ago.
|
||||
Future<void> _adoptDevTokenIfGiven() async {
|
||||
if (!AppConfig.hasDevToken) return;
|
||||
if (await client.currentSession() != null) return;
|
||||
debugPrint('[AUTH] using DM_DEV_TOKEN — no sign-in was performed');
|
||||
await client.adoptSession(
|
||||
Session(
|
||||
accessToken: AppConfig.devToken,
|
||||
refreshToken: AppConfig.devRefreshToken,
|
||||
// Without a refresh token there is nothing to rotate, so do not spend
|
||||
// a call discovering that. The token dies when the server says so.
|
||||
expiresAt: DateTime.now().add(
|
||||
AppConfig.devRefreshToken.isEmpty
|
||||
? const Duration(days: 365)
|
||||
: const Duration(hours: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The client sends `+91 98765 43210` today; the server normalises, but
|
||||
/// sending E.164 costs nothing and removes the ambiguity.
|
||||
static String _normalisePhone(String identifier) {
|
||||
final trimmed = identifier.trim();
|
||||
if (trimmed.contains('@')) return trimmed;
|
||||
final digits = trimmed.replaceAll(RegExp(r'[^0-9+]'), '');
|
||||
if (digits.startsWith('+')) return digits;
|
||||
if (digits.length == 10) return '+91$digits';
|
||||
if (digits.length == 12 && digits.startsWith('91')) return '+$digits';
|
||||
return digits.isEmpty ? trimmed : digits;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- serviceability
|
||||
|
||||
@override
|
||||
Future<List<ServiceArea>> getServiceableStates() async {
|
||||
const path = '/serviceability/states';
|
||||
final response = await client.get(path, ifNoneMatch: _etags[path]);
|
||||
if (response.notModified) return _statesCache[path] ?? const [];
|
||||
|
||||
final states = response.rows.map(ServiceArea.fromJson).toList();
|
||||
if (response.etag != null) _etags[path] = response.etag!;
|
||||
_statesCache[path] = states;
|
||||
return states;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<District>> getServiceableDistricts(String stateCode) async {
|
||||
final path = '/serviceability/states/$stateCode/districts';
|
||||
final response = await client.get(path, ifNoneMatch: _etags[path]);
|
||||
if (response.notModified) return _districtCache[path] ?? const [];
|
||||
|
||||
// The full list arrives, unavailable districts included: the picker filters
|
||||
// them out but their names feed the "Coming soon" line.
|
||||
final districts = response.rows.map(District.fromJson).toList();
|
||||
for (final district in districts) {
|
||||
_districtsByCode[district.code] = district;
|
||||
}
|
||||
if (response.etag != null) _etags[path] = response.etag!;
|
||||
_districtCache[path] = districts;
|
||||
return districts;
|
||||
}
|
||||
|
||||
@override
|
||||
District? districtByCode(String? code) =>
|
||||
code == null ? null : _districtsByCode[code];
|
||||
|
||||
// -------------------------------------------------------------------- slots
|
||||
|
||||
@override
|
||||
Future<List<PickupSlot>> getPickupSlots({Place? pickup}) async {
|
||||
final query = {
|
||||
if (pickup?.lat != null) 'lat': '${pickup!.lat}',
|
||||
if (pickup?.lng != null) 'lng': '${pickup!.lng}',
|
||||
};
|
||||
// Tagged per zone: the capacity in one zone's windows says nothing about
|
||||
// another's, so the coordinates are part of the cache key.
|
||||
final tag = '/pickup-slots?${query.entries.map((e) => '${e.key}=${e.value}').join('&')}';
|
||||
final response = await client.get(
|
||||
'/pickup-slots',
|
||||
query: query,
|
||||
ifNoneMatch: _etags[tag],
|
||||
);
|
||||
if (response.notModified) return _slotCache[tag] ?? const [];
|
||||
|
||||
final slots = response.rows.map(PickupSlot.fromJson).toList();
|
||||
for (final slot in slots) {
|
||||
_slotsById[slot.id] = slot;
|
||||
}
|
||||
if (response.etag != null) _etags[tag] = response.etag!;
|
||||
_slotCache[tag] = slots;
|
||||
return slots;
|
||||
}
|
||||
|
||||
@override
|
||||
PickupSlot? slotById(String? id) => id == null ? null : _slotsById[id];
|
||||
|
||||
// ----------------------------------------------------------------- location
|
||||
|
||||
@override
|
||||
Future<Place> reverseGeocode({double? lat, double? lng}) async {
|
||||
if (lat == null || lng == null) {
|
||||
throw ApiException(ApiException.invalid, 'No location to look up');
|
||||
}
|
||||
final response = await client.get(
|
||||
'/places/reverse-geocode',
|
||||
query: {'lat': '$lat', 'lng': '$lng'},
|
||||
authenticated: true,
|
||||
);
|
||||
final place = Place.fromJson(response.map);
|
||||
// The endpoint echoes the coordinates, but the pin is the authority on
|
||||
// where the Miler is sent — never let a geocoder move it.
|
||||
return Place(title: place.title, sub: place.sub, lat: lat, lng: lng);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Place>> searchPlaces(String query, {double? lat, double? lng}) async {
|
||||
final response = await client.get(
|
||||
'/places/search',
|
||||
query: {
|
||||
'q': query.trim(),
|
||||
// Biases the results towards where the customer is looking. The
|
||||
// contract takes both and an empty query then answers with their
|
||||
// recent and saved places instead.
|
||||
if (lat != null) 'lat': '$lat',
|
||||
if (lng != null) 'lng': '$lng',
|
||||
},
|
||||
);
|
||||
return response.rows.map(Place.fromJson).toList();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- limits
|
||||
|
||||
@override
|
||||
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
|
||||
final response = await client.get(
|
||||
'/config/booking-limits',
|
||||
query: {
|
||||
if (pickup?.lat != null) 'lat': '${pickup!.lat}',
|
||||
if (pickup?.lng != null) 'lng': '${pickup!.lng}',
|
||||
},
|
||||
);
|
||||
return BookingLimits.fromJson(response.map);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- fare
|
||||
|
||||
@override
|
||||
Future<FareEstimate> estimateFare({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
}) async {
|
||||
final response = await client.post(
|
||||
'/fare/estimate',
|
||||
body: {
|
||||
// The contract also accepts a `stateCode`/`districtCode` on the pickup.
|
||||
// This app does not have them: the pickup is a map pin, and the
|
||||
// reverse geocoder returns an address, not a serviceable-area code. The
|
||||
// coordinates say the same thing and the server already resolves them.
|
||||
'pickup': {'latitude': ?pickup.lat, 'longitude': ?pickup.lng},
|
||||
'destinations': [
|
||||
for (final d in destinations)
|
||||
{
|
||||
'stateCode': ?d.destination.stateCode,
|
||||
'districtCode': ?d.destination.districtCode,
|
||||
// One entry per package, and no `weightKg` on any of them: this
|
||||
// app never asks the customer what a parcel weighs — the Miler
|
||||
// weighs it at the door, which is when the price settles — so the
|
||||
// count is all we honestly know, and the server prices its own
|
||||
// default band from it.
|
||||
'packages': [
|
||||
for (var i = 0; i < d.packageCount; i++) const <String, dynamic>{},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
timeout: null,
|
||||
);
|
||||
final fare = FareEstimate.fromJson(response.map);
|
||||
if (fare == null) {
|
||||
throw ApiException(
|
||||
ApiException.serverError,
|
||||
'We could not price that just now',
|
||||
);
|
||||
}
|
||||
return fare;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ booking
|
||||
|
||||
@override
|
||||
Future<Booking> createBooking({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? idempotencyKey,
|
||||
}) async {
|
||||
if (slotId == null || slotId.isEmpty) {
|
||||
throw ApiException(ApiException.invalid, 'Pick a pickup slot');
|
||||
}
|
||||
// [fare] is deliberately not sent. The contract's create request has no
|
||||
// field for what the customer was quoted, and an undocumented extra on the
|
||||
// one call that must not be rejected is not worth the audit trail. The
|
||||
// server prices the booking itself; the shown band lives in the estimate
|
||||
// call's own log.
|
||||
//
|
||||
// Who the Miler asks for at the door. The contract asks for it on the
|
||||
// pickup, and the signed-in customer is the only answer this app has.
|
||||
final customer = (await client.currentSession())?.customer;
|
||||
|
||||
final response = await client.post(
|
||||
'/bookings',
|
||||
idempotencyKey: idempotencyKey ?? client.newIdempotencyKey(),
|
||||
body: {
|
||||
'slotId': slotId,
|
||||
'pickup': pickup.toBookingJson(
|
||||
contactName: customer?.name,
|
||||
contactPhone: customer?.phone,
|
||||
),
|
||||
'destinations': [for (final d in destinations) d.toBookingJson()],
|
||||
// The delivery instructions the customer typed per destination. The
|
||||
// contract carries one `remarks` line for the whole visit, which is
|
||||
// what the Miler reads, so several are joined rather than dropped.
|
||||
'remarks': ?_remarksFrom(destinations),
|
||||
},
|
||||
);
|
||||
return _bookingFrom(response.map);
|
||||
}
|
||||
|
||||
static String? _remarksFrom(List<DestinationGroup> destinations) {
|
||||
final lines = [
|
||||
for (final d in destinations)
|
||||
if (d.details.instructions?.trim().isNotEmpty ?? false)
|
||||
d.details.instructions!.trim(),
|
||||
];
|
||||
return lines.isEmpty ? null : lines.join(' · ');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelBooking(String reference, String? reason) async {
|
||||
await client.post(
|
||||
'/bookings/$reference/cancel',
|
||||
body: {'reason': ?reason},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BookingPage> getBookingPage({
|
||||
BookingStatus? status,
|
||||
String? cursor,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await client.get(
|
||||
'/bookings',
|
||||
query: {
|
||||
if (status != null) 'status': status.name,
|
||||
'limit': '$limit',
|
||||
'cursor': ?cursor,
|
||||
},
|
||||
);
|
||||
return BookingPage(
|
||||
bookings: response.rows.map(_bookingFrom).toList(),
|
||||
nextCursor: response.nextCursor,
|
||||
total: response.total,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Booking?> getBooking(String reference) async {
|
||||
final path = '/bookings/$reference';
|
||||
final response = await client.get(path, ifNoneMatch: _etags[path]);
|
||||
// 304 is the common answer while the tracking screen polls: nothing moved,
|
||||
// so the caller keeps what it already has.
|
||||
if (response.notModified) return null;
|
||||
if (response.etag != null) _etags[path] = response.etag!;
|
||||
return _bookingFrom(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Booking?> getOrder(String trackingId) async {
|
||||
final response = await client.get('/orders/$trackingId');
|
||||
return _bookingFrom(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateDestinationDetails(
|
||||
String reference,
|
||||
int index,
|
||||
DeliveryDetails details,
|
||||
) async {
|
||||
await client.patch(
|
||||
'/bookings/$reference/destinations/$index',
|
||||
body: details.toPatchJson(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors the server's policy so the button can be hidden, but the server
|
||||
/// re-checks and is the authority — this only decides what is offered.
|
||||
///
|
||||
/// The window closes **when the Miler arrives**, so `arrived` is the cutoff
|
||||
/// and is itself no longer cancellable. Offering it one stage too long is not
|
||||
/// a cosmetic slip: the customer taps Cancel with the Miler at the door, the
|
||||
/// server answers `BOOKING_NOT_CANCELLABLE`, and the app has to explain a
|
||||
/// refusal it invited.
|
||||
@override
|
||||
bool isCancellable(JourneyStage stage) =>
|
||||
stage.index < JourneyStage.arrived.index;
|
||||
|
||||
Booking _bookingFrom(Map<String, dynamic> json) {
|
||||
final booking = Booking.fromJson(json);
|
||||
// Attach the hub we already know for each district, so the route map can
|
||||
// draw its line without another round trip.
|
||||
for (final group in booking.destinations) {
|
||||
group.district ??= districtByCode(group.destination.districtCode);
|
||||
}
|
||||
return booking;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ops QA
|
||||
|
||||
/// Walks a booking through the stages on a non-production backend.
|
||||
///
|
||||
/// This is the server's own QA helper — double-gated behind its
|
||||
/// `CX_ALLOW_STAGE_OVERRIDE` — and it is what the tracking screen's stepper
|
||||
/// drives when the build asks for it against staging. It
|
||||
/// refuses to leave the app on production before the request is made,
|
||||
/// because a stage the client asked for is not a stage that happened.
|
||||
@override
|
||||
Future<void> setStage(String reference, JourneyStage stage) async {
|
||||
if (AppConfig.isProd) {
|
||||
throw ApiException(
|
||||
ApiException.forbidden,
|
||||
'Stage override is not available on production',
|
||||
);
|
||||
}
|
||||
await client.post(
|
||||
'/ops/bookings/$reference/stage',
|
||||
body: {'stage': stage.key},
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ devices
|
||||
|
||||
@override
|
||||
Future<void> registerDevice(String token) async {
|
||||
await client.post(
|
||||
'/devices',
|
||||
body: {
|
||||
'token': token,
|
||||
'platform': _platformName,
|
||||
'appVersion': _appVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unregisterDevice(String token) async {
|
||||
await client.delete('/devices/$token');
|
||||
}
|
||||
|
||||
static String get _platformName => AppConfig.platformHeader;
|
||||
static String get _appVersion => AppConfig.appVersion;
|
||||
|
||||
// ------------------------------------------------------- profile & addresses
|
||||
|
||||
@override
|
||||
Future<Customer?> getProfile() async {
|
||||
final response = await client.get('/profile');
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
return Customer.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer?> updateProfile({String? name, String? email}) async {
|
||||
final response = await client.put(
|
||||
'/profile',
|
||||
body: {'name': ?name, 'email': ?email},
|
||||
);
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
return Customer.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SavedPlace>> getSavedLocations() async {
|
||||
final response = await client.get('/locations');
|
||||
return response.rows.map(SavedPlace.fromJson).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SavedPlace?> addSavedLocation(Place place, {String? label}) async {
|
||||
final response = await client.post(
|
||||
'/locations',
|
||||
body: {...place.toJson(), 'label': ?label},
|
||||
);
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
return SavedPlace.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SavedPlace?> updateSavedLocation(SavedPlace place) async {
|
||||
final response = await client.put(
|
||||
'/locations/${place.id}',
|
||||
body: place.toJson(),
|
||||
);
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
return SavedPlace.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSavedLocation(String id) async {
|
||||
await client.delete('/locations/$id');
|
||||
}
|
||||
|
||||
}
|
||||
173
lib/data/location_service.dart
Normal file
173
lib/data/location_service.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
/// Why a location fix is not available. Each value maps to a distinct thing we
|
||||
/// tell the customer, so none of them is a generic failure.
|
||||
enum LocationDenial {
|
||||
/// Device location is switched off entirely.
|
||||
serviceOff,
|
||||
|
||||
/// Permission refused this time; asking again is allowed.
|
||||
denied,
|
||||
|
||||
/// Refused permanently — only Settings can undo it.
|
||||
deniedForever,
|
||||
|
||||
/// Permission granted, but the fix timed out or the platform failed.
|
||||
unavailable,
|
||||
}
|
||||
|
||||
/// A resolved device position, or the reason there isn't one.
|
||||
class LocationFix {
|
||||
const LocationFix.ok(this.lat, this.lng, {this.accuracy}) : denial = null;
|
||||
const LocationFix.denied(this.denial)
|
||||
: lat = null,
|
||||
lng = null,
|
||||
accuracy = null;
|
||||
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
|
||||
/// Horizontal accuracy in metres, when the platform reports it. Drawn as the
|
||||
/// halo around the blue dot, so the customer can see how sure we are.
|
||||
final double? accuracy;
|
||||
|
||||
final LocationDenial? denial;
|
||||
|
||||
bool get hasFix => lat != null && lng != null;
|
||||
|
||||
/// What to show the customer when there is no fix. Never a raw error.
|
||||
String get message => switch (denial) {
|
||||
LocationDenial.serviceOff =>
|
||||
'Turn on location to drop the pin for you. You can still search for the address.',
|
||||
LocationDenial.denied =>
|
||||
'Allow location access and we will drop the pin for you.',
|
||||
LocationDenial.deniedForever =>
|
||||
'Location is blocked for Doormile. Enable it in Settings, or search for the address.',
|
||||
_ => 'We could not get your location. Search for the address instead.',
|
||||
};
|
||||
}
|
||||
|
||||
/// The only place the app touches platform GPS.
|
||||
///
|
||||
/// Every failure — permission refused, location off, no plugin (widget tests) —
|
||||
/// comes back as a [LocationFix] with a [LocationDenial]. Nothing throws, so a
|
||||
/// caller never has to guard the booking flow behind a try/catch.
|
||||
class LocationService {
|
||||
LocationService();
|
||||
|
||||
/// Swappable so tests can answer without a platform.
|
||||
static LocationService instance = LocationService();
|
||||
|
||||
/// Coimbatore city centre. Where the map opens before we know better, so the
|
||||
/// customer never stares at a grey ocean tile.
|
||||
static const fallbackLat = 11.0168;
|
||||
static const fallbackLng = 76.9558;
|
||||
|
||||
/// How long to wait on a permission or service answer before giving up.
|
||||
static const _probe = Duration(seconds: 4);
|
||||
|
||||
/// Asks for permission if we do not have it yet, then returns a fix.
|
||||
///
|
||||
/// Every platform call is bounded: a wedged location plugin must never leave
|
||||
/// the customer watching "Getting address…" with no way forward.
|
||||
Future<LocationFix> current({
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
}) async {
|
||||
final enabled = await _guard(Geolocator.isLocationServiceEnabled, _probe);
|
||||
if (enabled == null) {
|
||||
return const LocationFix.denied(LocationDenial.unavailable);
|
||||
}
|
||||
if (!enabled) return const LocationFix.denied(LocationDenial.serviceOff);
|
||||
|
||||
var permission = await _guard(Geolocator.checkPermission, _probe);
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await _guard(Geolocator.requestPermission, timeout);
|
||||
}
|
||||
if (permission == null) {
|
||||
return const LocationFix.denied(LocationDenial.unavailable);
|
||||
}
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
return const LocationFix.denied(LocationDenial.deniedForever);
|
||||
}
|
||||
if (permission == LocationPermission.denied) {
|
||||
return const LocationFix.denied(LocationDenial.denied);
|
||||
}
|
||||
|
||||
final fix = await _guard(
|
||||
() => Geolocator.getCurrentPosition(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
timeLimit: timeout,
|
||||
),
|
||||
),
|
||||
timeout,
|
||||
// A cached fix beats no fix at all when the precise one times out.
|
||||
) ?? await _guard<Position?>(() => Geolocator.getLastKnownPosition(), _probe);
|
||||
|
||||
return fix == null
|
||||
? const LocationFix.denied(LocationDenial.unavailable)
|
||||
: LocationFix.ok(
|
||||
fix.latitude,
|
||||
fix.longitude,
|
||||
accuracy: fix.accuracy > 0 ? fix.accuracy : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs a platform call, returning null instead of throwing or hanging.
|
||||
/// Covers MissingPluginException, a refused channel, and a plugin that
|
||||
/// simply never answers.
|
||||
static Future<T?> _guard<T>(Future<T> Function() call, Duration limit) async {
|
||||
try {
|
||||
return await call().timeout(limit);
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the OS settings page so a permanently denied customer can recover.
|
||||
Future<void> openSettings() async {
|
||||
try {
|
||||
await Geolocator.openAppSettings();
|
||||
} on Object {
|
||||
// Nothing useful to do if the platform refuses.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers instantly with a known position, without touching the platform.
|
||||
///
|
||||
/// **A test seam, and only that.** Widget tests run on a fake clock that a real
|
||||
/// platform-channel reply never reaches, so every test that renders the pickup
|
||||
/// screen installs one of these as [LocationService.instance]. Nothing in the
|
||||
/// app does, and nothing should: a build that invents where the customer is
|
||||
/// sends a Miler to the wrong door.
|
||||
class FixedLocationService extends LocationService {
|
||||
FixedLocationService({
|
||||
this.lat = LocationService.fallbackLat,
|
||||
this.lng = LocationService.fallbackLng,
|
||||
this.accuracy = 18,
|
||||
this.denial,
|
||||
});
|
||||
|
||||
/// A service that always refuses, for exercising the denied states.
|
||||
FixedLocationService.denied(LocationDenial reason)
|
||||
: lat = LocationService.fallbackLat,
|
||||
lng = LocationService.fallbackLng,
|
||||
accuracy = null,
|
||||
denial = reason;
|
||||
|
||||
final double lat;
|
||||
final double lng;
|
||||
final double? accuracy;
|
||||
final LocationDenial? denial;
|
||||
|
||||
@override
|
||||
Future<LocationFix> current({
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
}) async => denial != null
|
||||
? LocationFix.denied(denial!)
|
||||
: LocationFix.ok(lat, lng, accuracy: accuracy);
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {}
|
||||
}
|
||||
306
lib/data/map_config.dart
Normal file
306
lib/data/map_config.dart
Normal file
@@ -0,0 +1,306 @@
|
||||
/// Where map tiles come from, what must be credited, and any key needed to ask
|
||||
/// for them.
|
||||
///
|
||||
/// This is the **only** place a tile URL, an attribution string or a map API
|
||||
/// key exists. No widget builds a URL; screens ask for a style and get back
|
||||
/// whatever the configured provider serves. Swapping CARTO for MapTiler, for a
|
||||
/// keyed CARTO plan, or for self-hosted tiles is a build flag or one entry in
|
||||
/// [MapTileProvider.presets] — no map screen changes.
|
||||
///
|
||||
/// Deliberately free of Flutter imports so the choice of provider is testable
|
||||
/// as plain data. `lib/ui/widgets/map_tiles.dart` turns it into a `TileLayer`.
|
||||
library;
|
||||
|
||||
/// The two cartographies the app uses.
|
||||
enum DmMapStyle {
|
||||
/// Streets, labels and landmarks. The pickup map, where the customer is
|
||||
/// looking for their own door.
|
||||
streets,
|
||||
|
||||
/// Quieter, near-monochrome. The route map, where the line is the subject
|
||||
/// and the basemap is context.
|
||||
muted,
|
||||
}
|
||||
|
||||
/// A raster tile source: its URL templates, its credit line, and its key.
|
||||
class MapTileProvider {
|
||||
const MapTileProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.templates,
|
||||
required this.attribution,
|
||||
this.attributionUrl = osmCopyright,
|
||||
this.subdomains = const <String>[],
|
||||
this.keyParam,
|
||||
this.apiKey = '',
|
||||
this.requiresKey = false,
|
||||
this.maxNativeZoom = 20,
|
||||
this.retina = true,
|
||||
});
|
||||
|
||||
/// Stable id, also what `DM_MAP_PROVIDER` selects.
|
||||
final String id;
|
||||
|
||||
/// Human name, for the "map source" line in diagnostics.
|
||||
final String name;
|
||||
|
||||
/// One URL template per style. `{s}` `{z}` `{x}` `{y}` `{r}` as flutter_map
|
||||
/// expects; the key is appended by [urlFor], never written into a template.
|
||||
final Map<DmMapStyle, String> templates;
|
||||
|
||||
/// The credit line that must stay visible on every map using this source.
|
||||
final String attribution;
|
||||
|
||||
/// Where the attribution links, for the licence.
|
||||
final String attributionUrl;
|
||||
|
||||
/// Tile host shards, when the provider uses them.
|
||||
final List<String> subdomains;
|
||||
|
||||
/// Query parameter the key travels in — `api_key` for CARTO and Stadia,
|
||||
/// `key` for MapTiler. Null when the provider takes no key.
|
||||
final String? keyParam;
|
||||
|
||||
/// The key itself. Supplied at build time, never committed.
|
||||
final String apiKey;
|
||||
|
||||
/// True when the provider serves nothing without a key, so a missing key is
|
||||
/// a misconfiguration rather than a downgrade.
|
||||
final bool requiresKey;
|
||||
|
||||
final int maxNativeZoom;
|
||||
|
||||
/// Whether the provider serves `@2x` tiles for the `{r}` placeholder.
|
||||
final bool retina;
|
||||
|
||||
static const osmCopyright = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
bool get hasKey => apiKey.isNotEmpty;
|
||||
|
||||
/// False when this provider cannot serve a tile as configured.
|
||||
bool get usable => !requiresKey || hasKey;
|
||||
|
||||
/// The template for [style], with the key appended when there is one.
|
||||
String urlFor(DmMapStyle style) {
|
||||
final template = templates[style] ?? templates[DmMapStyle.streets]!;
|
||||
if (!hasKey || keyParam == null) return template;
|
||||
final separator = template.contains('?') ? '&' : '?';
|
||||
return '$template$separator$keyParam=$apiKey';
|
||||
}
|
||||
|
||||
MapTileProvider withKey(String key) => MapTileProvider(
|
||||
id: id,
|
||||
name: name,
|
||||
templates: templates,
|
||||
attribution: attribution,
|
||||
attributionUrl: attributionUrl,
|
||||
subdomains: subdomains,
|
||||
keyParam: keyParam,
|
||||
apiKey: key,
|
||||
requiresKey: requiresKey,
|
||||
maxNativeZoom: maxNativeZoom,
|
||||
retina: retina,
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------- presets
|
||||
|
||||
/// CARTO's basemaps over OpenStreetMap data. The development default:
|
||||
/// warm, legible, and usable without an account.
|
||||
///
|
||||
/// Anonymous use is **not** a production contract. CARTO issues API keys for
|
||||
/// application use — set `DM_MAP_KEY` and it travels as `api_key` on every
|
||||
/// tile request, no other change required.
|
||||
static const carto = MapTileProvider(
|
||||
id: 'carto',
|
||||
name: 'CARTO basemaps (OpenStreetMap data)',
|
||||
templates: {
|
||||
DmMapStyle.streets:
|
||||
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
DmMapStyle.muted:
|
||||
'https://{s}.basemaps.cartocdn.com/rastertiles/light_all/{z}/{x}/{y}{r}.png',
|
||||
},
|
||||
attribution: '© OpenStreetMap contributors · © CARTO',
|
||||
subdomains: ['a', 'b', 'c', 'd'],
|
||||
keyParam: 'api_key',
|
||||
);
|
||||
|
||||
/// OpenStreetMap's own tile servers.
|
||||
///
|
||||
/// Their usage policy does not permit a distributed app to lean on these —
|
||||
/// present for local development and as a last-resort fallback only.
|
||||
static const osm = MapTileProvider(
|
||||
id: 'osm',
|
||||
name: 'OpenStreetMap standard tiles',
|
||||
templates: {
|
||||
DmMapStyle.streets: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
DmMapStyle.muted: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
},
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxNativeZoom: 19,
|
||||
retina: false,
|
||||
);
|
||||
|
||||
/// MapTiler — a commercial provider with an India-usable free tier.
|
||||
static const maptiler = MapTileProvider(
|
||||
id: 'maptiler',
|
||||
name: 'MapTiler',
|
||||
templates: {
|
||||
DmMapStyle.streets:
|
||||
'https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}{r}.png',
|
||||
DmMapStyle.muted:
|
||||
'https://api.maptiler.com/maps/dataviz-light/{z}/{x}/{y}{r}.png',
|
||||
},
|
||||
attribution: '© MapTiler · © OpenStreetMap contributors',
|
||||
keyParam: 'key',
|
||||
requiresKey: true,
|
||||
);
|
||||
|
||||
/// Stadia Maps, serving OpenStreetMap-derived styles.
|
||||
static const stadia = MapTileProvider(
|
||||
id: 'stadia',
|
||||
name: 'Stadia Maps',
|
||||
templates: {
|
||||
DmMapStyle.streets:
|
||||
'https://tiles.stadiamaps.com/tiles/osm_bright/{z}/{x}/{y}{r}.png',
|
||||
DmMapStyle.muted:
|
||||
'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png',
|
||||
},
|
||||
attribution: '© Stadia Maps · © OpenStreetMap contributors',
|
||||
keyParam: 'api_key',
|
||||
requiresKey: true,
|
||||
);
|
||||
|
||||
/// Everything that ships with the app, by id.
|
||||
static const Map<String, MapTileProvider> presets = {
|
||||
'carto': carto,
|
||||
'osm': osm,
|
||||
'maptiler': maptiler,
|
||||
'stadia': stadia,
|
||||
};
|
||||
}
|
||||
|
||||
/// The resolved map configuration for this build.
|
||||
///
|
||||
/// Read once at startup from `--dart-define`s; [instance] is assignable so a
|
||||
/// test or a staging build can substitute one without touching a screen.
|
||||
class DmMapConfig {
|
||||
const DmMapConfig({
|
||||
required this.provider,
|
||||
required this.userAgent,
|
||||
this.warning,
|
||||
});
|
||||
|
||||
final MapTileProvider provider;
|
||||
|
||||
/// Sent as the `User-Agent` on every tile request, so the provider can
|
||||
/// identify and rate-limit the app properly rather than seeing raw traffic.
|
||||
final String userAgent;
|
||||
|
||||
/// Set when the requested configuration could not be honoured — surfaced in
|
||||
/// debug diagnostics rather than silently swallowed.
|
||||
final String? warning;
|
||||
|
||||
/// Swappable for tests and staging.
|
||||
static DmMapConfig instance = DmMapConfig.fromEnvironment();
|
||||
|
||||
/// The bundle id, which is also what identifies us to a tile provider.
|
||||
static const packageName = 'in.doormile.customer';
|
||||
|
||||
// ---- build-time configuration -------------------------------------------
|
||||
//
|
||||
// flutter run \
|
||||
// --dart-define=DM_MAP_PROVIDER=maptiler \
|
||||
// --dart-define=DM_MAP_KEY=xxxxxxxx
|
||||
//
|
||||
// flutter build appbundle \
|
||||
// --dart-define=DM_MAP_PROVIDER=carto \
|
||||
// --dart-define=DM_MAP_KEY=$CARTO_API_KEY
|
||||
//
|
||||
// # a fully custom or self-hosted style
|
||||
// flutter run \
|
||||
// --dart-define=DM_MAP_PROVIDER=custom \
|
||||
// --dart-define=DM_MAP_URL=https://tiles.doormile.in/streets/{z}/{x}/{y}.png \
|
||||
// --dart-define=DM_MAP_URL_MUTED=https://tiles.doormile.in/light/{z}/{x}/{y}.png \
|
||||
// --dart-define=DM_MAP_ATTRIBUTION='© Doormile · © OpenStreetMap contributors'
|
||||
|
||||
static const _provider = String.fromEnvironment('DM_MAP_PROVIDER');
|
||||
static const _key = String.fromEnvironment('DM_MAP_KEY');
|
||||
static const _url = String.fromEnvironment('DM_MAP_URL');
|
||||
static const _urlMuted = String.fromEnvironment('DM_MAP_URL_MUTED');
|
||||
static const _subdomains = String.fromEnvironment('DM_MAP_SUBDOMAINS');
|
||||
static const _attribution = String.fromEnvironment('DM_MAP_ATTRIBUTION');
|
||||
static const _attributionUrl = String.fromEnvironment('DM_MAP_ATTRIBUTION_URL');
|
||||
static const _maxZoom = int.fromEnvironment('DM_MAP_MAX_ZOOM', defaultValue: 0);
|
||||
static const _appVersion = String.fromEnvironment(
|
||||
'DM_APP_VERSION',
|
||||
defaultValue: '1.0.0',
|
||||
);
|
||||
|
||||
factory DmMapConfig.fromEnvironment() {
|
||||
final id = _provider.isEmpty ? 'carto' : _provider;
|
||||
String? warning;
|
||||
|
||||
MapTileProvider provider;
|
||||
if (id == 'custom') {
|
||||
if (_url.isEmpty) {
|
||||
provider = MapTileProvider.carto;
|
||||
warning =
|
||||
'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to CARTO.';
|
||||
} else {
|
||||
provider = MapTileProvider(
|
||||
id: 'custom',
|
||||
name: 'Custom tile source',
|
||||
templates: {
|
||||
DmMapStyle.streets: _url,
|
||||
DmMapStyle.muted: _urlMuted.isEmpty ? _url : _urlMuted,
|
||||
},
|
||||
attribution: _attribution.isEmpty
|
||||
? '© OpenStreetMap contributors'
|
||||
: _attribution,
|
||||
attributionUrl: _attributionUrl.isEmpty
|
||||
? MapTileProvider.osmCopyright
|
||||
: _attributionUrl,
|
||||
subdomains: _subdomains.isEmpty
|
||||
? const []
|
||||
: _subdomains.split(',').map((s) => s.trim()).toList(),
|
||||
maxNativeZoom: _maxZoom == 0 ? 20 : _maxZoom,
|
||||
retina: _url.contains('{r}'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
provider = MapTileProvider.presets[id] ?? MapTileProvider.carto;
|
||||
if (!MapTileProvider.presets.containsKey(id)) {
|
||||
warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to CARTO.';
|
||||
}
|
||||
}
|
||||
|
||||
if (_key.isNotEmpty) provider = provider.withKey(_key);
|
||||
|
||||
// A keyed provider with no key would render nothing at all. Better a
|
||||
// downgraded map than a grey rectangle in a customer's hands.
|
||||
if (!provider.usable) {
|
||||
warning =
|
||||
'${provider.name} needs DM_MAP_KEY; fell back to CARTO for this build.';
|
||||
provider = MapTileProvider.carto;
|
||||
if (_key.isNotEmpty) provider = provider.withKey(_key);
|
||||
}
|
||||
|
||||
return DmMapConfig(
|
||||
provider: provider,
|
||||
userAgent: 'Doormile/$_appVersion ($packageName)',
|
||||
warning: warning,
|
||||
);
|
||||
}
|
||||
|
||||
String urlFor(DmMapStyle style) => provider.urlFor(style);
|
||||
|
||||
String get attribution => provider.attribution;
|
||||
String get attributionUrl => provider.attributionUrl;
|
||||
List<String> get subdomains => provider.subdomains;
|
||||
int get maxNativeZoom => provider.maxNativeZoom;
|
||||
bool get retina => provider.retina;
|
||||
|
||||
/// One line for a diagnostics screen or a log at startup.
|
||||
String get describe =>
|
||||
'${provider.name}${provider.hasKey ? ' (keyed)' : ' (anonymous)'}';
|
||||
}
|
||||
1227
lib/data/models.dart
Normal file
1227
lib/data/models.dart
Normal file
File diff suppressed because it is too large
Load Diff
148
lib/data/session_store.dart
Normal file
148
lib/data/session_store.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'models.dart';
|
||||
|
||||
/// One signed-in session: the two tokens, when the access token dies, and who
|
||||
/// the customer is.
|
||||
@immutable
|
||||
class Session {
|
||||
const Session({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresAt,
|
||||
this.customer,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
|
||||
/// Absolute expiry, computed from the `expiresIn` the server sent.
|
||||
final DateTime expiresAt;
|
||||
|
||||
final Customer? customer;
|
||||
|
||||
/// Treated as expired a minute early, so a call started just before the
|
||||
/// boundary does not land just after it.
|
||||
bool get isExpired =>
|
||||
DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 1)));
|
||||
|
||||
Session copyWith({
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
DateTime? expiresAt,
|
||||
Customer? customer,
|
||||
}) => Session(
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
customer: customer ?? this.customer,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'accessToken': accessToken,
|
||||
'refreshToken': refreshToken,
|
||||
'expiresAt': expiresAt.millisecondsSinceEpoch,
|
||||
if (customer != null) 'customer': customer!.toJson(),
|
||||
};
|
||||
|
||||
static Session? fromJson(Map<String, dynamic> json) {
|
||||
final access = json['accessToken'] as String?;
|
||||
final refresh = json['refreshToken'] as String?;
|
||||
final expires = json['expiresAt'];
|
||||
if (access == null || access.isEmpty) return null;
|
||||
if (refresh == null || refresh.isEmpty) return null;
|
||||
return Session(
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiresAt: expires is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(expires)
|
||||
: DateTime.now(),
|
||||
customer: json['customer'] is Map<String, dynamic>
|
||||
? Customer.fromJson(json['customer'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the session lives between launches.
|
||||
///
|
||||
/// The refresh token is valid for 60 days and **rotates on every use** — a
|
||||
/// replayed one revokes the whole chain server-side. So the two rules this
|
||||
/// class exists to enforce are: persist the newest token the moment it arrives,
|
||||
/// and never hand out a half-written session.
|
||||
abstract class SessionStore {
|
||||
Future<Session?> read();
|
||||
Future<void> write(Session session);
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
/// Keychain on iOS, EncryptedSharedPreferences on Android.
|
||||
class SecureSessionStore implements SessionStore {
|
||||
SecureSessionStore({FlutterSecureStorage? storage})
|
||||
// Keychain on iOS by default; Android needs asking, or it falls back to a
|
||||
// plaintext preferences file — which is the one place a 60-day refresh
|
||||
// token must never sit.
|
||||
: _storage =
|
||||
storage ??
|
||||
const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
static const String _key = 'dm_cx_session_v1';
|
||||
|
||||
@override
|
||||
Future<Session?> read() async {
|
||||
try {
|
||||
final raw = await _storage.read(key: _key);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
return Session.fromJson(decoded);
|
||||
} catch (e) {
|
||||
// A session we cannot read is a session we do not have. Signing the
|
||||
// customer out is recoverable; crashing at launch is not.
|
||||
debugPrint('[SESSION] unreadable, treating as signed out: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Session session) async {
|
||||
try {
|
||||
await _storage.write(key: _key, value: jsonEncode(session.toJson()));
|
||||
} catch (e) {
|
||||
// Losing persistence costs the customer a re-login next launch. It must
|
||||
// not cost them the sign-in they just completed.
|
||||
debugPrint('[SESSION] could not persist: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
await _storage.delete(key: _key);
|
||||
} catch (e) {
|
||||
debugPrint('[SESSION] could not clear: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store. Tests only — a session that does not survive the process
|
||||
/// is not a session.
|
||||
class MemorySessionStore implements SessionStore {
|
||||
Session? _session;
|
||||
|
||||
@override
|
||||
Future<Session?> read() async => _session;
|
||||
|
||||
@override
|
||||
Future<void> write(Session session) async => _session = session;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _session = null;
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/utils/snackbar_utils.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _phone = TextEditingController();
|
||||
bool _loading = false;
|
||||
|
||||
bool get _valid => _phone.text.replaceAll(' ', '').length == 10;
|
||||
|
||||
void _login() async {
|
||||
if (!_valid) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _loading = true);
|
||||
|
||||
final state = context.read<AppState>();
|
||||
try {
|
||||
await state.initiateLogin(_phone.text);
|
||||
if (!mounted) return;
|
||||
|
||||
// Backend already gates login behind the PIN check, so no separate
|
||||
// OTP possession-of-phone step is needed for returning users.
|
||||
setState(() => _loading = false);
|
||||
Navigator.pushNamed(context, Routes.enterPin);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
SnackbarUtils.showError(context, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final width = size.width;
|
||||
final height = size.height;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.primary.withValues(alpha: 0.05),
|
||||
Colors.white,
|
||||
AppColors.tertiary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: width * 0.05,
|
||||
vertical: height * 0.02,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: height * 0.08),
|
||||
const DoormileMark(size: 140),
|
||||
SizedBox(height: height * 0.04),
|
||||
Text(
|
||||
"Sign In",
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primary,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: height * 0.02),
|
||||
Text(
|
||||
"Enter your mobile number to get started.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black54,
|
||||
fontSize: 18,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: height * 0.05),
|
||||
|
||||
// Phone number field
|
||||
SizedBox(
|
||||
height: 60,
|
||||
width: width * 0.9,
|
||||
child: _PhoneField(
|
||||
controller: _phone,
|
||||
onChanged: () {
|
||||
setState(() {});
|
||||
if (_valid) {
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (!_valid && _phone.text.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: width * 0.02,
|
||||
top: height * 0.005,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Enter a valid 10-digit mobile number',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: height * 0.04),
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'By continuing, you agree to '),
|
||||
TextSpan(
|
||||
text: 'T&C',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
// Launch T&C
|
||||
},
|
||||
),
|
||||
const TextSpan(text: ' and '),
|
||||
TextSpan(
|
||||
text: 'Privacy Policy',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
// Launch Privacy Policy
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: height * 0.04),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
children: [
|
||||
const TextSpan(text: 'New to Doormile? '),
|
||||
TextSpan(
|
||||
text: 'Create account',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
Navigator.pushNamed(context, Routes.signup);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(width * 0.05),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _valid && !_loading ? _login : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _valid ? AppColors.primary : Colors.grey.shade300,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Next',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onChanged;
|
||||
const _PhoneField({required this.controller, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 65,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: Colors.grey.shade200, width: 1.5),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Simulated Flag/Prefix
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text('🇮🇳', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'+91',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 1.5,
|
||||
height: 32,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: Colors.grey.shade200,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: (_) => onChanged(),
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
inputFormatters: [_PhoneFormatter()],
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
hintText: 'Enter mobile number',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey.shade400,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats input as "XXXXX XXXXX".
|
||||
class _PhoneFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
final trimmed = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||
String out = trimmed;
|
||||
if (trimmed.length > 5) {
|
||||
out = '${trimmed.substring(0, 5)} ${trimmed.substring(5)}';
|
||||
}
|
||||
return TextEditingValue(
|
||||
text: out,
|
||||
selection: TextSelection.collapsed(offset: out.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class _Slide {
|
||||
final IconData icon;
|
||||
final Color tint;
|
||||
final String title;
|
||||
final String body;
|
||||
final bool ev;
|
||||
const _Slide(this.icon, this.tint, this.title, this.body, {this.ev = false});
|
||||
}
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
final _controller = PageController();
|
||||
int _index = 0;
|
||||
|
||||
static const _slides = [
|
||||
_Slide(Icons.touch_app_rounded, AppColors.primary, 'Send a parcel in one touch',
|
||||
'No forms, no waiting. Tap, confirm, done.'),
|
||||
_Slide(Icons.map_rounded, AppColors.secondary, 'Watch every mile, live',
|
||||
'Origin to hub to your doorstep — real-time, always.'),
|
||||
_Slide(Icons.electric_moped_rounded, AppColors.tertiary, 'EV-first, on time, every time',
|
||||
'99% on-time, powered by MileTruth AI.',
|
||||
ev: true),
|
||||
];
|
||||
|
||||
void _next() {
|
||||
if (_index < _slides.length - 1) {
|
||||
_controller.nextPage(
|
||||
duration: const Duration(milliseconds: 350), curve: Curves.easeOut);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, Routes.login);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLast = _index == _slides.length - 1;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: AppBackground(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const DoormileMark(size: 36),
|
||||
AnimatedOpacity(
|
||||
opacity: isLast ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: TextButton(
|
||||
onPressed: isLast
|
||||
? null
|
||||
: () => Navigator.pushReplacementNamed(context, Routes.login),
|
||||
child: Text('Skip',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemCount: _slides.length,
|
||||
itemBuilder: (context, i) => _SlideView(slide: _slides[i]),
|
||||
),
|
||||
),
|
||||
// Dots
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_slides.length, (i) {
|
||||
final active = i == _index;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
height: 8,
|
||||
width: active ? 24 : 8,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primary : AppColors.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 28, 16, 24),
|
||||
child: PrimaryButton(
|
||||
label: isLast ? 'Get Started' : 'Next',
|
||||
onPressed: _next,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlideView extends StatelessWidget {
|
||||
final _Slide slide;
|
||||
const _SlideView({required this.slide});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 340, maxHeight: 340),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
slide.tint.withValues(alpha: 0.15),
|
||||
slide.tint.withValues(alpha: 0.02),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(36),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: slide.tint.withValues(alpha: 0.1),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 20),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.8), width: 2),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Decorative abstract shapes
|
||||
Positioned(
|
||||
top: 20,
|
||||
left: -10,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: slide.tint.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: -30,
|
||||
bottom: -20,
|
||||
child: Icon(
|
||||
Icons.blur_on,
|
||||
size: 160,
|
||||
color: slide.tint.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
// Main Icon Glassmorphism Container
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
width: 160,
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.6)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(slide.icon, size: 80, color: slide.tint),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// EV badge
|
||||
if (slide.ev)
|
||||
Positioned(
|
||||
top: 24,
|
||||
right: 24,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.eco, color: AppColors.tertiary, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'EV-Powered',
|
||||
style: TextStyle(
|
||||
color: AppColors.tertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Text(slide.title, textAlign: TextAlign.center, style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 8),
|
||||
Text(slide.body,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sms_autofill/sms_autofill.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/utils/snackbar_utils.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
class OtpScreen extends StatefulWidget {
|
||||
const OtpScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> with CodeAutoFill {
|
||||
final List<TextEditingController> _controllers =
|
||||
List.generate(6, (_) => TextEditingController());
|
||||
final List<FocusNode> _nodes = List.generate(6, (_) => FocusNode());
|
||||
Timer? _timer;
|
||||
int _seconds = 28;
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTimer();
|
||||
listenForCode();
|
||||
_prefillDebugBypass();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _nodes[0].requestFocus());
|
||||
}
|
||||
|
||||
/// Debug-only tester shortcut, see [AppState.verifyOtp]. Compiled out of
|
||||
/// release builds via kDebugMode.
|
||||
Future<void> _prefillDebugBypass() async {
|
||||
if (!kDebugMode) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final provider = prefs.getInt('smsDefaultProvider') ?? 0;
|
||||
final passKey = prefs.getInt('smsPassKey');
|
||||
if (provider != 1 || passKey == null) return;
|
||||
if (!mounted || _controllers.any((c) => c.text.isNotEmpty)) return;
|
||||
|
||||
final passKeyStr = passKey.toString().padLeft(6, '0');
|
||||
for (int i = 0; i < 6; i++) {
|
||||
_controllers[i].text = passKeyStr[i];
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void codeUpdated() {
|
||||
if (!mounted) return;
|
||||
final digits = (code ?? '').replaceAll(RegExp(r'[^0-9]'), '');
|
||||
if (digits.length < 6) return;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
_controllers[i].text = digits[i];
|
||||
}
|
||||
setState(() {});
|
||||
_verify();
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_seconds = 28;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (_seconds == 0) {
|
||||
t.cancel();
|
||||
} else {
|
||||
setState(() => _seconds--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String get _code => _controllers.map((c) => c.text).join();
|
||||
|
||||
void _verify() async {
|
||||
if (_loading) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _loading = true);
|
||||
|
||||
await context.read<AppState>().verifyOtp(
|
||||
_code,
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
final state = context.read<AppState>();
|
||||
if (state.isResettingPin) {
|
||||
Navigator.pushReplacementNamed(context, Routes.resetPin);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, Routes.setPin);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
SnackbarUtils.showError(context, err);
|
||||
for (var c in _controllers) {
|
||||
c.clear();
|
||||
}
|
||||
_nodes[0].requestFocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _resendOtp() async {
|
||||
setState(() => _loading = true);
|
||||
await context.read<AppState>().sendOtp(
|
||||
context.read<AppState>().phone,
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_startTimer();
|
||||
SnackbarUtils.showSuccess(context, 'OTP Resent');
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
SnackbarUtils.showError(context, err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onChanged(int i, String v) {
|
||||
if (v.length > 1) {
|
||||
final chars = v.replaceAll(RegExp(r'[^0-9]'), '').split('');
|
||||
for (int j = 0; j < 6; j++) {
|
||||
_controllers[j].text = j < chars.length ? chars[j] : '';
|
||||
}
|
||||
_nodes[5].requestFocus();
|
||||
setState(() {});
|
||||
if (_code.length == 6) _verify();
|
||||
return;
|
||||
}
|
||||
if (v.isNotEmpty && i < 5) _nodes[i + 1].requestFocus();
|
||||
if (v.isEmpty && i > 0) _nodes[i - 1].requestFocus();
|
||||
setState(() {});
|
||||
if (_code.length == 6) _verify();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
cancel();
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final n in _nodes) {
|
||||
n.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final phone = context.watch<AppState>().phone;
|
||||
final filled = _code.length == 6;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
appBar: AppBar(
|
||||
leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: AppCard(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryFixed, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.sms_rounded, size: 40, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Verify your number',
|
||||
textAlign: TextAlign.center, style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 8),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
children: [
|
||||
const TextSpan(text: 'We sent a 6-digit code to '),
|
||||
TextSpan(
|
||||
text: '+91 $phone',
|
||||
style: AppTheme.labelMd,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Row(
|
||||
children: List.generate(
|
||||
6,
|
||||
(i) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
child: _OtpBox(
|
||||
controller: _controllers[i],
|
||||
node: _nodes[i],
|
||||
onChanged: (v) => _onChanged(i, v),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (_seconds > 0)
|
||||
Text('Resend code in 0:${_seconds.toString().padLeft(2, '0')}',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant))
|
||||
else
|
||||
TextButton(
|
||||
onPressed: _resendOtp,
|
||||
child: Text('Resend code now',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.primary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
label: 'Verify & Continue',
|
||||
trailingIcon: Icons.arrow_forward,
|
||||
loading: _loading,
|
||||
onPressed: filled ? _verify : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OtpBox extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode node;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _OtpBox({required this.controller, required this.node, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filled = controller.text.isNotEmpty;
|
||||
return SizedBox(
|
||||
height: 56,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: node,
|
||||
onChanged: onChanged,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
cursorColor: AppColors.primary,
|
||||
style: AppTheme.headlineMd,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
fillColor: filled ? AppColors.primaryFixed.withValues(alpha: 0.3) : AppColors.surface,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/utils/snackbar_utils.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
enum PinMode { enter, set, reset }
|
||||
|
||||
class PinScreen extends StatefulWidget {
|
||||
final PinMode mode;
|
||||
const PinScreen({super.key, required this.mode});
|
||||
|
||||
@override
|
||||
State<PinScreen> createState() => _PinScreenState();
|
||||
}
|
||||
|
||||
class _PinScreenState extends State<PinScreen> {
|
||||
final _controllers = List.generate(4, (_) => TextEditingController());
|
||||
final _nodes = List.generate(4, (_) => FocusNode());
|
||||
String _code = '';
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _nodes[0].requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void _onBackspaceOnEmpty(int i) {
|
||||
if (i > 0) {
|
||||
_controllers[i - 1].clear();
|
||||
_nodes[i - 1].requestFocus();
|
||||
_updateCode();
|
||||
}
|
||||
}
|
||||
|
||||
void _onChanged(int i, String v) {
|
||||
if (v.length > 1) {
|
||||
final chars = v.replaceAll(RegExp(r'[^0-9]'), '').split('');
|
||||
for (int j = 0; j < 4; j++) {
|
||||
_controllers[j].text = j < chars.length ? chars[j] : '';
|
||||
}
|
||||
_nodes[3].requestFocus();
|
||||
_updateCode();
|
||||
if (_code.length == 4) _submit();
|
||||
return;
|
||||
}
|
||||
if (v.isNotEmpty && i < 3) _nodes[i + 1].requestFocus();
|
||||
_updateCode();
|
||||
if (_code.length == 4) _submit();
|
||||
}
|
||||
|
||||
void _updateCode() {
|
||||
setState(() {
|
||||
_code = _controllers.map((c) => c.text).join();
|
||||
});
|
||||
}
|
||||
|
||||
void _submit() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _loading = true);
|
||||
final state = context.read<AppState>();
|
||||
|
||||
try {
|
||||
if (widget.mode == PinMode.set) {
|
||||
await state.registerWithPin(_code);
|
||||
} else if (widget.mode == PinMode.reset) {
|
||||
await state.resetPinWithBackend(_code);
|
||||
} else {
|
||||
await state.verifyPinWithBackend(_code);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pushNamedAndRemoveUntil(context, Routes.home, (r) => false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
SnackbarUtils.showError(context, e.toString());
|
||||
for (var c in _controllers) c.clear();
|
||||
_nodes[0].requestFocus();
|
||||
_updateCode();
|
||||
}
|
||||
}
|
||||
|
||||
void _forgotPin() async {
|
||||
setState(() => _loading = true);
|
||||
await context.read<AppState>().sendOtp(
|
||||
context.read<AppState>().phone,
|
||||
resetPin: true,
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
Navigator.pushNamed(context, Routes.otp);
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
SnackbarUtils.showError(context, err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String title = 'Enter PIN';
|
||||
String subtitle = 'Enter your 4-digit security PIN to log in.';
|
||||
if (widget.mode == PinMode.set) {
|
||||
title = 'Set PIN';
|
||||
subtitle = 'Create a 4-digit security PIN for your account.';
|
||||
} else if (widget.mode == PinMode.reset) {
|
||||
title = 'Set New PIN';
|
||||
subtitle = 'Create a new 4-digit security PIN.';
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topRight,
|
||||
end: Alignment.bottomLeft,
|
||||
colors: [
|
||||
AppColors.tertiary.withValues(alpha: 0.05),
|
||||
Colors.white,
|
||||
AppColors.primary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).size.height * 0.12),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.lock_outline, size: 48, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primary,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.black54,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
4,
|
||||
(i) => _PinBox(
|
||||
controller: _controllers[i],
|
||||
node: _nodes[i],
|
||||
onChanged: (v) => _onChanged(i, v),
|
||||
onBackspaceOnEmpty: () => _onBackspaceOnEmpty(i),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.mode == PinMode.enter)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const SizedBox(), // Empty space for left side
|
||||
InkWell(
|
||||
onTap: _forgotPin,
|
||||
child: const Text(
|
||||
"Forget Pin?",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primary,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_loading) ...[
|
||||
const SizedBox(height: 32),
|
||||
const CircularProgressIndicator(color: AppColors.primary),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Back Button
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 16,
|
||||
left: 16,
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.pop(context),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black12,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.black,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinBox extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode node;
|
||||
final ValueChanged<String> onChanged;
|
||||
final VoidCallback onBackspaceOnEmpty;
|
||||
|
||||
const _PinBox({
|
||||
required this.controller,
|
||||
required this.node,
|
||||
required this.onChanged,
|
||||
required this.onBackspaceOnEmpty,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filled = controller.text.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
width: 58,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: filled ? AppColors.primary : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: filled ? AppColors.primary.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: filled ? 12 : 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: filled ? AppColors.primary : Colors.grey.shade200,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey: (event) {
|
||||
if (event is RawKeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.backspace &&
|
||||
controller.text.isEmpty) {
|
||||
onBackspaceOnEmpty();
|
||||
}
|
||||
},
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: node,
|
||||
onChanged: onChanged,
|
||||
textAlign: TextAlign.center,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
obscureText: true,
|
||||
obscuringCharacter: '●',
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: filled ? Colors.white : AppColors.primary,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
counterText: "",
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/utils/snackbar_utils.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class SignupScreen extends StatefulWidget {
|
||||
const SignupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends State<SignupScreen> {
|
||||
final _name = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
bool _loading = false;
|
||||
|
||||
bool get _valid =>
|
||||
_name.text.trim().isNotEmpty &&
|
||||
_email.text.trim().isNotEmpty &&
|
||||
_phone.text.replaceAll(' ', '').length == 10;
|
||||
|
||||
void _sendOtp() async {
|
||||
if (!_valid) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _loading = true);
|
||||
|
||||
await context.read<AppState>().sendOtp(
|
||||
_phone.text,
|
||||
firstName: _name.text.trim(),
|
||||
email: _email.text.trim(),
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
Navigator.pushNamed(context, Routes.otp);
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
SnackbarUtils.showError(context, err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_email.dispose();
|
||||
_phone.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
appBar: AppBar(
|
||||
leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const DoormileMark(size: 64),
|
||||
const SizedBox(height: 32),
|
||||
Text('Create Account', style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 6),
|
||||
Text('Join Doormile for eco-friendly deliveries',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Name Field
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text('Full Name',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
_CustomTextField(
|
||||
controller: _name,
|
||||
hint: 'e.g. Rahul Sharma',
|
||||
icon: Icons.person_outline,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Email Field
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text('Email Address',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
_CustomTextField(
|
||||
controller: _email,
|
||||
hint: 'e.g. rahul@example.com',
|
||||
icon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Phone Field
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text('Mobile Number',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
_PhoneField(controller: _phone, onChanged: () => setState(() {})),
|
||||
const SizedBox(height: 36),
|
||||
|
||||
PrimaryButton(
|
||||
label: 'Send OTP',
|
||||
loading: _loading,
|
||||
onPressed: _valid ? _sendOtp : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final IconData icon;
|
||||
final TextInputType? keyboardType;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const _CustomTextField({
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
required this.icon,
|
||||
this.keyboardType,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: AppColors.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: (_) => onChanged(),
|
||||
keyboardType: keyboardType,
|
||||
style: AppTheme.bodyLg,
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
hintText: hint,
|
||||
hintStyle: AppTheme.bodyLg.copyWith(color: AppColors.outlineVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onChanged;
|
||||
const _PhoneField({required this.controller, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('+91',
|
||||
style: AppTheme.bodyMd
|
||||
.copyWith(color: AppColors.primary, fontWeight: FontWeight.w600)),
|
||||
const Icon(Icons.expand_more, size: 20, color: AppColors.onSurfaceVariant),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
color: AppColors.outlineVariant,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: (_) => onChanged(),
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
inputFormatters: [_PhoneFormatter()],
|
||||
style: AppTheme.bodyLg.copyWith(letterSpacing: 1),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
hintText: '00000 00000',
|
||||
hintStyle:
|
||||
AppTheme.bodyLg.copyWith(color: AppColors.outlineVariant, letterSpacing: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats input as "XXXXX XXXXX".
|
||||
class _PhoneFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
final trimmed = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||
String out = trimmed;
|
||||
if (trimmed.length > 5) {
|
||||
out = '${trimmed.substring(0, 5)} ${trimmed.substring(5)}';
|
||||
}
|
||||
return TextEditingValue(
|
||||
text: out,
|
||||
selection: TextSelection.collapsed(offset: out.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
|
||||
late final AnimationController _entrance =
|
||||
AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..forward();
|
||||
late final AnimationController _bar =
|
||||
AnimationController(vsync: this, duration: const Duration(seconds: 3))..repeat();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkSession();
|
||||
}
|
||||
|
||||
void _checkSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('auth_token');
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 2600));
|
||||
|
||||
if (mounted) {
|
||||
if (token != null && token.isNotEmpty) {
|
||||
Navigator.pushReplacementNamed(context, Routes.home);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, Routes.onboarding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_entrance.dispose();
|
||||
_bar.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fade = CurvedAnimation(parent: _entrance, curve: Curves.easeOutCubic);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background Image
|
||||
FadeTransition(
|
||||
opacity: fade,
|
||||
child: Image.asset(
|
||||
'assets/images/Introsplashscreen.png',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
// Loading Indicator
|
||||
Positioned(
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: FadeTransition(
|
||||
opacity: fade,
|
||||
child: const SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,871 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/utils/snackbar_utils.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_scaffold.dart';
|
||||
|
||||
class BookingPickupScreen extends StatefulWidget {
|
||||
const BookingPickupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BookingPickupScreen> createState() => _BookingPickupScreenState();
|
||||
}
|
||||
|
||||
class _BookingPickupScreenState extends State<BookingPickupScreen> {
|
||||
late final TextEditingController _pickup;
|
||||
late final TextEditingController _destPincode;
|
||||
late final TextEditingController _name;
|
||||
late final TextEditingController _mobile;
|
||||
late final TextEditingController _notes;
|
||||
late final _formKey = GlobalKey<FormState>();
|
||||
bool _loading = false;
|
||||
|
||||
String _category = '';
|
||||
String _serviceType = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final draft = context.read<AppState>().draft;
|
||||
_pickup = TextEditingController(text: draft.pickupAddress);
|
||||
_destPincode = TextEditingController(text: draft.dropPincode);
|
||||
_name = TextEditingController(text: draft.contactName);
|
||||
_mobile = TextEditingController(text: draft.contactMobile);
|
||||
_notes = TextEditingController(text: draft.notes);
|
||||
_category = draft.parcelType;
|
||||
_serviceType = draft.service;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pickup.dispose();
|
||||
_destPincode.dispose();
|
||||
_name.dispose();
|
||||
_mobile.dispose();
|
||||
_notes.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showValidationError(String title, String message) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline_rounded, size: 56, color: AppColors.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: AppTheme.headlineSm),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
child: const Text('Got it'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_pickup.text.trim().isEmpty) {
|
||||
_showValidationError('Missing Location', 'Please enter or select a pickup address where the Miler should arrive.');
|
||||
return;
|
||||
}
|
||||
if (_destPincode.text.trim().length < 6) {
|
||||
_showValidationError('Missing Pincode', 'Please enter the 6-digit receiver pincode so we can estimate the fare.');
|
||||
return;
|
||||
}
|
||||
if (_category.isEmpty) {
|
||||
_showValidationError('Missing Category', 'Please select a parcel category so we can assign the right Miler.');
|
||||
return;
|
||||
}
|
||||
if (_serviceType.isEmpty) {
|
||||
_showValidationError('Missing Service Type', 'Please select whether you need Normal or Express delivery.');
|
||||
return;
|
||||
}
|
||||
|
||||
final appState = context.read<AppState>();
|
||||
final draft = appState.draft;
|
||||
draft.pickupAddress = _pickup.text;
|
||||
draft.dropPincode = _destPincode.text.trim();
|
||||
draft.contactName = _name.text;
|
||||
draft.contactMobile = _mobile.text;
|
||||
draft.notes = _notes.text;
|
||||
draft.parcelType = _category;
|
||||
draft.service = _serviceType;
|
||||
|
||||
// Extract pincode from typed address if GPS/saved address didn't set it.
|
||||
if (draft.pickupPincode.isEmpty) {
|
||||
final match = RegExp(r'\b\d{6}\b').firstMatch(_pickup.text);
|
||||
if (match != null) draft.pickupPincode = match.group(0)!;
|
||||
}
|
||||
|
||||
if (draft.pickupPincode.isEmpty) {
|
||||
_showValidationError('Missing Pickup Pincode',
|
||||
'We couldn\'t detect a pincode for your pickup address. Please use "Use GPS current location", pick a saved address, or include the 6-digit pincode in the address.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _loading = true);
|
||||
await appState.fetchPriceEstimate();
|
||||
setState(() => _loading = false);
|
||||
|
||||
if (mounted) Navigator.pushNamed(context, Routes.bookingPreview);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
return BookingScaffold(
|
||||
title: 'Book a Pickup',
|
||||
step: 1,
|
||||
stepLabel: 'Pickup Request',
|
||||
buttonLabel: 'Review Booking',
|
||||
enabled: !_loading,
|
||||
loading: _loading,
|
||||
onContinue: _submit,
|
||||
children: [
|
||||
// Pickup location card
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Icon(Icons.location_on, size: 14, color: AppColors.success),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text('PICKUP LOCATION',
|
||||
style: AppTheme.caption.copyWith(
|
||||
letterSpacing: 1, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _pickup,
|
||||
style: AppTheme.headlineSm,
|
||||
cursorColor: AppColors.primary,
|
||||
maxLines: 2,
|
||||
minLines: 1,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: 'Enter pickup address manually',
|
||||
),
|
||||
),
|
||||
const Divider(height: 20, color: AppColors.surfaceContainer),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
throw Exception('Location permissions are denied');
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
bool? open = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Permission Denied'),
|
||||
content: const Text('Location permissions are permanently denied. Please enable them in app settings.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Settings')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (open == true) await Geolocator.openAppSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_showLocationDisabledSheet();
|
||||
return;
|
||||
}
|
||||
|
||||
Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
||||
List<Placemark> placemarks = await placemarkFromCoordinates(position.latitude, position.longitude);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
|
||||
final state = context.read<AppState>();
|
||||
if (placemarks.isNotEmpty) {
|
||||
final place = placemarks.first;
|
||||
final area = '${place.subLocality ?? place.locality}, ${place.administrativeArea}';
|
||||
final pincode = place.postalCode ?? '';
|
||||
final fullAddress = '${place.street}, $area, $pincode';
|
||||
_showLocationConfirmationSheet(context, state, area, pincode, fullAddress, position.latitude, position.longitude);
|
||||
} else {
|
||||
throw Exception('Could not determine address from coordinates.');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
SnackbarUtils.showError(context, e.toString());
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
if (_loading)
|
||||
const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary))
|
||||
else
|
||||
const Icon(Icons.my_location, size: 18, color: AppColors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(_loading ? 'Locating...' : 'Use GPS current location',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 20, color: AppColors.surfaceContainer),
|
||||
Text('SAVED ADDRESSES', style: AppTheme.caption.copyWith(letterSpacing: 1, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
child: Row(
|
||||
children: [
|
||||
_SavedAddressChip(
|
||||
icon: Icons.home_rounded,
|
||||
label: 'Home',
|
||||
address: '12th Main, Indiranagar, Bengaluru, Karnataka 560038',
|
||||
pincode: '560038',
|
||||
onSelect: (addr, pin) => setState(() {
|
||||
_pickup.text = addr;
|
||||
final state = context.read<AppState>();
|
||||
state.draft.pickupPincode = pin;
|
||||
_name.text = state.firstName;
|
||||
_mobile.text = state.phone;
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SavedAddressChip(
|
||||
icon: Icons.business_rounded,
|
||||
label: 'Office',
|
||||
address: 'Sector 4, HSR Layout, Bengaluru, Karnataka 560102',
|
||||
pincode: '560102',
|
||||
onSelect: (addr, pin) => setState(() {
|
||||
_pickup.text = addr;
|
||||
final state = context.read<AppState>();
|
||||
state.draft.pickupPincode = pin;
|
||||
_name.text = state.firstName;
|
||||
_mobile.text = state.phone;
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SavedAddressChip(
|
||||
icon: Icons.favorite_rounded,
|
||||
label: "Mom's House",
|
||||
address: 'Jayanagar 4th Block, Bengaluru, Karnataka 560011',
|
||||
pincode: '560011',
|
||||
onSelect: (addr, pin) => setState(() {
|
||||
_pickup.text = addr;
|
||||
final state = context.read<AppState>();
|
||||
state.draft.pickupPincode = pin;
|
||||
_name.text = state.firstName;
|
||||
_mobile.text = state.phone;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Destination Pincode card
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Icon(Icons.pin_drop, size: 14, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text('RECEIVER PINCODE',
|
||||
style: AppTheme.caption.copyWith(
|
||||
letterSpacing: 1, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _destPincode,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
style: AppTheme.headlineSm,
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: const InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
counterText: "",
|
||||
hintText: 'Enter 6-digit delivery pincode',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Category Card
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Category',
|
||||
style: AppTheme.caption.copyWith(
|
||||
letterSpacing: 1, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _SelectionCard(width: null, icon: Icons.category, label: 'General Goods', selected: _category == 'General Goods', onTap: () => setState(() => _category = 'General Goods'))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _SelectionCard(width: null, icon: Icons.menu_book, label: 'Books & Documents', selected: _category == 'Books & Documents', onTap: () => setState(() => _category = 'Books & Documents'))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _SelectionCard(width: null, icon: Icons.devices, label: 'Electronics & Gadgets', selected: _category == 'Electronics & Gadgets', onTap: () => setState(() => _category = 'Electronics & Gadgets'))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _SelectionCard(width: null, icon: Icons.checkroom, label: 'Clothing & Textiles', selected: _category == 'Clothing & Textiles', onTap: () => setState(() => _category = 'Clothing & Textiles'))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _SelectionCard(width: null, icon: Icons.wine_bar, label: 'Fragile Items', selected: _category == 'Fragile Items', onTap: () => setState(() => _category = 'Fragile Items'))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _SelectionCard(width: null, icon: Icons.medical_services, label: 'Medical & Pharma', selected: _category == 'Medical & Pharma', onTap: () => setState(() => _category = 'Medical & Pharma'))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _SelectionCard(width: null, icon: Icons.directions_car, label: 'Automotive Parts', selected: _category == 'Automotive Parts', onTap: () => setState(() => _category = 'Automotive Parts'))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _SelectionCard(width: null, icon: Icons.restaurant, label: 'Food & Perishables', selected: _category == 'Food & Perishables', onTap: () => setState(() => _category = 'Food & Perishables'))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Type Card
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Service Type',
|
||||
style: AppTheme.caption.copyWith(
|
||||
letterSpacing: 1, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ServiceCard(
|
||||
title: 'Normal',
|
||||
subtitle: 'Standard delivery',
|
||||
icon: Icons.local_shipping_outlined,
|
||||
selected: _serviceType == 'Normal',
|
||||
onTap: () => setState(() => _serviceType = 'Normal'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _ServiceCard(
|
||||
title: 'Express',
|
||||
subtitle: 'Lightning fast',
|
||||
icon: Icons.bolt,
|
||||
selected: _serviceType == 'Express',
|
||||
onTap: () => setState(() => _serviceType = 'Express'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Contact Details Card
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Contact Person (Optional)',
|
||||
style: AppTheme.caption.copyWith(
|
||||
letterSpacing: 1, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _name,
|
||||
style: AppTheme.bodyLg,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Name (e.g. Arun)',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _mobile,
|
||||
keyboardType: TextInputType.phone,
|
||||
style: AppTheme.bodyLg,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Mobile Number',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Notes Card
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Pickup Notes (Optional)',
|
||||
style: AppTheme.caption.copyWith(
|
||||
letterSpacing: 1, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _notes,
|
||||
maxLines: 3,
|
||||
style: AppTheme.bodyLg,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Call before coming, Shop closes at 8 PM, etc.',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
Text('RECENT & SAVED',
|
||||
style: AppTheme.caption
|
||||
.copyWith(letterSpacing: 1.2, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
...state.savedAddresses.map((a) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: AppCard(
|
||||
onTap: () {
|
||||
final draft = context.read<AppState>().draft;
|
||||
draft.pickupPincode = a.pincode;
|
||||
draft.pickupLatitude = a.latitude;
|
||||
draft.pickupLongitude = a.longitude;
|
||||
setState(() => _pickup.text = '${a.label}, ${a.line}');
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
IconBadge(
|
||||
icon: a.icon, bg: a.iconBg, color: a.iconColor, circle: true),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(a.label, style: AppTheme.labelMd),
|
||||
const SizedBox(height: 2),
|
||||
Text(a.line, style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
const EcoBanner(
|
||||
text: 'Miler will collect parcel details, weight, and pricing upon arrival.'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showLocationConfirmationSheet(BuildContext context, AppState state, String area, String pincode, String fullAddress, double latitude, double longitude) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 120,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
image: const DecorationImage(
|
||||
image: AssetImage('assets/images/map_placeholder.png'), // Will failover smoothly if missing
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: const Center(
|
||||
child: IconBadge(icon: Icons.location_on, bg: AppColors.primaryContainer, color: AppColors.primary, circle: true),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('GPS Location Found', style: AppTheme.headlineSm),
|
||||
const SizedBox(height: 8),
|
||||
Text(area, style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Pincode: $pincode', style: AppTheme.caption),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
label: 'Confirm',
|
||||
onPressed: () {
|
||||
state.draft.pickupPincode = pincode;
|
||||
state.draft.pickupLatitude = latitude;
|
||||
state.draft.pickupLongitude = longitude;
|
||||
setState(() {
|
||||
_pickup.text = fullAddress;
|
||||
_name.text = state.firstName;
|
||||
_mobile.text = state.phone;
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLocationDisabledSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.location_off_rounded, size: 64, color: AppColors.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"Location is turned off",
|
||||
style: AppTheme.headlineSm,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
"Please turn on location to accurately detect your pickup address.",
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
label: 'Turn On',
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
await Geolocator.openLocationSettings();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SavedAddressChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String address;
|
||||
final String pincode;
|
||||
final Function(String address, String pincode) onSelect;
|
||||
|
||||
const _SavedAddressChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.address,
|
||||
required this.pincode,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => onSelect(address, pincode),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.surfaceContainerHigh),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: AppColors.surface,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppColors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(label, style: AppTheme.labelMd),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final double? width;
|
||||
|
||||
const _SelectionCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.width = 85,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: width,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.primaryFixed : AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.primary : AppColors.surfaceContainerHigh,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
boxShadow: selected
|
||||
? [BoxShadow(color: AppColors.primary.withValues(alpha: 0.15), blurRadius: 12, offset: const Offset(0, 4))]
|
||||
: [],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: selected ? AppColors.primary : AppColors.onSurfaceVariant, size: 24),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
style: AppTheme.labelMd.copyWith(
|
||||
color: selected ? AppColors.primary : AppColors.onSurfaceVariant,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServiceCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ServiceCard({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: selected
|
||||
? const LinearGradient(
|
||||
colors: [AppColors.primaryContainer, AppColors.primary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
)
|
||||
: null,
|
||||
color: selected ? null : AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.primary : AppColors.surfaceContainerHigh,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
boxShadow: selected
|
||||
? [BoxShadow(color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 6))]
|
||||
: [],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.white.withValues(alpha: 0.2) : AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: selected ? Colors.white : AppColors.onSurfaceVariant, size: 24),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: AppTheme.headlineSm.copyWith(
|
||||
color: selected ? Colors.white : AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: selected ? Colors.white.withValues(alpha: 0.8) : AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/utils/snackbar_utils.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_scaffold.dart';
|
||||
import 'package:doormile/features/booking/screens/payment_sheet.dart';
|
||||
|
||||
class BookingPreviewScreen extends StatefulWidget {
|
||||
const BookingPreviewScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BookingPreviewScreen> createState() => _BookingPreviewScreenState();
|
||||
}
|
||||
|
||||
class _BookingPreviewScreenState extends State<BookingPreviewScreen> {
|
||||
bool _loading = false;
|
||||
|
||||
void _confirmOrder() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await context.read<AppState>().placeOrder();
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
Navigator.pushNamedAndRemoveUntil(context, Routes.bookingSuccess, ModalRoute.withName(Routes.home));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
SnackbarUtils.showError(context, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
final draft = state.draft;
|
||||
|
||||
return BookingScaffold(
|
||||
title: 'Review Booking',
|
||||
step: 4,
|
||||
stepLabel: 'Step 4: Review',
|
||||
buttonLabel: 'Confirm booking',
|
||||
loading: _loading,
|
||||
enabled: !_loading,
|
||||
onContinue: _confirmOrder,
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
StatusChip(
|
||||
label: 'Step 4 of 4',
|
||||
color: AppColors.primary,
|
||||
bg: AppColors.primaryFixed.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text('Review Booking', style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 4),
|
||||
Text('Please verify your delivery details before confirming payment.',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Pickup location
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.gps_fixed, size: 18, color: AppColors.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Pickup Location', style: AppTheme.labelMd)),
|
||||
SecondaryButton(
|
||||
label: 'Change',
|
||||
onPressed: () => Navigator.popUntil(
|
||||
context, (r) => r.settings.name == Routes.bookingPickup),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(draft.pickupAddress,
|
||||
style: AppTheme.headlineSm.copyWith(fontWeight: FontWeight.w700)),
|
||||
Text(draft.dropPincode.isNotEmpty
|
||||
? 'Drop details will be collected by Miler at Pincode: ${draft.dropPincode}'
|
||||
: 'Drop details will be collected by Miler',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MiniCard(
|
||||
icon: Icons.inventory_2,
|
||||
title: 'Category',
|
||||
value: draft.parcelType,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MiniCard(
|
||||
icon: Icons.speed,
|
||||
title: 'Service',
|
||||
value: draft.service,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
border: Border.all(color: AppColors.outlineVariant),
|
||||
child: Row(
|
||||
children: [
|
||||
const IconBadge(
|
||||
icon: Icons.local_shipping,
|
||||
bg: AppColors.primary,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Doormile Collection',
|
||||
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
Text('Assigned optimal vehicle size', style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(
|
||||
label: 'EV First',
|
||||
icon: Icons.eco,
|
||||
color: AppColors.tertiary,
|
||||
bg: AppColors.tertiaryFixed.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Fare summary
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Estimated Price Range', style: AppTheme.headlineSm),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text('Final price determined at pickup',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('₹${draft.minPrice.toInt()} - ₹${draft.maxPrice.toInt()}',
|
||||
style: AppTheme.headlineMd.copyWith(color: AppColors.primary)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Text('Taxes and fees are included in the total price.',
|
||||
style: AppTheme.caption),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fareRow(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(label,
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(value, style: AppTheme.labelMd),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _MiniCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String value;
|
||||
final String? subtitle;
|
||||
final List<String>? chips;
|
||||
const _MiniCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.subtitle,
|
||||
this.chips,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconBadge(
|
||||
icon: icon,
|
||||
bg: AppColors.surfaceContainer,
|
||||
color: AppColors.primary,
|
||||
size: 34,
|
||||
iconSize: 18,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(title, style: AppTheme.caption),
|
||||
Text(value, style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!, style: AppTheme.caption.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
if (chips != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: chips!
|
||||
.map((c) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: StatusChip(
|
||||
label: c,
|
||||
color: AppColors.secondary,
|
||||
bg: AppColors.secondaryFixed,
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
/// Shared layout for the multi-step booking flow: app bar, step progress,
|
||||
/// scrollable body, and a sticky bottom action button.
|
||||
class BookingScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final int step; // 1..4
|
||||
final String stepLabel;
|
||||
final String buttonLabel;
|
||||
final VoidCallback onContinue;
|
||||
final List<Widget> children;
|
||||
final Widget? bottomExtra;
|
||||
final bool enabled;
|
||||
final bool loading;
|
||||
|
||||
const BookingScaffold({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.step,
|
||||
required this.stepLabel,
|
||||
required this.buttonLabel,
|
||||
required this.onContinue,
|
||||
required this.children,
|
||||
this.bottomExtra,
|
||||
this.enabled = true,
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: AppBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.primaryContainer, AppColors.primary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -20,
|
||||
top: 10,
|
||||
child: Transform.rotate(
|
||||
angle: 0.1,
|
||||
child: Icon(Icons.add_location_alt_rounded, size: 140, color: Colors.white.withValues(alpha: 0.04)),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 10, 16, 18),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTheme.headlineMd.copyWith(color: Colors.white)),
|
||||
),
|
||||
const Icon(Icons.location_on_outlined, color: Colors.white),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
padding: const EdgeInsets.all(14),
|
||||
color: Colors.white.withValues(alpha: 0.94),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(stepLabel,
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('Step $step',
|
||||
style: AppTheme.caption.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
JourneyProgressBar(value: 1, gradient: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 24),
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest.withValues(alpha: 0.95),
|
||||
border: const Border(top: BorderSide(color: AppColors.surfaceContainer)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (bottomExtra != null) ...[bottomExtra!, const SizedBox(height: 10)],
|
||||
PrimaryButton(
|
||||
label: buttonLabel,
|
||||
loading: loading,
|
||||
trailingIcon: Icons.arrow_forward,
|
||||
height: 54,
|
||||
onPressed: enabled ? onContinue : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
class BookingSuccessScreen extends StatefulWidget {
|
||||
const BookingSuccessScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BookingSuccessScreen> createState() => _BookingSuccessScreenState();
|
||||
}
|
||||
|
||||
class _BookingSuccessScreenState extends State<BookingSuccessScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c =
|
||||
AnimationController(vsync: this, duration: const Duration(milliseconds: 600))..forward();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final order = context.read<AppState>().orders.first;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
ScaleTransition(
|
||||
scale: CurvedAnimation(parent: _c, curve: Curves.elasticOut),
|
||||
child: Container(
|
||||
width: 110,
|
||||
height: 110,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check_circle,
|
||||
color: AppColors.success, size: 72),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Text('Booking Confirmed!', style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Your EV pickup is scheduled. We’ll notify you when the rider is on the way.',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
AppCard(
|
||||
child: Column(
|
||||
children: [
|
||||
_row('Order ID', order.id),
|
||||
const Divider(height: 22, color: AppColors.surfaceContainer),
|
||||
_row('Estimated Fare', '₹${order.amount.toStringAsFixed(0)}'),
|
||||
const Divider(height: 22, color: AppColors.surfaceContainer),
|
||||
_row('Pickup Status', 'Waiting for Miler assignment'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const EcoBanner(
|
||||
text: 'This delivery is 100% electric — offsetting ~1.2kg of CO₂.'),
|
||||
const Spacer(),
|
||||
PrimaryButton(
|
||||
label: 'Track Live',
|
||||
trailingIcon: Icons.navigation,
|
||||
onPressed: () => Navigator.pushNamedAndRemoveUntil(
|
||||
context,
|
||||
Routes.liveTracking,
|
||||
(r) => r.settings.name == Routes.home,
|
||||
arguments: order,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.popUntil(
|
||||
context, (r) => r.settings.name == Routes.home),
|
||||
child: Text('Back to Home',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String k, String v) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(k, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(v,
|
||||
textAlign: TextAlign.right,
|
||||
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_scaffold.dart';
|
||||
|
||||
class ParcelDetailsScreen extends StatefulWidget {
|
||||
const ParcelDetailsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ParcelDetailsScreen> createState() => _ParcelDetailsScreenState();
|
||||
}
|
||||
|
||||
class _ParcelDetailsScreenState extends State<ParcelDetailsScreen> {
|
||||
final _types = const [
|
||||
('Documents', Icons.description),
|
||||
('Electronics', Icons.devices),
|
||||
('Food', Icons.restaurant),
|
||||
('Clothing', Icons.checkroom),
|
||||
('Fragile', Icons.auto_awesome),
|
||||
('Other', Icons.more_horiz),
|
||||
];
|
||||
final _weights = const ['0–1 kg', '1–5 kg', '5–10 kg', '10 kg+'];
|
||||
final _sizes = const [
|
||||
('Small', 'Fits in a backpack (e.g. Phone, Keys)'),
|
||||
('Medium', 'Fits in a courier box (e.g. Laptop, Shoes)'),
|
||||
('Large', 'Requires vehicle trunk (e.g. Monitor, Luggage)'),
|
||||
];
|
||||
final _days = const ['Today', 'Tomorrow'];
|
||||
final _slots = const ['10:00 AM - 12:00 PM', '12:00 PM - 02:00 PM', '02:00 PM - 04:00 PM'];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final draft = context.watch<AppState>().draft;
|
||||
return BookingScaffold(
|
||||
title: 'Parcel Details',
|
||||
step: 2,
|
||||
stepLabel: 'Step 2: Details',
|
||||
buttonLabel: 'Continue',
|
||||
onContinue: () => Navigator.pushNamed(context, Routes.servicePrice),
|
||||
children: [
|
||||
Text('What are you sending?', style: AppTheme.headlineSm),
|
||||
const SizedBox(height: 14),
|
||||
GridView.count(
|
||||
crossAxisCount: 3,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.05,
|
||||
children: _types.map((t) {
|
||||
final active = draft.parcelType == t.$1;
|
||||
return _SelectTile(
|
||||
active: active,
|
||||
onTap: () {
|
||||
draft.parcelType = t.$1;
|
||||
context.read<AppState>().touch();
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(t.$2, color: active ? Colors.white : AppColors.primary, size: 26),
|
||||
const SizedBox(height: 8),
|
||||
Text(t.$1,
|
||||
style: AppTheme.labelMd.copyWith(
|
||||
color: active ? Colors.white : AppColors.onSurface)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Approximate Weight', style: AppTheme.headlineSm),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _weights
|
||||
.map((w) => _Pill(
|
||||
label: w,
|
||||
active: draft.weight == w,
|
||||
onTap: () {
|
||||
draft.weight = w;
|
||||
context.read<AppState>().touch();
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Package Size', style: AppTheme.headlineSm),
|
||||
const SizedBox(height: 12),
|
||||
..._sizes.map((s) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _RadioCard(
|
||||
active: draft.size == s.$1,
|
||||
onTap: () {
|
||||
draft.size = s.$1;
|
||||
context.read<AppState>().touch();
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
IconBadge(
|
||||
icon: Icons.inventory_2,
|
||||
bg: AppColors.primaryContainer.withValues(alpha: 0.1),
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(s.$1,
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(s.$2, style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 12),
|
||||
Text('Pickup Slot', style: AppTheme.headlineSm),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: _days
|
||||
.map((d) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _Pill(
|
||||
label: d,
|
||||
active: draft.pickupDay == d,
|
||||
onTap: () {
|
||||
draft.pickupDay = d;
|
||||
context.read<AppState>().touch();
|
||||
},
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._slots.map((slot) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _RadioCard(
|
||||
active: draft.pickupSlot == slot,
|
||||
onTap: () {
|
||||
draft.pickupSlot = slot;
|
||||
context.read<AppState>().touch();
|
||||
},
|
||||
child: Text(slot,
|
||||
style: AppTheme.bodyMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
)),
|
||||
const SizedBox(height: 16),
|
||||
// Fragile toggle
|
||||
AppCard(
|
||||
border: Border.all(color: AppColors.outlineVariant),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: AppColors.warning),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Mark as Fragile',
|
||||
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
Text('Handle with extra care', style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: draft.fragile,
|
||||
activeTrackColor: AppColors.primary,
|
||||
onChanged: (v) {
|
||||
draft.fragile = v;
|
||||
context.read<AppState>().touch();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Additional Notes (Optional)',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
maxLines: 3,
|
||||
cursorColor: AppColors.primary,
|
||||
onChanged: (v) => draft.notes = v,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Gate code, specific handling instructions, etc.',
|
||||
hintStyle: AppTheme.bodyMd.copyWith(color: AppColors.outline),
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceContainerLowest,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiaryFixedDim.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.tertiary.withValues(alpha: 0.1)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const IconBadge(
|
||||
icon: Icons.electric_rickshaw,
|
||||
bg: Color(0x1A005251),
|
||||
color: AppColors.tertiary,
|
||||
size: 40,
|
||||
iconSize: 20,
|
||||
circle: true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('EV-First & 3-Wheeler Collection',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.tertiary, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'A Doormile agent arrives in a 3-wheeler during your slot. Delivered by EV to cut carbon footprint.',
|
||||
style: AppTheme.caption
|
||||
.copyWith(color: AppColors.onTertiaryFixedVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shared selection widgets ----
|
||||
|
||||
class _SelectTile extends StatelessWidget {
|
||||
final bool active;
|
||||
final Widget child;
|
||||
final VoidCallback onTap;
|
||||
const _SelectTile({required this.active, required this.child, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primary : AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: active ? AppColors.primary : AppColors.outlineVariant),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
final String label;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
const _Pill({required this.label, required this.active, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primary : AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(label,
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: active ? Colors.white : AppColors.onSurface)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RadioCard extends StatelessWidget {
|
||||
final bool active;
|
||||
final Widget child;
|
||||
final VoidCallback onTap;
|
||||
const _RadioCard({required this.active, required this.child, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? const Color(0xFFF8E0E3) : AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: active ? AppColors.primary : AppColors.outlineVariant,
|
||||
width: active ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: child),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: active ? AppColors.primary : AppColors.outline, width: 2),
|
||||
),
|
||||
child: active
|
||||
? const Center(
|
||||
child: CircleAvatar(radius: 5, backgroundColor: AppColors.primary))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/utils/snackbar_utils.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
Future<void> showPaymentSheet(BuildContext context, double amount) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (_) => _PaymentSheet(amount: amount),
|
||||
);
|
||||
}
|
||||
|
||||
class _Method {
|
||||
final String name;
|
||||
final String? sub;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
const _Method(this.name, this.icon, this.iconColor, {this.sub});
|
||||
}
|
||||
|
||||
class _PaymentSheet extends StatefulWidget {
|
||||
final double amount;
|
||||
const _PaymentSheet({required this.amount});
|
||||
|
||||
@override
|
||||
State<_PaymentSheet> createState() => _PaymentSheetState();
|
||||
}
|
||||
|
||||
class _PaymentSheetState extends State<_PaymentSheet> {
|
||||
final _methods = const [
|
||||
_Method('UPI (GPay, PhonePe, Paytm)', Icons.account_balance, AppColors.secondary),
|
||||
_Method('Credit / Debit Cards', Icons.credit_card, AppColors.onSurface),
|
||||
_Method('Net banking', Icons.account_balance_outlined, AppColors.onSurface),
|
||||
_Method('Cash on Delivery', Icons.payments_outlined, AppColors.onSurface),
|
||||
];
|
||||
int _selected = 0;
|
||||
bool _processing = false;
|
||||
|
||||
void _pay() async {
|
||||
setState(() => _processing = true);
|
||||
final state = context.read<AppState>();
|
||||
state.draft.paymentMethod = _methods[_selected].name;
|
||||
await Future.delayed(const Duration(milliseconds: 1400));
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await state.placeOrder();
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context); // close sheet
|
||||
Navigator.pushNamedAndRemoveUntil(
|
||||
context,
|
||||
Routes.bookingSuccess,
|
||||
(r) => r.settings.name == Routes.home,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _processing = false);
|
||||
SnackbarUtils.showError(context, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(99)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Pay ₹${widget.amount.toStringAsFixed(0)}', style: AppTheme.headlineLg),
|
||||
Text('Order ID: 293810',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
...List.generate(_methods.length, (i) {
|
||||
final m = _methods[i];
|
||||
final active = i == _selected;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 6),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _selected = i),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.surfaceContainer : AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: active ? AppColors.primary : AppColors.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(m.icon, color: m.iconColor, size: 22),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(m.name,
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(fontWeight: FontWeight.w700)),
|
||||
if (m.sub != null)
|
||||
Text(m.sub!,
|
||||
style: AppTheme.caption.copyWith(color: AppColors.primary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: active ? AppColors.primary : AppColors.outline,
|
||||
width: 2),
|
||||
color: active ? AppColors.primary : Colors.transparent,
|
||||
),
|
||||
child: active
|
||||
? const Icon(Icons.circle, size: 8, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: PrimaryButton(
|
||||
label: 'Pay ₹${widget.amount.toStringAsFixed(0)}',
|
||||
loading: _processing,
|
||||
height: 54,
|
||||
onPressed: _pay,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_scaffold.dart';
|
||||
|
||||
class ServicePriceScreen extends StatelessWidget {
|
||||
const ServicePriceScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
final draft = state.draft;
|
||||
return BookingScaffold(
|
||||
title: 'Service & Price',
|
||||
step: 3,
|
||||
stepLabel: 'Step 3: Choose Service',
|
||||
buttonLabel: 'Confirm & Continue',
|
||||
onContinue: () => Navigator.pushNamed(context, Routes.bookingPreview),
|
||||
children: [
|
||||
Text('CHOOSE SERVICE',
|
||||
style: AppTheme.caption
|
||||
.copyWith(letterSpacing: 1.2, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 12),
|
||||
_ServiceCard(
|
||||
active: draft.service == 'Standard',
|
||||
title: 'Standard',
|
||||
badge: 'EV-FIRST',
|
||||
subtitle: 'Today by 6:00 PM',
|
||||
note: '3-Wheeler Collection included',
|
||||
price: '₹${draft.minPrice.toStringAsFixed(0)}',
|
||||
icon: Icons.eco,
|
||||
iconColor: AppColors.tertiary,
|
||||
onTap: () {
|
||||
draft.service = 'Standard';
|
||||
state.touch();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ServiceCard(
|
||||
active: draft.service == 'Express',
|
||||
title: 'Express',
|
||||
subtitle: 'Arrival in 2 hrs',
|
||||
price: '₹${draft.maxPrice.toStringAsFixed(0)}',
|
||||
icon: Icons.bolt,
|
||||
iconColor: AppColors.secondary,
|
||||
onTap: () {
|
||||
draft.service = 'Express';
|
||||
state.touch();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Selected slot
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_month, color: AppColors.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('SELECTED SLOT',
|
||||
style: AppTheme.caption
|
||||
.copyWith(letterSpacing: 1, fontWeight: FontWeight.w600)),
|
||||
Text('Pickup: ${draft.pickupDay}, ${draft.pickupSlot}',
|
||||
style: AppTheme.labelMd),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Fare breakdown
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('ESTIMATED FARE',
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 14),
|
||||
_fareRow('Estimated Range', '₹${draft.minPrice.toStringAsFixed(0)} - ₹${draft.maxPrice.toStringAsFixed(0)}'),
|
||||
const Divider(height: 24, color: AppColors.surfaceContainerHigh),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Final price determined at pickup', style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Pay-by row
|
||||
AppCard(
|
||||
onTap: () {},
|
||||
child: Row(
|
||||
children: [
|
||||
const IconBadge(
|
||||
icon: Icons.account_balance_wallet,
|
||||
bg: AppColors.surfaceContainer,
|
||||
color: AppColors.primary,
|
||||
size: 36,
|
||||
iconSize: 18,
|
||||
circle: true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text('Pay by: UPI', style: AppTheme.labelMd)),
|
||||
const Icon(Icons.expand_more, color: AppColors.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, size: 16, color: AppColors.tertiary),
|
||||
SizedBox(width: 6),
|
||||
Text('Secured by Doormile Pay Protection',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: AppColors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fareRow(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
Text(value, style: AppTheme.labelMd),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ServiceCard extends StatelessWidget {
|
||||
final bool active;
|
||||
final String title;
|
||||
final String? badge;
|
||||
final String subtitle;
|
||||
final String? note;
|
||||
final String price;
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ServiceCard({
|
||||
required this.active,
|
||||
required this.title,
|
||||
this.badge,
|
||||
required this.subtitle,
|
||||
this.note,
|
||||
required this.price,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? const Color(0xFFFBE3E6) : AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: active ? AppColors.primary : AppColors.outlineVariant,
|
||||
width: active ? 2 : 1,
|
||||
),
|
||||
boxShadow: active ? null : AppTheme.cardShadow,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: iconColor),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(title,
|
||||
style: AppTheme.headlineSm, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
StatusChip(
|
||||
label: badge!,
|
||||
color: AppColors.tertiary,
|
||||
bg: AppColors.tertiaryFixed.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
if (note != null)
|
||||
Text(note!,
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: AppColors.tertiary, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (active)
|
||||
const CircleAvatar(
|
||||
radius: 11,
|
||||
backgroundColor: AppColors.primary,
|
||||
child: Icon(Icons.check, size: 14, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(price,
|
||||
style: AppTheme.headlineSm.copyWith(color: AppColors.primary)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/models/models.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<AppState>().fetchOrders();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
final active = state.orders.where((o) => o.status == OrderStatus.active).firstOrNull;
|
||||
|
||||
return AppBackground(
|
||||
child: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_hero(context, state),
|
||||
const SizedBox(height: 26),
|
||||
active != null ? _activeShipment(context, active) : _emptyShipment(context),
|
||||
const SizedBox(height: 18),
|
||||
_ecoBanner(),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).padding.top,
|
||||
color: AppColors.primaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _hero(BuildContext context, AppState state) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
height: 196,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.primaryContainer, AppColors.primary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)),
|
||||
child: AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: -20,
|
||||
top: 20,
|
||||
child: Transform.rotate(
|
||||
angle: -0.1,
|
||||
child: Icon(Icons.local_shipping_rounded, size: 150, color: Colors.white.withValues(alpha: 0.04)),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 40,
|
||||
bottom: -30,
|
||||
child: Transform.rotate(
|
||||
angle: 0.15,
|
||||
child: Icon(Icons.route_rounded, size: 100, color: Colors.white.withValues(alpha: 0.03)),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const DoormileMark(size: 48, color: Colors.white, glyphColor: AppColors.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.location_on_rounded, color: Colors.white70, size: 15),
|
||||
const SizedBox(width: 4),
|
||||
Text('Bengaluru', style: AppTheme.labelMd.copyWith(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Hi, ${state.firstName.isEmpty ? 'Admin' : state.firstName}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTheme.headlineMd.copyWith(color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
'Let deliveries move with less friction',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTheme.caption.copyWith(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: const Icon(Icons.notifications_none_rounded, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 130,
|
||||
child: AppCard(
|
||||
padding: const EdgeInsets.all(18),
|
||||
radius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Send a parcel', style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Fast pickup, live tracking, EV-first delivery.',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
final draft = context.read<AppState>().draft;
|
||||
draft.pickupAddress = 'Current Location (GPS)';
|
||||
Navigator.pushNamed(context, Routes.bookingPickup);
|
||||
},
|
||||
icon: const Icon(Icons.arrow_forward_rounded, size: 18),
|
||||
label: const Text('Book Now'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
height: 90,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Icon(Icons.local_shipping_rounded, size: 68, color: AppColors.primary),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: IconBadge(
|
||||
icon: Icons.eco_rounded,
|
||||
bg: AppColors.tertiaryContainer,
|
||||
color: AppColors.tertiary,
|
||||
size: 28,
|
||||
iconSize: 16,
|
||||
circle: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 310),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _activeShipment(BuildContext context, Order active) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Live Shipment', style: AppTheme.headlineSm.copyWith(fontWeight: FontWeight.w800)),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pushNamed(context, Routes.myOrders),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text('View Details', style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pushNamed(context, Routes.liveTracking, arguments: active),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.primary, AppColors.secondary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 24, offset: const Offset(0, 10)),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(2), // Gradient border width
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryFixed,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(Icons.inventory_2_rounded, color: AppColors.primary, size: 28),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text('PENDING PICKUP', style: AppTheme.caption.copyWith(color: AppColors.primary, fontWeight: FontWeight.w800, letterSpacing: 1.2)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(active.id, style: AppTheme.headlineSm),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: AppColors.outlineVariant),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Live', style: TextStyle(color: AppColors.onSurface, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward_ios_rounded, size: 10, color: AppColors.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.surfaceContainerHigh),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Column(
|
||||
children: [
|
||||
Icon(Icons.my_location_rounded, size: 20, color: AppColors.primary),
|
||||
SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: VerticalDivider(color: AppColors.outlineVariant, thickness: 2),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Icon(Icons.pin_drop_rounded, size: 20, color: AppColors.tertiary),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Pickup Location', style: AppTheme.caption),
|
||||
Text(active.fromLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTheme.labelMd),
|
||||
const SizedBox(height: 20),
|
||||
Text('Destination', style: AppTheme.caption),
|
||||
Text(active.toLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTheme.labelMd),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _trackNode(String title, String subtitle, bool active) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: active ? AppColors.secondary : AppColors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyShipment(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SectionHeader(title: 'Active Shipment'),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 18),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.primary.withValues(alpha: 0.1), AppColors.primary.withValues(alpha: 0.25)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 54,
|
||||
height: 54,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.inbox_rounded,
|
||||
size: 28,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text('No active shipments', style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'You do not have any deliveries in progress right now.',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.caption,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SecondaryButton(
|
||||
label: 'Book a Shipment',
|
||||
onPressed: () {
|
||||
final draft = context.read<AppState>().draft;
|
||||
draft.pickupAddress = 'Current Location (GPS)';
|
||||
Navigator.pushNamed(context, Routes.bookingPickup);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ecoBanner() {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: EcoBanner(text: 'This delivery offsets 1.2kg of CO2 emissions.'),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/features/dashboard/screens/home_screen.dart';
|
||||
import 'package:doormile/features/tracking/screens/my_orders_screen.dart';
|
||||
import 'package:doormile/features/dashboard/screens/summary_screen.dart';
|
||||
import 'package:doormile/features/dashboard/screens/profile_screen.dart';
|
||||
|
||||
/// Root scaffold hosting the 5-tab bottom navigation.
|
||||
class MainShell extends StatefulWidget {
|
||||
final int initialIndex;
|
||||
const MainShell({super.key, this.initialIndex = 0});
|
||||
|
||||
@override
|
||||
State<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
late int _index = widget.initialIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_requestPermissions();
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
final status = await Permission.locationWhenInUse.status;
|
||||
if (!status.isGranted) {
|
||||
await Permission.locationWhenInUse.request();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Location permission error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Tab indices: 0 Home, 1 Track, 3 Wallet, 4 Profile. 2 = Book (action).
|
||||
final _pages = const [
|
||||
HomeScreen(),
|
||||
MyOrdersScreen(embedded: true),
|
||||
SizedBox.shrink(), // Book slot — never shown (navigates away)
|
||||
SummaryScreen(),
|
||||
ProfileScreen(),
|
||||
];
|
||||
|
||||
void _onTap(int i) {
|
||||
if (i == 2) {
|
||||
Navigator.pushNamed(context, Routes.bookingPickup);
|
||||
return;
|
||||
}
|
||||
setState(() => _index = i);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: IndexedStack(index: _index, children: _pages),
|
||||
extendBody: true,
|
||||
bottomNavigationBar: _BottomBar(index: _index, onTap: _onTap),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomBar extends StatelessWidget {
|
||||
final int index;
|
||||
final ValueChanged<int> onTap;
|
||||
const _BottomBar({required this.index, required this.onTap});
|
||||
|
||||
static const _items = [
|
||||
(_NavItem(Icons.home_rounded, Icons.home_outlined, 'Home')),
|
||||
(_NavItem(Icons.map_rounded, Icons.map_outlined, 'Track')),
|
||||
(_NavItem(Icons.add_circle, Icons.add_circle, 'Book')),
|
||||
(_NavItem(Icons.bar_chart_rounded, Icons.bar_chart_outlined, 'Summary')),
|
||||
(_NavItem(Icons.person_rounded, Icons.person_outline, 'Profile')),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: Container(
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest.withValues(alpha: 0.96),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: Colors.white),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: List.generate(_items.length, (i) {
|
||||
final item = _items[i];
|
||||
final active = i == index;
|
||||
if (i == 2) {
|
||||
// Centre "Book" action button.
|
||||
return _BookButton(onTap: () => onTap(2));
|
||||
}
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => onTap(i),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primaryFixed : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Icon(active ? item.active : item.inactive,
|
||||
color: active ? AppColors.primary : AppColors.onSurfaceVariant,
|
||||
size: 22),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(item.label,
|
||||
maxLines: 1,
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: active ? AppColors.primary : AppColors.onSurfaceVariant,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BookButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _BookButton({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.primaryContainer, AppColors.primary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.28),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 28),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text('Book',
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: AppColors.primary, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem {
|
||||
final IconData active;
|
||||
final IconData inactive;
|
||||
final String label;
|
||||
const _NavItem(this.active, this.inactive, this.label);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class ProfileScreen extends StatelessWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
return AppBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
const ImmersiveHeader(
|
||||
title: 'My Profile',
|
||||
subtitle: 'Addresses, support, and preferences',
|
||||
icon: Icons.person_rounded,
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildProfileAvatar(context, state),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ProfileStatCard(
|
||||
value: '${state.orders.length * 4 + 2}',
|
||||
label: 'Parcels',
|
||||
icon: Icons.inventory_2_rounded,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _ProfileStatCard(
|
||||
value: '4.9',
|
||||
label: 'Rating',
|
||||
icon: Icons.star_rounded,
|
||||
color: Colors.amber,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_tile(context, Icons.location_on_outlined, 'My addresses',
|
||||
onTap: () => Navigator.pushNamed(context, Routes.addresses)),
|
||||
_tile(context, Icons.notifications_outlined, 'Notifications', dot: true),
|
||||
_tile(context, Icons.card_giftcard_outlined, 'Refer & earn'),
|
||||
_tile(context, Icons.language_outlined, 'Language', sub: 'English'),
|
||||
_tile(context, Icons.help_outline, 'Help & support',
|
||||
onTap: () => Navigator.pushNamed(context, Routes.help)),
|
||||
const SizedBox(height: 4),
|
||||
AppCard(
|
||||
onTap: () => _confirmLogout(context, state),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.logout, color: AppColors.primary),
|
||||
const SizedBox(width: 16),
|
||||
Text('Logout',
|
||||
style: AppTheme.labelMd.copyWith(
|
||||
color: AppColors.primary, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Text('Doormile Customers V1.0.0', style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileAvatar(BuildContext context, AppState state) {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
color: Colors.white,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 10)],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: Image.asset(
|
||||
'assets/images/profileicon.png',
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Icon(Icons.person, size: 50, color: Color(0xFFC60018)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: const BoxDecoration(color: Color(0xFFC60018), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.edit, size: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(state.userName.isEmpty ? 'Admin User' : state.userName, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black87)),
|
||||
Text('+91 ${state.phone.isEmpty ? '98765 43210' : state.phone}', style: const TextStyle(fontSize: 14, color: Colors.black54)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stat(String value, String label, Color color) {
|
||||
return const SizedBox.shrink(); // Replaced by _ProfileStatCard
|
||||
}
|
||||
|
||||
Widget _tile(BuildContext context, IconData icon, String title,
|
||||
{String? sub, bool dot = false, VoidCallback? onTap}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: AppCard(
|
||||
onTap: onTap ?? () {},
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: AppColors.primary, size: 20),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: AppTheme.labelMd),
|
||||
if (sub != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(sub, style: AppTheme.caption.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
if (dot)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error, shape: BoxShape.circle),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded, color: AppColors.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmLogout(BuildContext context, AppState state) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: Text('Log out?', style: AppTheme.headlineSm),
|
||||
content: Text('You can sign back in anytime with your number.',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('Cancel',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
state.logout();
|
||||
Navigator.pop(context);
|
||||
Navigator.pushNamedAndRemoveUntil(context, Routes.login, (r) => false);
|
||||
},
|
||||
child: Text('Logout', style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileStatCard extends StatelessWidget {
|
||||
final String value;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _ProfileStatCard({required this.value, required this.label, required this.icon, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.surfaceContainerHigh, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 10, offset: const Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(label, style: AppTheme.caption.copyWith(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: AppTheme.headlineMd.copyWith(color: AppColors.onSurface)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
class SavedAddressesScreen extends StatefulWidget {
|
||||
const SavedAddressesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SavedAddressesScreen> createState() => _SavedAddressesScreenState();
|
||||
}
|
||||
|
||||
class _SavedAddressesScreenState extends State<SavedAddressesScreen> {
|
||||
final List<Map<String, dynamic>> _addresses = [
|
||||
{
|
||||
'title': 'Home',
|
||||
'address': '12th Main, Indiranagar, Bengaluru, Karnataka 560038',
|
||||
'icon': Icons.home_rounded,
|
||||
},
|
||||
{
|
||||
'title': 'Office',
|
||||
'address': 'Sector 4, HSR Layout, Bengaluru, Karnataka 560102',
|
||||
'icon': Icons.business_rounded,
|
||||
},
|
||||
{
|
||||
'title': "Mom's House",
|
||||
'address': 'Jayanagar 4th Block, Bengaluru, Karnataka 560011',
|
||||
'icon': Icons.favorite_rounded,
|
||||
},
|
||||
];
|
||||
|
||||
void _showAddAddressSheet() {
|
||||
final titleCtrl = TextEditingController();
|
||||
final addressCtrl = TextEditingController();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Add New Address', style: AppTheme.headlineSm),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('LABEL (e.g. Gym)', style: AppTheme.caption.copyWith(letterSpacing: 1)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: titleCtrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter label...',
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceContainerLowest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.surfaceContainer),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.surfaceContainer),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('FULL ADDRESS', style: AppTheme.caption.copyWith(letterSpacing: 1)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: addressCtrl,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter complete address...',
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceContainerLowest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.surfaceContainer),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: AppColors.surfaceContainer),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
label: 'Save Address',
|
||||
onPressed: () {
|
||||
if (titleCtrl.text.trim().isNotEmpty && addressCtrl.text.trim().isNotEmpty) {
|
||||
setState(() {
|
||||
_addresses.add({
|
||||
'title': titleCtrl.text.trim(),
|
||||
'address': addressCtrl.text.trim(),
|
||||
'icon': Icons.location_on_rounded, // default icon
|
||||
});
|
||||
});
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: AppBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
const ImmersiveHeader(
|
||||
title: 'Saved Addresses',
|
||||
subtitle: 'Manage your pickup & drop-off locations',
|
||||
icon: Icons.location_on_rounded,
|
||||
showBack: true,
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(20),
|
||||
itemCount: _addresses.length + 1,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 16),
|
||||
itemBuilder: (context, i) {
|
||||
if (i == _addresses.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: PrimaryButton(
|
||||
label: 'Add New Address',
|
||||
trailingIcon: Icons.add_rounded,
|
||||
onPressed: _showAddAddressSheet,
|
||||
),
|
||||
);
|
||||
}
|
||||
final item = _addresses[i];
|
||||
return _AddressCard(
|
||||
title: item['title'],
|
||||
address: item['address'],
|
||||
icon: item['icon'],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String address;
|
||||
final IconData icon;
|
||||
|
||||
const _AddressCard({required this.title, required this.address, required this.icon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: AppColors.primary, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title, style: AppTheme.labelMd),
|
||||
const Icon(Icons.more_horiz_rounded, color: AppColors.onSurfaceVariant, size: 20),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(address, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
class SummaryScreen extends StatelessWidget {
|
||||
const SummaryScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
|
||||
// Dynamically calculate stats from real orders
|
||||
final totalDeliveries = state.orders.length;
|
||||
final totalSpent = state.orders.fold<double>(0, (sum, order) => sum + order.amount);
|
||||
|
||||
return AppBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
const ImmersiveHeader(
|
||||
title: 'Summary',
|
||||
subtitle: 'Your delivery and eco-impact stats',
|
||||
icon: Icons.bar_chart_rounded,
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 100),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SectionHeader(title: 'Overview', action: 'This Month', onAction: () {}),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_StatCard(
|
||||
title: 'Total Deliveries',
|
||||
value: '$totalDeliveries',
|
||||
icon: Icons.inventory_2_rounded,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_StatCard(
|
||||
title: 'Total Spent',
|
||||
value: '₹${totalSpent.toStringAsFixed(0)}',
|
||||
icon: Icons.payments_rounded,
|
||||
color: AppColors.tertiary,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
SectionHeader(title: 'Top Locations', action: 'Manage', onAction: () => Navigator.pushNamed(context, '/addresses')),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
onTap: () => Navigator.pushNamed(context, '/addresses'),
|
||||
padding: const EdgeInsets.all(0),
|
||||
child: Column(
|
||||
children: [
|
||||
_LocationRow(title: 'Home', subtitle: 'Indiranagar, Bengaluru', isFirst: true),
|
||||
const Divider(height: 1, color: AppColors.surfaceContainer),
|
||||
_LocationRow(title: 'Office', subtitle: 'HSR Layout, Bengaluru', isFirst: false),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _StatCard({required this.title, required this.value, required this.icon, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.surfaceContainerHigh, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 10, offset: const Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(title, style: AppTheme.caption.copyWith(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: AppTheme.headlineMd.copyWith(color: AppColors.onSurface)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class _LocationRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final bool isFirst;
|
||||
const _LocationRow({required this.title, required this.subtitle, required this.isFirst});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, isFirst ? 16 : 12, 16, isFirst ? 12 : 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.surfaceContainerHigh),
|
||||
),
|
||||
child: const Icon(Icons.business_rounded, size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text(subtitle, style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded, color: AppColors.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
|
||||
enum _ConnStatus { connecting, connected, disconnected }
|
||||
|
||||
class _ChatMessage {
|
||||
final String text;
|
||||
final bool fromCustomer;
|
||||
_ChatMessage(this.text, {required this.fromCustomer});
|
||||
}
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final String riderName;
|
||||
final String bookingId;
|
||||
final String? authToken;
|
||||
|
||||
const ChatScreen({
|
||||
super.key,
|
||||
required this.riderName,
|
||||
required this.bookingId,
|
||||
this.authToken,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final List<_ChatMessage> _messages = [];
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
_ConnStatus _status = _ConnStatus.connecting;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_connectWebSocket();
|
||||
}
|
||||
|
||||
void _connectWebSocket() {
|
||||
if (widget.bookingId.isEmpty) {
|
||||
setState(() => _status = _ConnStatus.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _status = _ConnStatus.connecting);
|
||||
|
||||
try {
|
||||
String wsUrl = 'ws://api.doormile.com/ws/bookings/${widget.bookingId}/chat?role=customer';
|
||||
if (widget.authToken != null && widget.authToken!.isNotEmpty) {
|
||||
wsUrl += '&token=${widget.authToken}';
|
||||
}
|
||||
|
||||
_channel = WebSocketChannel.connect(
|
||||
Uri.parse(wsUrl),
|
||||
);
|
||||
|
||||
// Trigger connected status on first successful frame after connect
|
||||
_channel!.ready.then((_) {
|
||||
if (mounted) setState(() => _status = _ConnStatus.connected);
|
||||
}).catchError((e) {
|
||||
debugPrint('WS ready error: $e');
|
||||
if (mounted) setState(() => _status = _ConnStatus.disconnected);
|
||||
});
|
||||
|
||||
_channel!.stream.listen(
|
||||
_onMessage,
|
||||
onError: (e) {
|
||||
debugPrint('WS error: $e');
|
||||
if (mounted) setState(() => _status = _ConnStatus.disconnected);
|
||||
},
|
||||
onDone: () {
|
||||
if (mounted) setState(() => _status = _ConnStatus.disconnected);
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('WS connect error: $e');
|
||||
if (mounted) setState(() => _status = _ConnStatus.disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
void _onMessage(dynamic raw) {
|
||||
try {
|
||||
final data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
final text = data['text'] as String? ?? '';
|
||||
final sender = data['sender'] as String? ?? '';
|
||||
if (text.isEmpty) return;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_messages.add(_ChatMessage(text, fromCustomer: sender == 'customer'));
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('WS parse error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _messageController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final payload = jsonEncode({'text': text, 'sender': 'customer'});
|
||||
|
||||
if (_channel != null && _status == _ConnStatus.connected) {
|
||||
_channel!.sink.add(payload);
|
||||
}
|
||||
|
||||
// Optimistically show the message regardless of WS state
|
||||
setState(() {
|
||||
_messages.add(_ChatMessage(text, fromCustomer: true));
|
||||
_messageController.clear();
|
||||
});
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
_scrollController.dispose();
|
||||
_channel?.sink.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.riderName,
|
||||
style: AppTheme.headlineSm.copyWith(color: Colors.white, fontSize: 18)),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: switch (_status) {
|
||||
_ConnStatus.connected => const Color(0xFF4CAF50),
|
||||
_ConnStatus.connecting => const Color(0xFFFF9800),
|
||||
_ConnStatus.disconnected => const Color(0xFFEF5350),
|
||||
},
|
||||
),
|
||||
),
|
||||
Text(
|
||||
switch (_status) {
|
||||
_ConnStatus.connected => 'Connected',
|
||||
_ConnStatus.connecting => 'Connecting...',
|
||||
_ConnStatus.disconnected => 'Disconnected',
|
||||
},
|
||||
style: AppTheme.caption.copyWith(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (_status == _ConnStatus.disconnected)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
tooltip: 'Reconnect',
|
||||
onPressed: () {
|
||||
_channel?.sink.close();
|
||||
_connectWebSocket();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _messages.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_status == _ConnStatus.connecting
|
||||
? 'Connecting to chat...'
|
||||
: 'No messages yet. Say hi!',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = _messages[index];
|
||||
return Align(
|
||||
alignment: msg.fromCustomer
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: msg.fromCustomer
|
||||
? AppColors.primary
|
||||
: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16).copyWith(
|
||||
bottomLeft: msg.fromCustomer
|
||||
? const Radius.circular(16)
|
||||
: Radius.zero,
|
||||
bottomRight: msg.fromCustomer
|
||||
? Radius.zero
|
||||
: const Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
msg.text,
|
||||
style: AppTheme.bodyMd.copyWith(
|
||||
color: msg.fromCustomer
|
||||
? Colors.white
|
||||
: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: AppColors.surfaceContainer)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message ${widget.riderName}...',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceContainerLowest,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InkWell(
|
||||
onTap: _sendMessage,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.send_rounded, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/shared/models/models.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
class HelpSupportScreen extends StatefulWidget {
|
||||
const HelpSupportScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HelpSupportScreen> createState() => _HelpSupportScreenState();
|
||||
}
|
||||
|
||||
class _HelpSupportScreenState extends State<HelpSupportScreen> {
|
||||
final _faqs = const [
|
||||
FaqItem('How do I track?',
|
||||
'Track your delivery in real-time through the "Track" tab in the bottom navigation. Our EV fleet provides precision GPS updates every 10 seconds.'),
|
||||
FaqItem('Price calculation?',
|
||||
'Fare = base fare + distance charge + any demand/weather surge. EV sustainability discounts are applied automatically at checkout.'),
|
||||
FaqItem('Cancellation?',
|
||||
'You can cancel free of charge before a rider is assigned. After assignment, a small fee may apply to cover the EV trip.'),
|
||||
];
|
||||
|
||||
final List<ChatMessage> _messages = [
|
||||
const ChatMessage("Hi there! I'm Doormile Assistant. How can I help you today with your EV delivery?"),
|
||||
const ChatMessage('I need to update my delivery address for order #DM-9921.', fromUser: true),
|
||||
const ChatMessage('Sure! Our system shows your EV driver is still 10 mins away from pickup. I can update that for you now.'),
|
||||
];
|
||||
|
||||
final _input = TextEditingController();
|
||||
int? _openFaq = 0;
|
||||
|
||||
void _send() {
|
||||
final text = _input.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
setState(() {
|
||||
_messages.add(ChatMessage(text, fromUser: true));
|
||||
_input.clear();
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 700), () {
|
||||
if (!mounted) return;
|
||||
setState(() => _messages.add(const ChatMessage(
|
||||
'Thanks! A Doormile specialist will follow up shortly. Is there anything else?')));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_input.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
appBar: AppBar(
|
||||
leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)),
|
||||
title: Text('Help & Support',
|
||||
style: AppTheme.headlineMd.copyWith(color: AppColors.primary)),
|
||||
actions: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.help_outline, color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
// Search
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, color: AppColors.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search for help...',
|
||||
hintStyle: AppTheme.bodyMd.copyWith(color: AppColors.outline),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text('Frequently Asked Questions',
|
||||
style: AppTheme.headlineSm, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('View all',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...List.generate(_faqs.length, (i) {
|
||||
final open = _openFaq == i;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: AppCard(
|
||||
onTap: () => setState(() => _openFaq = open ? null : i),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(_faqs[i].question,
|
||||
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
Icon(open ? Icons.expand_less : Icons.expand_more,
|
||||
color: AppColors.onSurfaceVariant),
|
||||
],
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
crossFadeState:
|
||||
open ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(_faqs[i].answer,
|
||||
style: AppTheme.bodyMd
|
||||
.copyWith(color: AppColors.onSurfaceVariant, height: 1.4)),
|
||||
),
|
||||
secondChild: const SizedBox(width: double.infinity),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
Text('Recent Support', style: AppTheme.headlineSm),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
..._messages.map(_bubble),
|
||||
const SizedBox(height: 8),
|
||||
_composer(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Chat with us banner
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
child: const Icon(Icons.chat, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Chat with us',
|
||||
style: AppTheme.labelMd.copyWith(
|
||||
color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
Text('Get instant support from our team',
|
||||
style: AppTheme.caption
|
||||
.copyWith(color: Colors.white.withValues(alpha: 0.9))),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Colors.white),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bubble(ChatMessage m) {
|
||||
return Align(
|
||||
alignment: m.fromUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(maxWidth: 260),
|
||||
decoration: BoxDecoration(
|
||||
color: m.fromUser ? AppColors.primary : AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(m.text,
|
||||
style: AppTheme.bodyMd.copyWith(
|
||||
color: m.fromUser ? Colors.white : AppColors.onSurface, height: 1.35)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _composer() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(left: 14, right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _input,
|
||||
cursorColor: AppColors.primary,
|
||||
onSubmitted: (_) => _send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
hintStyle: AppTheme.bodyMd.copyWith(color: AppColors.outline),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _send,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.send, color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import 'package:doormile/shared/models/models.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/utils/snackbar_utils.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
class LiveTrackingScreen extends StatefulWidget {
|
||||
final Order? order;
|
||||
const LiveTrackingScreen({super.key, this.order});
|
||||
|
||||
@override
|
||||
State<LiveTrackingScreen> createState() => _LiveTrackingScreenState();
|
||||
}
|
||||
|
||||
class _LiveTrackingScreenState extends State<LiveTrackingScreen> {
|
||||
static const _defaultCenter = LatLng(11.02, 76.95);
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
GoogleMapController? _mapController;
|
||||
|
||||
LatLng _milerPosition = _defaultCenter;
|
||||
Set<Marker> _markers = {};
|
||||
int _etaSeconds = 0;
|
||||
String _milerName = 'Assigning Miler...';
|
||||
String _status = 'In transit';
|
||||
Timer? _etaTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_connectWebSocket();
|
||||
}
|
||||
|
||||
void _connectWebSocket() {
|
||||
final bookingId = widget.order?.id;
|
||||
if (bookingId == null || bookingId.isEmpty) return;
|
||||
try {
|
||||
_channel = WebSocketChannel.connect(
|
||||
Uri.parse('ws://api.doormile.com/ws/bookings/$bookingId/track'),
|
||||
);
|
||||
_channel!.stream.listen(
|
||||
_onMessage,
|
||||
onError: (e) => debugPrint('WS error: $e'),
|
||||
onDone: () => debugPrint('WS closed'),
|
||||
cancelOnError: false,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('WS connect error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onMessage(dynamic raw) {
|
||||
final Map<String, dynamic> data;
|
||||
try {
|
||||
data = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
debugPrint('WS parse error: $e');
|
||||
return;
|
||||
}
|
||||
|
||||
final lat = (data['lat'] as num?)?.toDouble();
|
||||
final lon = (data['lon'] as num?)?.toDouble();
|
||||
final etaMinutes = (data['eta_minutes'] as num?)?.toInt();
|
||||
final milerName = data['miler_name'] as String?;
|
||||
final status = data['status'] as String?;
|
||||
bool positionChanged = false;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
if (milerName != null && milerName.isNotEmpty) {
|
||||
_milerName = milerName;
|
||||
}
|
||||
if (status != null && status.isNotEmpty) {
|
||||
_status = status;
|
||||
}
|
||||
if (lat != null && lon != null && (lat != 0.0 || lon != 0.0)) {
|
||||
_milerPosition = LatLng(lat, lon);
|
||||
positionChanged = true;
|
||||
_markers = {
|
||||
Marker(
|
||||
markerId: const MarkerId('miler'),
|
||||
position: _milerPosition,
|
||||
infoWindow: InfoWindow(title: _milerName),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (etaMinutes != null) {
|
||||
_etaSeconds = etaMinutes * 60;
|
||||
}
|
||||
});
|
||||
|
||||
if (etaMinutes != null) _restartEtaTimer();
|
||||
|
||||
if (positionChanged) {
|
||||
_mapController?.animateCamera(
|
||||
CameraUpdate.newLatLng(_milerPosition),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _restartEtaTimer() {
|
||||
_etaTimer?.cancel();
|
||||
if (_etaSeconds <= 0) return;
|
||||
_etaTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_etaSeconds > 0) _etaSeconds--;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String get _etaLabel {
|
||||
if (_etaSeconds > 0) {
|
||||
return '${(_etaSeconds / 60).ceil()} min';
|
||||
}
|
||||
return widget.order?.eta ?? '—';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_etaTimer?.cancel();
|
||||
_channel?.sink.close();
|
||||
_mapController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = widget.order?.id ?? 'DM-CHN-BLR-7741';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GoogleMap(
|
||||
initialCameraPosition: const CameraPosition(
|
||||
target: _defaultCenter,
|
||||
zoom: 14,
|
||||
),
|
||||
markers: _markers,
|
||||
onMapCreated: (controller) => _mapController = controller,
|
||||
myLocationButtonEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
_circleBtn(Icons.arrow_back, () => Navigator.pop(context)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text('Live Tracking',
|
||||
style: AppTheme.headlineSm.copyWith(color: AppColors.primary),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
StatusChip(
|
||||
label: 'EV Priority',
|
||||
icon: Icons.eco,
|
||||
color: AppColors.tertiary,
|
||||
bg: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Color(0x1A000000), blurRadius: 24, offset: Offset(0, -6)),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('PARCEL ID',
|
||||
style: AppTheme.caption.copyWith(letterSpacing: 1)),
|
||||
Text(id,
|
||||
style: AppTheme.headlineSm
|
||||
.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(
|
||||
label: _status,
|
||||
color: AppColors.tertiary,
|
||||
bg: AppColors.tertiaryFixed.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('ETA $_etaLabel',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
const SizedBox(height: 16),
|
||||
const _MileStepper(activeIndex: 2),
|
||||
const SizedBox(height: 16),
|
||||
_riderCard(context),
|
||||
const SizedBox(height: 12),
|
||||
const EcoBanner(
|
||||
text: 'Eco-Friendly Journey · saving 1.2kg of CO₂ on this delivery.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _circleBtn(IconData icon, VoidCallback onTap) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
shape: const CircleBorder(),
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox(
|
||||
width: 40, height: 40, child: Icon(icon, color: AppColors.primary, size: 22)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _riderCard(BuildContext context) {
|
||||
return AppCard(
|
||||
color: AppColors.surfaceContainerLow,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppColors.primaryFixed,
|
||||
child: Icon(Icons.person, color: AppColors.primary, size: 28),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_milerName,
|
||||
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
Row(
|
||||
children: [
|
||||
StatusChip(
|
||||
label: 'EV',
|
||||
icon: Icons.bolt,
|
||||
color: AppColors.tertiary,
|
||||
bg: AppColors.tertiaryFixed.withValues(alpha: 0.5)),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text('KA-01-EV-4412',
|
||||
style: AppTheme.caption, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text('★ 4.9',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.warning)),
|
||||
Text('4821 trips', style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final uri = Uri(scheme: 'tel', path: '+919876543210');
|
||||
try {
|
||||
await launchUrl(uri);
|
||||
} catch (e) {
|
||||
if (context.mounted) _toast(context, 'Could not launch dialer.');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.call, size: 18),
|
||||
label: const Text('Call Rider'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.primary),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => Navigator.pushNamed(
|
||||
context,
|
||||
'/chat',
|
||||
arguments: {
|
||||
'milerName': _milerName,
|
||||
'bookingId': widget.order?.id ?? '',
|
||||
},
|
||||
),
|
||||
icon: const Icon(Icons.chat_bubble_outline, size: 18),
|
||||
label: const Text('Chat'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toast(BuildContext context, String msg) {
|
||||
SnackbarUtils.showError(context, msg);
|
||||
}
|
||||
}
|
||||
|
||||
class _MileStepper extends StatelessWidget {
|
||||
final int activeIndex;
|
||||
const _MileStepper({required this.activeIndex});
|
||||
|
||||
static const _stops = [
|
||||
('Booked', AppColors.primary),
|
||||
('First Mile', AppColors.firstMile),
|
||||
('Mid Mile', AppColors.midMile),
|
||||
('Last Mile', AppColors.lastMile),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: List.generate(_stops.length, (i) {
|
||||
final done = i <= activeIndex;
|
||||
final stop = _stops[i];
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 3,
|
||||
color: i == 0
|
||||
? Colors.transparent
|
||||
: (i <= activeIndex ? stop.$2 : AppColors.surfaceContainerHigh),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: done ? stop.$2 : AppColors.surfaceContainerHigh,
|
||||
border: Border.all(
|
||||
color: done ? stop.$2 : AppColors.outlineVariant, width: 2),
|
||||
),
|
||||
child: done
|
||||
? const Icon(Icons.check, size: 8, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 3,
|
||||
color: i == _stops.length - 1
|
||||
? Colors.transparent
|
||||
: (i < activeIndex
|
||||
? _stops[i + 1].$2
|
||||
: AppColors.surfaceContainerHigh),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(stop.$1,
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: done ? stop.$2 : AppColors.onSurfaceVariant,
|
||||
fontWeight: i == activeIndex ? FontWeight.w700 : FontWeight.w500,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/models/models.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
class MyOrdersScreen extends StatefulWidget {
|
||||
/// When embedded in the bottom-nav shell we hide the back button.
|
||||
final bool embedded;
|
||||
const MyOrdersScreen({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
State<MyOrdersScreen> createState() => _MyOrdersScreenState();
|
||||
}
|
||||
|
||||
class _MyOrdersScreenState extends State<MyOrdersScreen> {
|
||||
final _filters = const ['All', 'Active', 'Delivered', 'Cancelled'];
|
||||
int _filter = 0;
|
||||
Timer? _pollingTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<AppState>().fetchOrders();
|
||||
});
|
||||
|
||||
// Start polling every 10 seconds for live status updates
|
||||
_pollingTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
||||
if (mounted) {
|
||||
context.read<AppState>().fetchOrders();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollingTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
final orders = state.orders;
|
||||
final filtered = switch (_filter) {
|
||||
1 => orders.where((o) => o.status == OrderStatus.active).toList(),
|
||||
2 => orders.where((o) => o.status == OrderStatus.delivered).toList(),
|
||||
3 => orders.where((o) => o.status == OrderStatus.cancelled).toList(),
|
||||
_ => orders,
|
||||
};
|
||||
|
||||
return AppBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
ImmersiveHeader(
|
||||
title: 'My Parcels',
|
||||
subtitle: 'Track all your deliveries',
|
||||
icon: Icons.inventory_2_rounded,
|
||||
showBack: !widget.embedded,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _filters.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final active = i == _filter;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _filter = i),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primary : AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(_filters[i],
|
||||
style: AppTheme.labelMd.copyWith(
|
||||
color: active ? Colors.white : AppColors.onSurfaceVariant)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: state.isLoadingOrders
|
||||
? const Center(child: CircularProgressIndicator(color: AppColors.primary))
|
||||
: filtered.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.primary.withValues(alpha: 0.1), AppColors.primary.withValues(alpha: 0.25)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.15),
|
||||
blurRadius: 24,
|
||||
spreadRadius: 4,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.inventory_2_rounded,
|
||||
size: 34,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('No parcels found',
|
||||
style: AppTheme.headlineSm.copyWith(color: AppColors.onSurface, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 8),
|
||||
Text('You have no active bookings in this category.',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
const SizedBox(height: 48), // Padding to lift it slightly above true center
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 14),
|
||||
itemBuilder: (context, i) => _OrderCard(order: filtered[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OrderCard extends StatelessWidget {
|
||||
final Order order;
|
||||
const _OrderCard({required this.order});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = order.status == OrderStatus.active;
|
||||
return AppCard(
|
||||
onTap: () => Navigator.pushNamed(
|
||||
context,
|
||||
active ? Routes.liveTracking : Routes.shipmentJourney,
|
||||
arguments: order,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Order ID', style: AppTheme.caption),
|
||||
Text('#${order.id}',
|
||||
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(
|
||||
label: order.status.label, color: order.status.color, bg: order.status.bg),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// Route mini timeline
|
||||
_routeRow(Icons.radio_button_unchecked, order.fromLabel, order.fromSub,
|
||||
AppColors.primary, dashed: true),
|
||||
_routeRow(Icons.location_on, order.toLabel, order.toSub, AppColors.onSurfaceVariant),
|
||||
if (active) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text('Journey Progress',
|
||||
style: AppTheme.caption.copyWith(color: AppColors.primary),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${(order.progress * 3).toStringAsFixed(1)} / 3.0 Miles',
|
||||
style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
JourneyProgressBar(value: order.progress),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
if (order.ecoRoute) ...[
|
||||
const Icon(Icons.eco, size: 14, color: AppColors.tertiary),
|
||||
const SizedBox(width: 4),
|
||||
Text('CO₂ saved: ${order.co2Saved} (EV)',
|
||||
style: AppTheme.caption.copyWith(color: AppColors.tertiary)),
|
||||
],
|
||||
],
|
||||
),
|
||||
const Divider(height: 22, color: AppColors.surfaceContainer),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(order.date,
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
Text('₹${order.amount.toStringAsFixed(2)}',
|
||||
style: AppTheme.headlineSm.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _routeRow(IconData icon, String title, String sub, Color color,
|
||||
{bool dashed = false}) {
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
if (dashed)
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 1.5,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
color: AppColors.outlineVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: dashed ? 10 : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
Text(sub, style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/shared/models/models.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class ShipmentJourneyScreen extends StatelessWidget {
|
||||
final Order? order;
|
||||
const ShipmentJourneyScreen({super.key, this.order});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final steps = const [
|
||||
JourneyStep(
|
||||
title: 'First Mile',
|
||||
subtitle: 'Picked up from Guindy Hub, Chennai\nAssigned to EV-Fleet #TN01-EV-4421',
|
||||
time: '7 Jun, 10:45 AM',
|
||||
icon: Icons.inventory_2,
|
||||
color: AppColors.firstMile,
|
||||
done: true,
|
||||
),
|
||||
JourneyStep(
|
||||
title: 'Mid Mile · IN TRANSIT',
|
||||
subtitle:
|
||||
'Departed Chennai Regional Hub\nEnroute to Nelamangala Distribution Center',
|
||||
time: 'Active Now',
|
||||
icon: Icons.local_shipping,
|
||||
color: AppColors.midMile,
|
||||
active: true,
|
||||
note:
|
||||
'AI REROUTE · Avoided AH45 congestion near Krishnagiri. Diverted to optimized arterial route.',
|
||||
),
|
||||
JourneyStep(
|
||||
title: 'Last Mile',
|
||||
subtitle: 'Out for Delivery\nTo: Indiranagar, Bengaluru Hub',
|
||||
time: 'Expected 9 Jun',
|
||||
icon: Icons.home_filled,
|
||||
color: AppColors.lastMile,
|
||||
),
|
||||
];
|
||||
|
||||
final logs = const [
|
||||
ActivityLog('Security Check Complete', 'Chennai Regional Center · 08 Jun 14:12',
|
||||
Icons.verified_user_outlined),
|
||||
ActivityLog('Sorted for Destination', 'Chennai Regional Center · 08 Jun 11:30',
|
||||
Icons.alt_route),
|
||||
ActivityLog('Arrived at Sorting Hub', 'Chennai Hub A · 07 Jun 18:45',
|
||||
Icons.warehouse_outlined),
|
||||
ActivityLog('Shipment Created', 'Electronic City Office · 07 Jun 09:15',
|
||||
Icons.add_box_outlined),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
appBar: AppBar(
|
||||
leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)),
|
||||
title: const DoormileWordmark(markSize: 32, fontSize: 22),
|
||||
actions: const [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: Icon(Icons.location_on_outlined, color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
children: [
|
||||
// Summary card
|
||||
AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Shipment Contents', style: AppTheme.caption),
|
||||
Text('Documents & electronics · 3.2 kg',
|
||||
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
StatusChip(
|
||||
label: 'EV EXPRESS',
|
||||
icon: Icons.eco,
|
||||
color: AppColors.tertiary,
|
||||
bg: AppColors.tertiaryFixed.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_city('MAA', 'Chennai'),
|
||||
const Expanded(
|
||||
child: Icon(Icons.bolt, color: AppColors.primary),
|
||||
),
|
||||
_city('BLR', 'Bengaluru', end: true),
|
||||
],
|
||||
),
|
||||
const Divider(height: 28, color: AppColors.surfaceContainer),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, size: 18, color: AppColors.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Estimated Arrival', style: AppTheme.caption),
|
||||
Text('9 Jun 2:30 PM',
|
||||
style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: 'Track Live',
|
||||
height: 44,
|
||||
onPressed: () => Navigator.pushNamed(context, '/tracking/live',
|
||||
arguments: order),
|
||||
).constrainedTo(120),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// AI insight banner
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiaryFixed.withValues(alpha: 0.35),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.tertiary.withValues(alpha: 0.15)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.psychology_alt, color: AppColors.tertiary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text('MileTruth AI Insight · Rerouted via Hosur bypass — ETA protected despite traffic.',
|
||||
style: AppTheme.bodyMd
|
||||
.copyWith(color: AppColors.tertiary, height: 1.35)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Journey Timeline', style: AppTheme.headlineMd),
|
||||
const SizedBox(height: 12),
|
||||
...List.generate(steps.length, (i) =>
|
||||
_TimelineTile(step: steps[i], isLast: i == steps.length - 1)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Activity Log', style: AppTheme.headlineMd),
|
||||
Text('All Updates',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AppCard(
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < logs.length; i++) ...[
|
||||
Row(
|
||||
children: [
|
||||
IconBadge(
|
||||
icon: logs[i].icon,
|
||||
bg: AppColors.surfaceContainer,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
size: 38,
|
||||
iconSize: 18,
|
||||
circle: true,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(logs[i].title,
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(fontWeight: FontWeight.w700)),
|
||||
Text(logs[i].subtitle, style: AppTheme.caption),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (i < logs.length - 1)
|
||||
const Divider(height: 22, color: AppColors.surfaceContainer),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _city(String code, String name, {bool end = false}) {
|
||||
return Column(
|
||||
crossAxisAlignment: end ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(code, style: AppTheme.headlineMd.copyWith(color: AppColors.primary)),
|
||||
Text(name, style: AppTheme.caption),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineTile extends StatelessWidget {
|
||||
final JourneyStep step;
|
||||
final bool isLast;
|
||||
const _TimelineTile({required this.step, required this.isLast});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: step.done || step.active
|
||||
? step.color.withValues(alpha: 0.15)
|
||||
: AppColors.surfaceContainer,
|
||||
shape: BoxShape.circle,
|
||||
border: step.active
|
||||
? Border.all(color: step.color, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: Icon(step.icon,
|
||||
color: step.done || step.active ? step.color : AppColors.outline,
|
||||
size: 20),
|
||||
),
|
||||
if (!isLast)
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 2.5,
|
||||
color: step.done ? step.color : AppColors.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(step.title,
|
||||
style: AppTheme.labelMd.copyWith(
|
||||
color: step.color, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
Text(step.time,
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: step.active
|
||||
? AppColors.tertiary
|
||||
: AppColors.onSurfaceVariant,
|
||||
fontWeight:
|
||||
step.active ? FontWeight.w700 : FontWeight.w400)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(step.subtitle,
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
if (step.note != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: AppColors.warning.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded,
|
||||
size: 16, color: AppColors.warning),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(step.note!,
|
||||
style: AppTheme.caption.copyWith(
|
||||
color: AppColors.warning, height: 1.4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on Widget {
|
||||
Widget constrainedTo(double width) =>
|
||||
ConstrainedBox(constraints: BoxConstraints(maxWidth: width), child: this);
|
||||
}
|
||||
338
lib/main.dart
338
lib/main.dart
@@ -1,186 +1,180 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/features/auth/screens/onboarding_screen.dart';
|
||||
import 'package:doormile/features/auth/screens/login_screen.dart';
|
||||
import 'package:doormile/features/auth/screens/signup_screen.dart';
|
||||
import 'package:doormile/features/auth/screens/otp_screen.dart';
|
||||
import 'package:doormile/features/auth/screens/pin_screen.dart';
|
||||
import 'package:doormile/features/dashboard/screens/main_shell.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_pickup_screen.dart';
|
||||
import 'package:doormile/features/booking/screens/parcel_details_screen.dart';
|
||||
import 'package:doormile/features/booking/screens/service_price_screen.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_preview_screen.dart';
|
||||
import 'package:doormile/features/booking/screens/booking_success_screen.dart';
|
||||
import 'package:doormile/features/tracking/screens/my_orders_screen.dart';
|
||||
import 'package:doormile/features/tracking/screens/live_tracking_screen.dart';
|
||||
import 'package:doormile/features/tracking/screens/shipment_journey_screen.dart';
|
||||
import 'package:doormile/features/support/screens/help_support_screen.dart';
|
||||
import 'package:doormile/features/dashboard/screens/saved_addresses_screen.dart';
|
||||
import 'package:doormile/features/support/screens/chat_screen.dart';
|
||||
import 'package:doormile/shared/models/models.dart';
|
||||
import 'state/app_scope.dart';
|
||||
import 'state/app_state.dart';
|
||||
import 'ui/screens/auth/login_screen.dart';
|
||||
import 'ui/screens/shell_screen.dart';
|
||||
import 'ui/tokens.dart';
|
||||
|
||||
void main() async {
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
} catch (e) {
|
||||
debugPrint('Firebase initialization error: $e');
|
||||
|
||||
// Draw behind the system bars — expected on Android 15+ and the reason the
|
||||
// screens manage their own SafeArea insets.
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
|
||||
runApp(const DoormileApp());
|
||||
}
|
||||
|
||||
/// Doormile — customer app.
|
||||
///
|
||||
/// Book pickup → Track → Delivered.
|
||||
///
|
||||
/// `lib/data/doormile_api.dart` is the service surface, and it has exactly one
|
||||
/// implementation: the live customer API. Nothing on screen is invented.
|
||||
class DoormileApp extends StatefulWidget {
|
||||
const DoormileApp({super.key, this.initialState});
|
||||
|
||||
/// Injectable for tests.
|
||||
final AppState? initialState;
|
||||
|
||||
@override
|
||||
State<DoormileApp> createState() => _DoormileAppState();
|
||||
}
|
||||
|
||||
class _DoormileAppState extends State<DoormileApp> {
|
||||
late final AppState _state = widget.initialState ?? AppState();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// A returning customer's session outlives the process: the refresh token is
|
||||
// good for 60 days. Look for it before deciding which screen to open.
|
||||
unawaited(_state.restoreSession());
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('auth_token');
|
||||
final initialRoute = (token != null && token.isNotEmpty) ? Routes.home : Routes.onboarding;
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
));
|
||||
runApp(DoormileApp(initialRoute: initialRoute));
|
||||
}
|
||||
|
||||
class Routes {
|
||||
static const splash = '/';
|
||||
static const onboarding = '/onboarding';
|
||||
static const login = '/login';
|
||||
static const signup = '/signup';
|
||||
static const otp = '/otp';
|
||||
static const enterPin = '/enter-pin';
|
||||
static const setPin = '/set-pin';
|
||||
static const resetPin = '/reset-pin';
|
||||
static const home = '/home';
|
||||
static const bookingPickup = '/booking/pickup';
|
||||
static const parcelDetails = '/booking/details';
|
||||
static const servicePrice = '/booking/service';
|
||||
static const bookingPreview = '/booking/preview';
|
||||
static const bookingSuccess = '/booking/success';
|
||||
static const myOrders = '/orders';
|
||||
static const liveTracking = '/tracking/live';
|
||||
static const shipmentJourney = '/tracking/journey';
|
||||
static const help = '/help';
|
||||
static const addresses = '/addresses';
|
||||
static const chat = '/chat';
|
||||
}
|
||||
|
||||
class DoormileApp extends StatelessWidget {
|
||||
final String initialRoute;
|
||||
const DoormileApp({super.key, required this.initialRoute});
|
||||
@override
|
||||
void dispose() {
|
||||
_state.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider(
|
||||
create: (_) => AppState(),
|
||||
child: _buildApp(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildApp() {
|
||||
return MaterialApp(
|
||||
title: 'Doormile',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light,
|
||||
initialRoute: initialRoute,
|
||||
onGenerateRoute: (settings) {
|
||||
Widget page;
|
||||
switch (settings.name) {
|
||||
case Routes.onboarding:
|
||||
page = const OnboardingScreen();
|
||||
break;
|
||||
case Routes.login:
|
||||
page = const LoginScreen();
|
||||
break;
|
||||
case Routes.signup:
|
||||
page = const SignupScreen();
|
||||
break;
|
||||
case Routes.otp:
|
||||
page = const OtpScreen();
|
||||
break;
|
||||
case Routes.enterPin:
|
||||
page = const PinScreen(mode: PinMode.enter);
|
||||
break;
|
||||
case Routes.setPin:
|
||||
page = const PinScreen(mode: PinMode.set);
|
||||
break;
|
||||
case Routes.resetPin:
|
||||
page = const PinScreen(mode: PinMode.reset);
|
||||
break;
|
||||
case Routes.home:
|
||||
page = const MainShell();
|
||||
break;
|
||||
case Routes.bookingPickup:
|
||||
page = const BookingPickupScreen();
|
||||
break;
|
||||
case Routes.parcelDetails:
|
||||
page = const ParcelDetailsScreen();
|
||||
break;
|
||||
case Routes.servicePrice:
|
||||
page = const ServicePriceScreen();
|
||||
break;
|
||||
case Routes.bookingPreview:
|
||||
page = const BookingPreviewScreen();
|
||||
break;
|
||||
case Routes.bookingSuccess:
|
||||
page = const BookingSuccessScreen();
|
||||
break;
|
||||
case Routes.myOrders:
|
||||
page = const MyOrdersScreen();
|
||||
break;
|
||||
case Routes.liveTracking:
|
||||
page = LiveTrackingScreen(order: settings.arguments as Order?);
|
||||
break;
|
||||
case Routes.shipmentJourney:
|
||||
page = ShipmentJourneyScreen(order: settings.arguments as Order?);
|
||||
break;
|
||||
case Routes.help:
|
||||
page = const HelpSupportScreen();
|
||||
break;
|
||||
case Routes.addresses:
|
||||
page = const SavedAddressesScreen();
|
||||
break;
|
||||
case Routes.chat:
|
||||
final chatArgs = settings.arguments as Map<String, String?>? ?? {};
|
||||
page = Builder(
|
||||
builder: (context) {
|
||||
final appState = Provider.of<AppState>(context, listen: false);
|
||||
return ChatScreen(
|
||||
riderName: chatArgs['milerName'] ?? 'Rider',
|
||||
bookingId: chatArgs['bookingId'] ?? '',
|
||||
authToken: appState.authToken,
|
||||
);
|
||||
},
|
||||
);
|
||||
break;
|
||||
default:
|
||||
page = const MainShell();
|
||||
}
|
||||
return PageRouteBuilder(
|
||||
settings: settings,
|
||||
pageBuilder: (_, __, ___) => page,
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, 0.02), end: Offset.zero)
|
||||
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 280),
|
||||
);
|
||||
},
|
||||
return AppScope(
|
||||
state: _state,
|
||||
child: MaterialApp(
|
||||
title: 'Doormile',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildDoormileTheme(),
|
||||
home: const _Launch(),
|
||||
// No text-scale clamp: components adapt instead. Rows grow, important
|
||||
// text wraps, and ellipsis is reserved for secondary information.
|
||||
builder: (context, child) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selection colour helper used by text fields.
|
||||
final selectionTheme = const TextSelectionThemeData(
|
||||
selectionColor: AppColors.primaryFixed,
|
||||
cursorColor: AppColors.primary,
|
||||
);
|
||||
ThemeData buildDoormileTheme() {
|
||||
const scheme = ColorScheme.light(
|
||||
primary: DmColors.brand,
|
||||
onPrimary: Colors.white,
|
||||
secondary: DmColors.brand,
|
||||
onSecondary: Colors.white,
|
||||
surface: DmColors.surface,
|
||||
onSurface: DmColors.ink,
|
||||
error: DmColors.danger,
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
fontFamily: 'Manrope',
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: DmColors.bg,
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
highlightColor: const Color(0x0A17110F),
|
||||
textSelectionTheme: const TextSelectionThemeData(
|
||||
cursorColor: DmColors.brand,
|
||||
selectionColor: Color(0x338F0F06),
|
||||
selectionHandleColor: DmColors.brand,
|
||||
),
|
||||
textTheme: const TextTheme().apply(
|
||||
bodyColor: DmColors.ink,
|
||||
displayColor: DmColors.ink,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: DmColors.hairline,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: _DmPageTransitions(),
|
||||
TargetPlatform.iOS: _DmPageTransitions(),
|
||||
TargetPlatform.macOS: _DmPageTransitions(),
|
||||
TargetPlatform.windows: _DmPageTransitions(),
|
||||
TargetPlatform.linux: _DmPageTransitions(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Forward navigation slides in from the right and fades, matching the
|
||||
/// prototype's screen transition.
|
||||
class _DmPageTransitions extends PageTransitionsBuilder {
|
||||
const _DmPageTransitions();
|
||||
|
||||
@override
|
||||
Widget buildTransitions<T>(
|
||||
PageRoute<T> route,
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
final curved = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: curved,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0.06, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curved),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app opens on.
|
||||
///
|
||||
/// Three states, and the middle one is the reason this widget exists: until the
|
||||
/// stored session has been looked for we do not yet know whether this customer
|
||||
/// is signed in, and showing them a login form that vanishes a moment later is
|
||||
/// worse than showing them nothing. The Android 12 splash is still up for most
|
||||
/// of it.
|
||||
class _Launch extends StatelessWidget {
|
||||
const _Launch();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
if (app.restoringSession) {
|
||||
return const Scaffold(backgroundColor: DmColors.canvas, body: SizedBox());
|
||||
}
|
||||
return app.isSignedIn ? const ShellScreen() : const LoginScreen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
|
||||
/// A saved or recent address entry.
|
||||
class AddressEntry {
|
||||
final String label; // e.g. "Home", "Work"
|
||||
final String line; // full address line
|
||||
final IconData icon;
|
||||
final Color iconBg;
|
||||
final Color iconColor;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final String pincode;
|
||||
|
||||
const AddressEntry({
|
||||
required this.label,
|
||||
required this.line,
|
||||
required this.icon,
|
||||
this.iconBg = AppColors.surfaceContainer,
|
||||
this.iconColor = AppColors.onSurfaceVariant,
|
||||
this.latitude = 0.0,
|
||||
this.longitude = 0.0,
|
||||
this.pincode = '',
|
||||
});
|
||||
}
|
||||
|
||||
enum OrderStatus { active, delivered, cancelled }
|
||||
|
||||
extension OrderStatusX on OrderStatus {
|
||||
String get label => switch (this) {
|
||||
OrderStatus.active => 'In Transit',
|
||||
OrderStatus.delivered => 'Delivered',
|
||||
OrderStatus.cancelled => 'Cancelled',
|
||||
};
|
||||
|
||||
Color get color => switch (this) {
|
||||
OrderStatus.active => AppColors.tertiary,
|
||||
OrderStatus.delivered => AppColors.success,
|
||||
OrderStatus.cancelled => AppColors.error,
|
||||
};
|
||||
|
||||
Color get bg => switch (this) {
|
||||
OrderStatus.active => AppColors.tertiaryFixed,
|
||||
OrderStatus.delivered => const Color(0xFFE3F6EC),
|
||||
OrderStatus.cancelled => AppColors.errorContainer,
|
||||
};
|
||||
}
|
||||
|
||||
/// One stop in a shipment journey timeline.
|
||||
class JourneyStep {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String time;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final bool done;
|
||||
final bool active;
|
||||
final String? note;
|
||||
|
||||
const JourneyStep({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.time,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.done = false,
|
||||
this.active = false,
|
||||
this.note,
|
||||
});
|
||||
}
|
||||
|
||||
class ActivityLog {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
const ActivityLog(this.title, this.subtitle, this.icon);
|
||||
}
|
||||
|
||||
/// A parcel order.
|
||||
class Order {
|
||||
final String id;
|
||||
final String fromLabel;
|
||||
final String fromSub;
|
||||
final String toLabel;
|
||||
final String toSub;
|
||||
final String date;
|
||||
final double amount;
|
||||
final OrderStatus status;
|
||||
final String parcelType;
|
||||
final String weight;
|
||||
final String size;
|
||||
final double progress; // 0..1
|
||||
final String co2Saved;
|
||||
final String eta;
|
||||
final bool ecoRoute;
|
||||
|
||||
const Order({
|
||||
required this.id,
|
||||
required this.fromLabel,
|
||||
required this.fromSub,
|
||||
required this.toLabel,
|
||||
required this.toSub,
|
||||
required this.date,
|
||||
required this.amount,
|
||||
required this.status,
|
||||
this.parcelType = 'Documents',
|
||||
this.weight = '0–1 kg',
|
||||
this.size = 'Small',
|
||||
this.progress = 0.0,
|
||||
this.co2Saved = '0.8kg',
|
||||
this.eta = '—',
|
||||
this.ecoRoute = true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class FaqItem {
|
||||
final String question;
|
||||
final String answer;
|
||||
const FaqItem(this.question, this.answer);
|
||||
}
|
||||
|
||||
class ChatMessage {
|
||||
final String text;
|
||||
final bool fromUser;
|
||||
const ChatMessage(this.text, {this.fromUser = false});
|
||||
}
|
||||
@@ -1,672 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import 'package:doormile/core/constants/api_constants.dart';
|
||||
|
||||
import 'package:doormile/shared/models/models.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
|
||||
/// Holds the in-progress booking the user is composing across the flow screens.
|
||||
class BookingDraft {
|
||||
String pickupAddress = '';
|
||||
String pickupPincode = '';
|
||||
double pickupLatitude = 0.0;
|
||||
double pickupLongitude = 0.0;
|
||||
String contactName = '';
|
||||
String contactMobile = '';
|
||||
String notes = '';
|
||||
|
||||
String destination = '';
|
||||
String service = '';
|
||||
String parcelType = '';
|
||||
String weight = '2 kg';
|
||||
String size = 'Small';
|
||||
String pickupDay = 'Today';
|
||||
String pickupSlot = '10:00 AM - 12:00 PM';
|
||||
bool fragile = false;
|
||||
String dropAddress = '';
|
||||
String dropPincode = '';
|
||||
String paymentMethod = 'Cash';
|
||||
bool needsInsurance = false;
|
||||
double declaredValue = 0.0;
|
||||
|
||||
double minPrice = 45.0;
|
||||
double maxPrice = 86.0;
|
||||
|
||||
void reset() {
|
||||
pickupAddress = '';
|
||||
pickupPincode = '';
|
||||
pickupLatitude = 0.0;
|
||||
pickupLongitude = 0.0;
|
||||
contactName = '';
|
||||
contactMobile = '';
|
||||
notes = '';
|
||||
destination = '';
|
||||
service = '';
|
||||
parcelType = '';
|
||||
weight = '2 kg';
|
||||
size = 'Small';
|
||||
pickupDay = 'Today';
|
||||
pickupSlot = '10:00 AM - 12:00 PM';
|
||||
fragile = false;
|
||||
dropAddress = '';
|
||||
dropPincode = '';
|
||||
paymentMethod = 'Cash';
|
||||
needsInsurance = false;
|
||||
declaredValue = 0.0;
|
||||
minPrice = 45.0;
|
||||
maxPrice = 86.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Global, in-memory app state — auth, wallet, orders, and the booking draft.
|
||||
class AppState extends ChangeNotifier {
|
||||
// ---- Auth ----
|
||||
bool isLoggedIn = false;
|
||||
String phone = '';
|
||||
String? verificationId;
|
||||
String userName = ''; // Updated via profile/login
|
||||
String firstName = '';
|
||||
|
||||
String? pendingFirstName;
|
||||
String? pendingEmail;
|
||||
String? authToken;
|
||||
bool isResettingPin = false;
|
||||
String? _deviceToken;
|
||||
|
||||
AppState() {
|
||||
_loadSession();
|
||||
}
|
||||
|
||||
Future<void> _loadSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
authToken = prefs.getString('auth_token');
|
||||
if (authToken != null && authToken!.isNotEmpty) {
|
||||
isLoggedIn = true;
|
||||
firstName = prefs.getString('first_name') ?? 'User';
|
||||
userName = prefs.getString('user_name') ?? 'User';
|
||||
phone = prefs.getString('phone') ?? '';
|
||||
|
||||
// Wipe mock data because this is a real authenticated session
|
||||
orders.clear();
|
||||
savedAddresses.clear();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (authToken != null) await prefs.setString('auth_token', authToken!);
|
||||
await prefs.setString('first_name', firstName);
|
||||
await prefs.setString('user_name', userName);
|
||||
await prefs.setString('phone', phone);
|
||||
}
|
||||
|
||||
Future<String?> _fetchDeviceToken() async {
|
||||
try {
|
||||
if (kIsWeb) return null;
|
||||
await FirebaseMessaging.instance.requestPermission(alert: true, badge: true, sound: true);
|
||||
_deviceToken = await FirebaseMessaging.instance.getToken();
|
||||
return _deviceToken;
|
||||
} catch (e) {
|
||||
debugPrint('FCM token error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void setPhone(String value) {
|
||||
phone = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Sends a 6-digit OTP to [phoneNumber] via Firebase Phone Auth.
|
||||
Future<void> sendOtp(String phoneNumber, {String? firstName, String? email, bool resetPin = false, required Function onSuccess, required Function(String) onError}) async {
|
||||
setPhone(phoneNumber);
|
||||
pendingFirstName = firstName ?? pendingFirstName;
|
||||
pendingEmail = email ?? pendingEmail;
|
||||
isResettingPin = resetPin;
|
||||
|
||||
final rawPhone = phoneNumber.replaceAll(' ', '');
|
||||
if (rawPhone.length != 10) {
|
||||
onError('A valid mobile number is required for verification.');
|
||||
return;
|
||||
}
|
||||
final fullPhone = '+91$rawPhone';
|
||||
|
||||
if (kIsWeb) {
|
||||
debugPrint('[OTP Service] Web build: bypassing Firebase OTP (mocking success)');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
verificationId = 'mock_verification_id_$fullPhone';
|
||||
onSuccess();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('[OTP Service] Initiating Firebase phone verification for $fullPhone');
|
||||
await FirebaseAuth.instance.verifyPhoneNumber(
|
||||
phoneNumber: fullPhone,
|
||||
verificationCompleted: (PhoneAuthCredential credential) async {
|
||||
debugPrint('[OTP Service] Verification completed automatically');
|
||||
try {
|
||||
await FirebaseAuth.instance.signInWithCredential(credential);
|
||||
} catch (e) {
|
||||
debugPrint('[OTP Service] Error signing in: $e');
|
||||
}
|
||||
},
|
||||
verificationFailed: (FirebaseAuthException e) {
|
||||
debugPrint('[OTP Service] Verification failed: ${e.code} - ${e.message}');
|
||||
onError(e.message ?? 'Failed to send verification code.');
|
||||
},
|
||||
codeSent: (String vId, int? resendToken) {
|
||||
debugPrint('[OTP Service] Code sent. Verification ID: $vId');
|
||||
verificationId = vId;
|
||||
onSuccess();
|
||||
},
|
||||
codeAutoRetrievalTimeout: (String vId) {
|
||||
debugPrint('[OTP Service] Auto retrieval timeout. Verification ID: $vId');
|
||||
verificationId = vId;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[OTP Service] Exception during OTP request: $e');
|
||||
onError('Error sending verification code: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug-only tester shortcut: if `smsDefaultProvider`/`smsPassKey` are set
|
||||
/// in SharedPreferences, that fixed passkey is accepted instead of the real
|
||||
/// OTP. `kDebugMode` means this branch is compiled out of release builds
|
||||
/// entirely, so it can never ship as a bypass.
|
||||
Future<bool> _checkDebugBypass(String smsCode) async {
|
||||
if (!kDebugMode) return false;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final provider = prefs.getInt('smsDefaultProvider') ?? 0;
|
||||
final passKey = prefs.getInt('smsPassKey');
|
||||
return provider == 1 && passKey != null && smsCode == passKey.toString().padLeft(6, '0');
|
||||
}
|
||||
|
||||
Future<void> verifyOtp(String smsCode, {required Function onSuccess, required Function(String) onError}) async {
|
||||
if (await _checkDebugBypass(smsCode)) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
if (verificationId == null) {
|
||||
onError('Verification ID is missing. Please resend OTP.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (kIsWeb) {
|
||||
debugPrint('[OTP Service] Web build: bypassing Firebase OTP verification (mocking success)');
|
||||
if (smsCode.length == 6) {
|
||||
onSuccess();
|
||||
} else {
|
||||
onError('Invalid mock OTP code (must be 6 digits).');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final credential = PhoneAuthProvider.credential(
|
||||
verificationId: verificationId!,
|
||||
smsCode: smsCode,
|
||||
);
|
||||
await FirebaseAuth.instance.signInWithCredential(credential);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
onError('Invalid verification code.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backend Wrappers ---
|
||||
|
||||
Future<void> registerWithPin(String pin) async {
|
||||
final rawPhone = phone.replaceAll(' ', '');
|
||||
final parts = (pendingFirstName ?? 'User').split(' ');
|
||||
final first = parts.first;
|
||||
final last = parts.length > 1 ? parts.sublist(1).join(' ') : '';
|
||||
|
||||
double lat = 0.0;
|
||||
double lng = 0.0;
|
||||
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (serviceEnabled) {
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
if (permission == LocationPermission.whileInUse || permission == LocationPermission.always) {
|
||||
Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.medium);
|
||||
lat = position.latitude;
|
||||
lng = position.longitude;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error getting location for registration: $e');
|
||||
}
|
||||
|
||||
final token = await _fetchDeviceToken();
|
||||
|
||||
final res = await http.post(
|
||||
Uri.parse(ApiConstants.register),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
"firstname": first,
|
||||
"lastname": last,
|
||||
"phone": rawPhone,
|
||||
"email": pendingEmail ?? '',
|
||||
"pin": pin,
|
||||
"configid": 1001,
|
||||
"defaultlatitude": lat,
|
||||
"defaultlongitude": lng,
|
||||
if (token != null) "device_token": token,
|
||||
}),
|
||||
);
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final data = jsonDecode(res.body);
|
||||
authToken = data['token'];
|
||||
|
||||
if (data['user'] != null && data['user']['firstname'] != null) {
|
||||
firstName = data['user']['firstname'];
|
||||
userName = '$firstName ${data['user']['lastname'] ?? ''}'.trim();
|
||||
} else {
|
||||
firstName = pendingFirstName ?? 'User';
|
||||
userName = pendingFirstName ?? 'User';
|
||||
}
|
||||
|
||||
login();
|
||||
} else {
|
||||
throw Exception('Registration Failed: ${res.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initiateLogin(String phoneNumber) async {
|
||||
final rawPhone = phoneNumber.replaceAll(' ', '');
|
||||
setPhone(phoneNumber);
|
||||
|
||||
// Easter Egg
|
||||
if (rawPhone == '9876543210') {
|
||||
return;
|
||||
}
|
||||
|
||||
final initRes = await http.post(
|
||||
Uri.parse(ApiConstants.login),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({"phone": rawPhone, "configid": 1001}),
|
||||
);
|
||||
|
||||
if (initRes.statusCode < 200 || initRes.statusCode >= 300) {
|
||||
throw Exception('Phone number not registered. Please sign up first.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyPinWithBackend(String pin) async {
|
||||
final rawPhone = phone.replaceAll(' ', '');
|
||||
|
||||
// Easter Egg
|
||||
if (rawPhone == '9876543210' && pin == '1234') {
|
||||
authToken = 'easter-egg-token';
|
||||
firstName = 'Admin';
|
||||
userName = 'Admin User';
|
||||
login();
|
||||
return;
|
||||
}
|
||||
|
||||
final token = await _fetchDeviceToken();
|
||||
|
||||
final verifyRes = await http.post(
|
||||
Uri.parse(ApiConstants.verifyPin),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
"phone": rawPhone,
|
||||
"pin": pin,
|
||||
"configid": 1001,
|
||||
if (token != null) "device_token": token,
|
||||
}),
|
||||
);
|
||||
|
||||
if (verifyRes.statusCode >= 200 && verifyRes.statusCode < 300) {
|
||||
final data = jsonDecode(verifyRes.body);
|
||||
authToken = data['token'];
|
||||
if (data['user'] != null && data['user']['firstname'] != null) {
|
||||
firstName = data['user']['firstname'];
|
||||
userName = '$firstName ${data['user']['lastname'] ?? ''}'.trim();
|
||||
}
|
||||
login();
|
||||
} else {
|
||||
throw Exception('Incorrect PIN. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resetPinWithBackend(String newPin) async {
|
||||
final rawPhone = phone.replaceAll(' ', '');
|
||||
// TODO: Create this endpoint in Go backend
|
||||
final res = await http.post(
|
||||
Uri.parse('${ApiConstants.baseUrl}/customer/reset-pin'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({"phone": rawPhone, "new_pin": newPin}),
|
||||
);
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final data = jsonDecode(res.body);
|
||||
if (data['token'] != null) authToken = data['token'];
|
||||
login();
|
||||
} else {
|
||||
throw Exception('PIN Reset Failed: ${res.body}');
|
||||
}
|
||||
}
|
||||
|
||||
void login() {
|
||||
isLoggedIn = true;
|
||||
orders.clear();
|
||||
savedAddresses.clear();
|
||||
_saveSession();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
isLoggedIn = false;
|
||||
authToken = null;
|
||||
firstName = 'User';
|
||||
userName = 'User';
|
||||
phone = '';
|
||||
verificationId = null;
|
||||
pendingFirstName = null;
|
||||
pendingEmail = null;
|
||||
orders.clear();
|
||||
savedAddresses.clear();
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('auth_token');
|
||||
await prefs.remove('first_name');
|
||||
await prefs.remove('user_name');
|
||||
await prefs.remove('phone');
|
||||
}
|
||||
|
||||
// ---- Booking draft ----
|
||||
final BookingDraft draft = BookingDraft();
|
||||
void touch() => notifyListeners();
|
||||
|
||||
|
||||
// ---- Orders ----
|
||||
List<Order> orders = [];
|
||||
|
||||
bool isLoadingOrders = false;
|
||||
|
||||
/// Fetches real bookings from the Go backend
|
||||
Future<void> fetchOrders() async {
|
||||
if (authToken == null) return;
|
||||
|
||||
isLoadingOrders = true;
|
||||
notifyListeners();
|
||||
|
||||
if (authToken == 'easter-egg-token') {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
orders = [
|
||||
Order(
|
||||
id: 'DM-TEST-001',
|
||||
fromLabel: 'Home (Mock)',
|
||||
fromSub: 'Pickup',
|
||||
toLabel: 'Pincode: 110001',
|
||||
toSub: 'Delivery',
|
||||
date: 'Today',
|
||||
amount: 65.0,
|
||||
status: OrderStatus.active,
|
||||
progress: 0.1,
|
||||
eta: '10 min',
|
||||
),
|
||||
];
|
||||
isLoadingOrders = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
print('\n=== DOORMILE API FETCH ORDERS ===');
|
||||
print('Endpoint: ${ApiConstants.getPickups}');
|
||||
|
||||
final res = await http.get(
|
||||
Uri.parse(ApiConstants.getPickups),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $authToken',
|
||||
},
|
||||
);
|
||||
|
||||
print('Status Code: ${res.statusCode}');
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final List<dynamic> data = jsonDecode(res.body);
|
||||
|
||||
orders = data.map((b) {
|
||||
return Order(
|
||||
id: b['booking_no'] ?? 'DM-UNK',
|
||||
fromLabel: b['pickupaddress'] ?? 'Unknown Location',
|
||||
fromSub: 'Pickup',
|
||||
toLabel: b['deliverypincode'] != null ? 'Pincode: ${b['deliverypincode']}' : 'Pending Destination',
|
||||
toSub: 'Delivery',
|
||||
date: b['created_at']?.split('T')[0] ?? 'Today',
|
||||
amount: (b['estimated_price'] ?? 0.0).toDouble(),
|
||||
status: _parseBackendStatus(b['status']),
|
||||
progress: b['status'] == 'Pending_Pickup' ? 0.1 : 0.5,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
print('Failed to fetch orders: $e');
|
||||
} finally {
|
||||
isLoadingOrders = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
OrderStatus _parseBackendStatus(String? status) {
|
||||
if (status == 'Pending_Pickup') return OrderStatus.active;
|
||||
if (status == 'Delivered') return OrderStatus.delivered;
|
||||
if (status == 'Cancelled') return OrderStatus.cancelled;
|
||||
return OrderStatus.active;
|
||||
}
|
||||
|
||||
/// Fetches estimated pricing from the Redis-backed Go API
|
||||
Future<void> fetchPriceEstimate() async {
|
||||
// Map UI categories to Backend enums
|
||||
String backendCategory = "General";
|
||||
switch (draft.parcelType) {
|
||||
case "General Goods": backendCategory = "General"; break;
|
||||
case "Books & Documents": backendCategory = "Documents"; break;
|
||||
case "Electronics & Gadgets": backendCategory = "Electronics"; break;
|
||||
case "Clothing & Textiles": backendCategory = "Clothing"; break;
|
||||
case "Fragile Items": backendCategory = "Fragile"; break;
|
||||
case "Medical & Pharma": backendCategory = "Medical"; break;
|
||||
case "Automotive Parts": backendCategory = "Automotive"; break;
|
||||
case "Food & Perishables": backendCategory = "Food"; break;
|
||||
default: backendCategory = "General";
|
||||
}
|
||||
|
||||
final payload = {
|
||||
"zone": "Local",
|
||||
"destinationpin": draft.dropPincode,
|
||||
"pickuppincode": draft.pickupPincode,
|
||||
"service_type": draft.service.isNotEmpty ? draft.service : "Normal",
|
||||
"weight": 1.0, // Default since we removed it from UI
|
||||
"category": backendCategory
|
||||
};
|
||||
|
||||
print('\n=== DOORMILE API PRICING REQUEST ===');
|
||||
print('Endpoint: ${ApiConstants.checkPrice}');
|
||||
print('Payload: ${jsonEncode(payload)}');
|
||||
print('====================================\n');
|
||||
|
||||
if (authToken == 'easter-egg-token') {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
draft.minPrice = 50.0;
|
||||
draft.maxPrice = 120.0;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await http.post(
|
||||
Uri.parse(ApiConstants.checkPrice),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
|
||||
print('\n=== DOORMILE API PRICING RESPONSE ===');
|
||||
print('Status Code: ${res.statusCode}');
|
||||
print('Response Body: ${res.body}');
|
||||
print('=============================\n');
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final data = jsonDecode(res.body);
|
||||
if (data['results'] != null && data['results'].isNotEmpty) {
|
||||
final result = data['results'][0];
|
||||
draft.minPrice = (result['min_price'] as num).toDouble();
|
||||
draft.maxPrice = (result['max_price'] as num).toDouble();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Pricing API error: $e');
|
||||
// On error, we just keep the default 45 - 86 fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirms the current draft into a new active order.
|
||||
Future<Order> placeOrder() async {
|
||||
// Map UI categories to Backend enums
|
||||
String backendCategory = "General";
|
||||
switch (draft.parcelType) {
|
||||
case "General Goods": backendCategory = "General"; break;
|
||||
case "Books & Documents": backendCategory = "Documents"; break;
|
||||
case "Electronics & Gadgets": backendCategory = "Electronics"; break;
|
||||
case "Clothing & Textiles": backendCategory = "Clothing"; break;
|
||||
case "Fragile Items": backendCategory = "Fragile"; break;
|
||||
case "Medical & Pharma": backendCategory = "Medical"; break;
|
||||
case "Automotive Parts": backendCategory = "Automotive"; break;
|
||||
case "Food & Perishables": backendCategory = "Food"; break;
|
||||
default: backendCategory = "General";
|
||||
}
|
||||
|
||||
final payload = {
|
||||
"pickupaddress": draft.pickupAddress.isNotEmpty ? draft.pickupAddress : "Current Location (GPS)",
|
||||
"pickuppincode": draft.pickupPincode,
|
||||
"pickuplatitude": draft.pickupLatitude,
|
||||
"pickuplongitude": draft.pickupLongitude,
|
||||
"deliverypincode": draft.dropPincode,
|
||||
"deliveryaddress": draft.dropAddress,
|
||||
"serviceoption": draft.service,
|
||||
"contactname": draft.contactName,
|
||||
"contactmobile": draft.contactMobile,
|
||||
"parcels": [
|
||||
{
|
||||
"itemcategory": backendCategory,
|
||||
"itemdescription": draft.notes.isNotEmpty ? draft.notes : "No description provided"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
print('\n=== DOORMILE API BOOKING REQUEST ===');
|
||||
print('Endpoint: ${ApiConstants.createPickup}');
|
||||
print('Payload: ${jsonEncode(payload)}');
|
||||
print('====================================\n');
|
||||
|
||||
if (authToken == 'easter-egg-token') {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final newOrder = Order(
|
||||
id: 'DM-PU-000${101 + orders.length}',
|
||||
fromLabel: draft.pickupAddress,
|
||||
fromSub: draft.contactName.isEmpty ? 'Pickup requested' : 'Contact: ${draft.contactName}',
|
||||
toLabel: draft.dropPincode.isNotEmpty ? 'Pincode: ${draft.dropPincode}' : 'Awaiting Miler input',
|
||||
toSub: 'Drop details pending',
|
||||
date: 'Today',
|
||||
amount: draft.minPrice,
|
||||
status: OrderStatus.active,
|
||||
progress: 0.1,
|
||||
eta: 'Assigning Miler...',
|
||||
);
|
||||
|
||||
orders.insert(0, newOrder);
|
||||
draft.reset();
|
||||
notifyListeners();
|
||||
return newOrder;
|
||||
}
|
||||
|
||||
final res = await http.post(
|
||||
Uri.parse(ApiConstants.createPickup),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (authToken != null) 'Authorization': 'Bearer $authToken',
|
||||
},
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
|
||||
print('\n=== DOORMILE API RESPONSE ===');
|
||||
print('Status Code: ${res.statusCode}');
|
||||
print('Response Body: ${res.body}');
|
||||
print('=============================\n');
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
final data = jsonDecode(res.body);
|
||||
|
||||
final newOrder = Order(
|
||||
id: data['booking_no'] ?? 'DM-PU-000${101 + orders.length}',
|
||||
fromLabel: draft.pickupAddress,
|
||||
fromSub: draft.contactName.isEmpty ? 'Pickup requested' : 'Contact: ${draft.contactName}',
|
||||
toLabel: draft.dropPincode.isNotEmpty ? 'Pincode: ${draft.dropPincode}' : 'Awaiting Miler input',
|
||||
toSub: 'Drop details pending',
|
||||
date: 'Today',
|
||||
amount: draft.minPrice,
|
||||
status: OrderStatus.active,
|
||||
progress: 0.1,
|
||||
eta: 'Assigning Miler...',
|
||||
);
|
||||
|
||||
orders.insert(0, newOrder);
|
||||
draft.reset();
|
||||
notifyListeners();
|
||||
return newOrder;
|
||||
} else {
|
||||
throw Exception('Failed to create pickup request: ${res.body}');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Saved addresses ----
|
||||
List<AddressEntry> savedAddresses = [
|
||||
AddressEntry(
|
||||
label: 'Home',
|
||||
line: 'Sector 45, Gurgaon, Haryana, 122003',
|
||||
icon: Icons.home_rounded,
|
||||
iconBg: AppColors.primaryFixed,
|
||||
iconColor: AppColors.primary,
|
||||
pincode: '122003',
|
||||
),
|
||||
AddressEntry(
|
||||
label: 'Work',
|
||||
line: 'Cyber City Building 10, DLF Phase 2',
|
||||
icon: Icons.work_rounded,
|
||||
iconBg: AppColors.tertiaryFixed,
|
||||
iconColor: AppColors.tertiary,
|
||||
pincode: '122002',
|
||||
),
|
||||
AddressEntry(
|
||||
label: 'Select City Walk Mall',
|
||||
line: 'Saket District Centre, New Delhi',
|
||||
icon: Icons.history_rounded,
|
||||
iconBg: AppColors.surfaceContainerHigh,
|
||||
iconColor: AppColors.onSurfaceVariant,
|
||||
pincode: '110017',
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
}
|
||||
24
lib/state/app_scope.dart
Normal file
24
lib/state/app_scope.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app_state.dart';
|
||||
|
||||
/// Makes [AppState] available to the widget tree and rebuilds dependents when
|
||||
/// it changes. Deliberately dependency-free — no external state package.
|
||||
class AppScope extends InheritedNotifier<AppState> {
|
||||
const AppScope({super.key, required AppState state, required super.child})
|
||||
: super(notifier: state);
|
||||
|
||||
/// Subscribes the calling widget to changes.
|
||||
static AppState of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope.of() called outside an AppScope');
|
||||
return scope!.notifier!;
|
||||
}
|
||||
|
||||
/// Reads without subscribing — for callbacks and one-shot lookups.
|
||||
static AppState read(BuildContext context) {
|
||||
final scope = context.getInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope.read() called outside an AppScope');
|
||||
return scope!.notifier!;
|
||||
}
|
||||
}
|
||||
728
lib/state/app_state.dart
Normal file
728
lib/state/app_state.dart
Normal file
@@ -0,0 +1,728 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../data/app_config.dart';
|
||||
import '../data/doormile_api.dart';
|
||||
import '../data/location_service.dart';
|
||||
import '../data/models.dart';
|
||||
|
||||
/// Single source of truth for the customer app.
|
||||
///
|
||||
/// Screens read from here and call these methods; nothing mutates a [Booking]
|
||||
/// directly.
|
||||
///
|
||||
/// **Every booking on screen came from the server.** This class has no way to
|
||||
/// invent one and no way to move one along: a stage changes when the backend
|
||||
/// says it changed and [refreshBooking] reads it back.
|
||||
class AppState extends ChangeNotifier {
|
||||
AppState({DoormileApi? api, LocationService? location})
|
||||
: api = api ?? DoormileApi.instance,
|
||||
location = location ?? LocationService.instance {
|
||||
this.api.onSessionLost = onSessionLost;
|
||||
loadLimits();
|
||||
}
|
||||
|
||||
final DoormileApi api;
|
||||
final LocationService location;
|
||||
|
||||
// ------------------------------------------------------------- session
|
||||
|
||||
Customer? customer;
|
||||
|
||||
/// **The authoritative pickup point.** Display address *and* the coordinates
|
||||
/// the Miler is sent to, in one object.
|
||||
///
|
||||
/// All three ways of choosing a pickup — the device fix, a map drag, and a
|
||||
/// search result — write this single field, so nothing downstream has to
|
||||
/// reconcile "the address" against "the pin".
|
||||
Place? pickup;
|
||||
|
||||
/// True once we have both an address and coordinates to send a Miler to.
|
||||
/// The booking step gates on this rather than on the address alone.
|
||||
bool get hasPickupPoint => pickup?.hasLocation ?? false;
|
||||
|
||||
/// True while a pin is being turned into an address. The pickup card shows a
|
||||
/// placeholder rather than a stale street name.
|
||||
bool resolvingPickup = false;
|
||||
|
||||
/// Why we could not read the device location, if we could not. Null when the
|
||||
/// pin came from GPS or from the customer.
|
||||
LocationDenial? locationDenial;
|
||||
|
||||
/// Where the **customer** is, as opposed to where the pickup pin is.
|
||||
///
|
||||
/// Kept separate on purpose: the pin moves when they drag the map, this does
|
||||
/// not. It is what the blue dot renders at, and it survives a drag so they
|
||||
/// can always see how far they have moved the pin from themselves.
|
||||
double? deviceLat;
|
||||
double? deviceLng;
|
||||
|
||||
/// Horizontal accuracy in metres, when the platform reported it — the halo
|
||||
/// around the dot.
|
||||
double? deviceAccuracy;
|
||||
|
||||
bool get hasDeviceLocation => deviceLat != null && deviceLng != null;
|
||||
|
||||
/// Guards against an earlier reverse-geocode landing after a later drag.
|
||||
int _pinSeq = 0;
|
||||
|
||||
/// Where the map should open: the current pin, else the service-area default.
|
||||
double get mapLat => pickup?.lat ?? LocationService.fallbackLat;
|
||||
double get mapLng => pickup?.lng ?? LocationService.fallbackLng;
|
||||
|
||||
bool get isSignedIn => customer != null;
|
||||
|
||||
/// True until the stored session has been looked for. The app shows its
|
||||
/// launch state rather than the sign-in screen while this is set, so a
|
||||
/// returning customer never sees a login form flash past.
|
||||
bool restoringSession = true;
|
||||
|
||||
void signIn(Customer c) {
|
||||
customer = c;
|
||||
notifyListeners();
|
||||
unawaited(refreshOrders());
|
||||
}
|
||||
|
||||
/// Looks for a persisted session at launch.
|
||||
///
|
||||
/// Never throws: being offline at launch is not being signed out, and the
|
||||
/// stored customer stands until a call actually comes back 401.
|
||||
Future<void> restoreSession() async {
|
||||
try {
|
||||
final restored = await api.restoreSession();
|
||||
if (restored != null) {
|
||||
customer = restored;
|
||||
unawaited(refreshOrders());
|
||||
} else if (AppConfig.hasAutoLogin) {
|
||||
await _autoSignIn();
|
||||
} else if (AppConfig.devAutoLogin) {
|
||||
// Dev builds only, and only when asked with DM_DEV_LOGIN: open straight
|
||||
// into the app without the login screen. Guarded in [AppConfig] so no
|
||||
// define can do this in a release. Without the flag, a dev build still
|
||||
// lands on login, where any 4-digit code signs in.
|
||||
debugPrint('[AUTH] DM_DEV_LOGIN — opening without the login screen');
|
||||
signIn(
|
||||
const Customer(
|
||||
id: 'cust_dev',
|
||||
name: 'Dev Customer',
|
||||
phone: '+91 98765 43210',
|
||||
email: 'dev@doormile.com',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Same rule as the limits call: this decides which screen opens, so it
|
||||
// fails to "signed out", never to a crash.
|
||||
debugPrint('[SESSION] restore failed: $e');
|
||||
} finally {
|
||||
restoringSession = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs in for real, without the login screen — `DM_LOGIN_AS`.
|
||||
///
|
||||
/// This is a genuine sign-in, not a bypass: it runs the same two calls the
|
||||
/// login and code screens run, and what it ends up holding is a token the
|
||||
/// server issued. That is the whole point of it existing next to
|
||||
/// [AppConfig.useDevData] — a build that skips the entrance still has to end
|
||||
/// up as a real customer, or every booking it makes is a fake one.
|
||||
///
|
||||
/// Failure is not fatal and never throws: the app simply lands on the login
|
||||
/// screen, which is exactly where someone whose code was refused should be.
|
||||
Future<void> _autoSignIn() async {
|
||||
final identifier = AppConfig.autoLoginIdentifier;
|
||||
try {
|
||||
// The request may come back `sent: false` on the resend cooldown; that
|
||||
// is fine, the code already issued is still the one Redis is holding.
|
||||
await api.sendOtp(identifier);
|
||||
final verified = await api.verifyOtp(identifier, AppConfig.autoLoginCode);
|
||||
customer = verified;
|
||||
unawaited(refreshOrders());
|
||||
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier');
|
||||
} catch (e) {
|
||||
debugPrint('[AUTH] DM_LOGIN_AS failed for $identifier: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by the API layer when a refresh fails — the chain is dead, so the
|
||||
/// customer is signed out wherever they happen to be standing.
|
||||
void onSessionLost() {
|
||||
if (customer == null) return;
|
||||
debugPrint('[SESSION] lost, signing out');
|
||||
signOut();
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
customer = null;
|
||||
orders.clear();
|
||||
pickup = null;
|
||||
deviceLat = null;
|
||||
deviceLng = null;
|
||||
deviceAccuracy = null;
|
||||
locationDenial = null;
|
||||
ordersCursor = null;
|
||||
clearCaches();
|
||||
notifyListeners();
|
||||
try {
|
||||
await api.signOut();
|
||||
} on ApiException catch (e) {
|
||||
// Local sign-out has already happened and must stand.
|
||||
debugPrint('[SESSION] sign-out call failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the device location and turns it into an address.
|
||||
///
|
||||
/// Never throws and never leaves the customer stuck: a refused permission
|
||||
/// falls back to the service-area centre with [locationDenial] set, so the
|
||||
/// screen can explain it and offer search instead.
|
||||
Future<void> detectPickupLocation() async {
|
||||
final seq = ++_pinSeq;
|
||||
resolvingPickup = true;
|
||||
notifyListeners();
|
||||
|
||||
final fix = await location.current();
|
||||
if (seq != _pinSeq) return;
|
||||
locationDenial = fix.denial;
|
||||
|
||||
if (fix.hasFix) {
|
||||
deviceLat = fix.lat;
|
||||
deviceLng = fix.lng;
|
||||
deviceAccuracy = fix.accuracy;
|
||||
}
|
||||
|
||||
final lat = fix.lat ?? pickup?.lat ?? LocationService.fallbackLat;
|
||||
final lng = fix.lng ?? pickup?.lng ?? LocationService.fallbackLng;
|
||||
await _resolvePin(lat, lng, seq);
|
||||
}
|
||||
|
||||
/// Called when the map settles under the pin. The newest drag always wins.
|
||||
Future<void> setPickupFromPin(double lat, double lng) async {
|
||||
final seq = ++_pinSeq;
|
||||
resolvingPickup = true;
|
||||
notifyListeners();
|
||||
await _resolvePin(lat, lng, seq);
|
||||
}
|
||||
|
||||
Future<void> _resolvePin(double lat, double lng, int seq) async {
|
||||
Place resolved;
|
||||
try {
|
||||
resolved = await api.reverseGeocode(lat: lat, lng: lng);
|
||||
} on ApiException {
|
||||
resolved = Place(
|
||||
title: 'Pinned location',
|
||||
sub: 'Address unavailable — the Miler will confirm it at pickup',
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
);
|
||||
}
|
||||
if (seq != _pinSeq) return;
|
||||
pickup = resolved;
|
||||
resolvingPickup = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Adopts a place chosen from search. Cancels any in-flight pin resolution,
|
||||
/// so the newest customer intent always wins — same rule as a drag.
|
||||
void setPickup(Place place) {
|
||||
_pinSeq++;
|
||||
// A search result without coordinates cannot be a pin. The backend
|
||||
// contract requires them (§6.2); if one arrives without, keep the address
|
||||
// and hold the previous pin rather than dropping the customer somewhere
|
||||
// else on the map.
|
||||
assert(
|
||||
place.hasLocation,
|
||||
'searchPlaces returned a Place with no coordinates: ${place.title}',
|
||||
);
|
||||
pickup = place.hasLocation
|
||||
? place
|
||||
: Place(
|
||||
title: place.title,
|
||||
sub: place.sub,
|
||||
lat: pickup?.lat,
|
||||
lng: pickup?.lng,
|
||||
);
|
||||
resolvingPickup = false;
|
||||
locationDenial = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Opens OS settings after a permanent refusal.
|
||||
Future<void> openLocationSettings() => location.openSettings();
|
||||
|
||||
// ------------------------------------------------- service delegation
|
||||
//
|
||||
// Screens call these, never `DoormileApi.instance`. That keeps the injected
|
||||
// instance authoritative and means no widget can tell whether an answer came
|
||||
// from the backend.
|
||||
|
||||
Future<OtpChallenge> sendOtp(String identifier) => api.sendOtp(identifier);
|
||||
|
||||
Future<OtpChallenge> signUp({
|
||||
required String name,
|
||||
required String phone,
|
||||
String? email,
|
||||
}) => api.signUp(name: name, phone: phone, email: email);
|
||||
|
||||
/// Verifies the code and adopts the session in one step, so no screen has to
|
||||
/// remember to call [signIn] afterwards.
|
||||
Future<Customer> verifyOtp(
|
||||
String identifier,
|
||||
String code, {
|
||||
String? name,
|
||||
}) async {
|
||||
final verified = await api.verifyOtp(identifier, code, name: name);
|
||||
signIn(verified);
|
||||
return verified;
|
||||
}
|
||||
|
||||
/// Biased towards the pickup point the customer is working with, so a search
|
||||
/// for a street name answers with the one in their city first.
|
||||
Future<List<Place>> searchPlaces(String query) {
|
||||
final near = draftPickup ?? pickup;
|
||||
return api.searchPlaces(query, lat: near?.lat, lng: near?.lng);
|
||||
}
|
||||
|
||||
/// Backend policy on cancellation; the UI only asks.
|
||||
bool isCancellable(JourneyStage stage) => api.isCancellable(stage);
|
||||
|
||||
/// The window a booking was placed in.
|
||||
///
|
||||
/// Served from whatever [loadSlots] last returned, which is why the index is
|
||||
/// never cleared: a booking made an hour ago must still be able to name its
|
||||
/// window after the slot list has rolled over.
|
||||
PickupSlot? slotById(String? id) =>
|
||||
id == null ? null : _slotIndex[id] ?? api.slotById(id);
|
||||
|
||||
final Map<String, PickupSlot> _slotIndex = {};
|
||||
|
||||
// --------------------------------------------------------------- limits
|
||||
|
||||
/// Caps on one pickup. Backend-owned; the UI never hardcodes them.
|
||||
BookingLimits limits = const BookingLimits();
|
||||
|
||||
/// Caps are allowed to vary by city, so the pickup point goes with the ask.
|
||||
Future<void> loadLimits() async {
|
||||
try {
|
||||
limits = await api.getBookingLimits(pickup: pickup);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
// The safe cap is already in place; a failed fetch must not block
|
||||
// booking. Catching everything, not just ApiException: this runs from
|
||||
// the constructor, and a malformed response is not worth a dead app.
|
||||
debugPrint('[LIMITS] keeping defaults: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- booking draft
|
||||
|
||||
/// The in-progress pickup. One destination is the common case; the customer
|
||||
/// can add more, all collected in the same visit.
|
||||
Place? draftPickup;
|
||||
List<DestinationGroup> draftDestinations = [DestinationGroup()];
|
||||
String? draftSlotId;
|
||||
FareEstimate? draftFare;
|
||||
|
||||
int get draftTotalPackages =>
|
||||
draftDestinations.fold(0, (sum, d) => sum + d.packageCount);
|
||||
|
||||
bool get canAddDestination =>
|
||||
draftDestinations.length < limits.maxDestinations &&
|
||||
draftTotalPackages < limits.maxPackages &&
|
||||
draftDestinations.every((d) => d.isComplete);
|
||||
|
||||
bool canAddPackageTo(int index) => draftTotalPackages < limits.maxPackages;
|
||||
|
||||
/// Every destination has a serviceable state and district.
|
||||
bool get draftReady =>
|
||||
draftDestinations.isNotEmpty &&
|
||||
draftDestinations.every((d) => d.isComplete);
|
||||
|
||||
/// One key per booking *intent*, held across every retry of it.
|
||||
///
|
||||
/// A retried `POST /customer/bookings` carrying the same key replays the
|
||||
/// original response instead of creating a second pickup; a new key is a new
|
||||
/// booking. So it is minted once here, when the customer starts filling the
|
||||
/// form, and cleared only when a booking actually lands.
|
||||
String? _bookingIdempotencyKey;
|
||||
|
||||
void startBooking() {
|
||||
draftPickup = pickup;
|
||||
draftDestinations = [DestinationGroup()];
|
||||
draftSlotId = null;
|
||||
draftFare = null;
|
||||
districtCache.clear();
|
||||
// Slots carry their own date. A session left open across midnight would
|
||||
// otherwise offer yesterday's windows, and booking one is refused — so the
|
||||
// list is re-read for every booking rather than kept for the session.
|
||||
_invalidateSlots();
|
||||
_bookingIdempotencyKey = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Whether "Send to another place" is offered at all.
|
||||
///
|
||||
/// Multi-destination is server-gated while the Miler app still keys its work
|
||||
/// on a booking-level id: a fanned-out pickup would give the rider one stop
|
||||
/// for three parcels. The backend advertises the cap, the client obeys it,
|
||||
/// and a cap of 1 hides the control rather than showing a dead one.
|
||||
bool get allowsMultipleDestinations => limits.allowsMultipleDestinations;
|
||||
|
||||
void selectStateFor(int index, ServiceArea area) {
|
||||
draftDestinations[index].destination
|
||||
..stateCode = area.code
|
||||
..stateName = area.name
|
||||
..districtCode = null
|
||||
..districtName = null;
|
||||
draftDestinations[index].district = null;
|
||||
draftFare = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectDistrictFor(int index, District district) {
|
||||
draftDestinations[index]
|
||||
..destination.districtCode = district.code
|
||||
..destination.districtName = district.name
|
||||
..district = district;
|
||||
notifyListeners();
|
||||
_loadFare();
|
||||
}
|
||||
|
||||
void setPackageCount(int index, int count) {
|
||||
final group = draftDestinations[index];
|
||||
final others = draftTotalPackages - group.packageCount;
|
||||
group.packageCount = count.clamp(1, limits.maxPackages - others);
|
||||
notifyListeners();
|
||||
_loadFare();
|
||||
}
|
||||
|
||||
/// Returns false when the caps say no, so the caller can explain why.
|
||||
bool addDestination() {
|
||||
if (!canAddDestination) return false;
|
||||
draftDestinations.add(DestinationGroup());
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void removeDestination(int index) {
|
||||
if (draftDestinations.length <= 1) return;
|
||||
draftDestinations.removeAt(index);
|
||||
notifyListeners();
|
||||
_loadFare();
|
||||
}
|
||||
|
||||
/// Republish after something mutated the draft in place (the details sheet).
|
||||
void touch() => notifyListeners();
|
||||
|
||||
void selectSlot(PickupSlot slot) {
|
||||
draftSlotId = slot.id;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setDraftPin(int index, MapPin? pin) {
|
||||
draftDestinations[index].details.pin = pin;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Priced as soon as the route is known, so Review has it ready.
|
||||
Future<void> _loadFare() async {
|
||||
final from = draftPickup ?? pickup;
|
||||
if (from == null || !draftReady) return;
|
||||
try {
|
||||
draftFare = await api.estimateFare(
|
||||
pickup: from,
|
||||
destinations: draftDestinations,
|
||||
);
|
||||
notifyListeners();
|
||||
} on ApiException {
|
||||
// A missing estimate is not worth blocking the booking over.
|
||||
}
|
||||
}
|
||||
|
||||
Future<Booking> confirmBooking() async {
|
||||
final key = _bookingIdempotencyKey ??= _newIdempotencyKey();
|
||||
try {
|
||||
final booking = await api.createBooking(
|
||||
pickup: draftPickup ?? pickup!,
|
||||
destinations: draftDestinations,
|
||||
slotId: draftSlotId,
|
||||
fare: draftFare,
|
||||
idempotencyKey: key,
|
||||
);
|
||||
// The intent is spent. A further booking needs a new key or the server
|
||||
// would replay this one.
|
||||
_bookingIdempotencyKey = null;
|
||||
orders.insert(0, booking);
|
||||
activeReference = booking.reference;
|
||||
notifyListeners();
|
||||
return booking;
|
||||
} on ApiException catch (e) {
|
||||
// The slot went stale — filled up, or its window passed while the form
|
||||
// was open. Both recover by re-reading the list, so drop the cached one
|
||||
// here and let the screen send them back to slot selection.
|
||||
if (e.needsFreshSlots) {
|
||||
_invalidateSlots();
|
||||
draftSlotId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
String _newIdempotencyKey() {
|
||||
final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
|
||||
final salt = Object().hashCode.toRadixString(36);
|
||||
return '$now-$salt';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- orders
|
||||
|
||||
final List<Booking> orders = [];
|
||||
String? activeReference;
|
||||
BookingStatus ordersTab = BookingStatus.active;
|
||||
|
||||
Booking? byReference(String? reference) {
|
||||
if (reference == null) return null;
|
||||
for (final o in orders) {
|
||||
if (o.reference == reference) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Booking? get trackedBooking => byReference(activeReference);
|
||||
|
||||
List<Booking> get activeBookings =>
|
||||
orders.where((o) => o.status == BookingStatus.active).toList();
|
||||
|
||||
List<Booking> ofStatus(BookingStatus status) =>
|
||||
orders.where((o) => o.status == status).toList();
|
||||
|
||||
/// Orders as the customer owns them: one row per pickup before collection,
|
||||
/// one row per destination once the backend has created those orders.
|
||||
List<OrderEntry> entriesOf(BookingStatus status) => [
|
||||
for (final booking in ofStatus(status))
|
||||
if (!booking.hasOrders)
|
||||
OrderEntry(booking)
|
||||
else
|
||||
for (var i = 0; i < booking.destinations.length; i++)
|
||||
OrderEntry(booking, i),
|
||||
];
|
||||
|
||||
List<OrderEntry> get recentEntries => [
|
||||
for (final booking in orders.where((o) => o.status != BookingStatus.active))
|
||||
if (!booking.hasOrders)
|
||||
OrderEntry(booking)
|
||||
else
|
||||
for (var i = 0; i < booking.destinations.length; i++)
|
||||
OrderEntry(booking, i),
|
||||
].take(3).toList();
|
||||
|
||||
/// Everything no longer running, newest first — Home's "Recent".
|
||||
List<Booking> get recent =>
|
||||
orders.where((o) => o.status != BookingStatus.active).take(3).toList();
|
||||
|
||||
/// Keyset cursor for the next page of orders; null when the last page has
|
||||
/// been read or none has been read yet.
|
||||
String? ordersCursor;
|
||||
|
||||
/// True while [loadMoreOrders] is in flight, so a scroll cannot start two.
|
||||
bool loadingMoreOrders = false;
|
||||
|
||||
bool get hasMoreOrders => ordersCursor != null && ordersCursor!.isNotEmpty;
|
||||
|
||||
/// Pull-to-refresh on Home and Orders — reads the first page again.
|
||||
///
|
||||
/// The list is the server's: whatever comes back replaces what is on screen,
|
||||
/// including an empty page, which is a real answer and not a failure.
|
||||
Future<void> refreshOrders() async {
|
||||
try {
|
||||
final page = await api.getBookingPage();
|
||||
// An empty first page is a real answer: this customer has no bookings.
|
||||
// The list is the server's, so it is adopted whole rather than merged.
|
||||
orders
|
||||
..clear()
|
||||
..addAll(page.bookings);
|
||||
ordersCursor = page.nextCursor;
|
||||
} on ApiException {
|
||||
// A failed refresh leaves the last good list on screen.
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Appends the next keyset page. Keyset rather than offset, so a booking
|
||||
/// arriving at the top mid-scroll cannot push a row into a second page and
|
||||
/// show it twice.
|
||||
Future<void> loadMoreOrders() async {
|
||||
if (!hasMoreOrders || loadingMoreOrders) return;
|
||||
loadingMoreOrders = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final page = await api.getBookingPage(cursor: ordersCursor);
|
||||
final seen = orders.map((o) => o.reference).toSet();
|
||||
orders.addAll(page.bookings.where((b) => !seen.contains(b.reference)));
|
||||
ordersCursor = page.nextCursor;
|
||||
} on ApiException {
|
||||
// Keep the rows already on screen; the cursor stays put so the next
|
||||
// scroll retries the same page rather than skipping it.
|
||||
} finally {
|
||||
loadingMoreOrders = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void openTracking(String reference) {
|
||||
activeReference = reference;
|
||||
notifyListeners();
|
||||
unawaited(refreshBooking(reference));
|
||||
}
|
||||
|
||||
/// Re-reads one booking. Null means the server answered `304` — nothing
|
||||
/// moved — so the copy already on screen stands.
|
||||
Future<void> refreshBooking(String reference) async {
|
||||
try {
|
||||
final fresh = await api.getBooking(reference);
|
||||
if (fresh == null) return;
|
||||
final at = orders.indexWhere((o) => o.reference == reference);
|
||||
if (at == -1) {
|
||||
orders.insert(0, fresh);
|
||||
} else {
|
||||
orders[at] = fresh;
|
||||
}
|
||||
notifyListeners();
|
||||
} on ApiException {
|
||||
// Keep showing what we have.
|
||||
}
|
||||
}
|
||||
|
||||
void setOrdersTab(BookingStatus status) {
|
||||
ordersTab = status;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Sends the optional address and recipient for an existing booking.
|
||||
///
|
||||
/// Accepted until the parcels are collected; after that the server answers
|
||||
/// `409` and the Miler's record at the door is the one that counts.
|
||||
Future<void> saveDestinationDetails(Booking booking, int index) async {
|
||||
if (index < 0 || index >= booking.destinations.length) return;
|
||||
final group = booking.destinations[index];
|
||||
await api.updateDestinationDetails(
|
||||
booking.reference,
|
||||
// The server's own position for this stop, which is what the route
|
||||
// addresses. The list position is only a fallback for a payload that
|
||||
// did not send one.
|
||||
group.index ?? index,
|
||||
group.details,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- push
|
||||
|
||||
/// Registers this device for milestone notifications.
|
||||
///
|
||||
/// Nothing calls this yet: no push SDK is wired in, so the app has no token
|
||||
/// to hand over. The endpoint is reached through here so that adding one is
|
||||
/// a two-line change rather than a new path through the state layer.
|
||||
Future<void> registerPushToken(String token) async {
|
||||
try {
|
||||
await api.registerDevice(token);
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[PUSH] could not register device: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unregisterPushToken(String token) async {
|
||||
try {
|
||||
await api.unregisterDevice(token);
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[PUSH] could not unregister device: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- stage transitions
|
||||
|
||||
/// What the tracking screen's QA stepper calls.
|
||||
///
|
||||
/// It asks the server's own helper to move the booking, then re-reads it —
|
||||
/// so what appears on screen is the stage the **server** recorded, never one
|
||||
/// the client applied to its own copy.
|
||||
Future<void> advanceStage(Booking booking, JourneyStage target) async {
|
||||
try {
|
||||
await api.setStage(booking.reference, target);
|
||||
await refreshBooking(booking.reference);
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[OPS] stage override refused: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelBooking(Booking booking, String? reason) async {
|
||||
await api.cancelBooking(booking.reference, reason);
|
||||
booking
|
||||
..status = BookingStatus.cancelled
|
||||
..cancelReason = reason
|
||||
..cancellable = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- serviceability cache
|
||||
|
||||
List<ServiceArea>? statesCache;
|
||||
final Map<String, List<District>> districtCache = {};
|
||||
List<PickupSlot>? slotsCache;
|
||||
|
||||
/// Only states we can actually serve reach the picker. A state with nothing
|
||||
/// open is not an option the customer should have to read past.
|
||||
Future<List<ServiceArea>> loadStates({bool refresh = false}) async {
|
||||
if (refresh) statesCache = null;
|
||||
final all = statesCache ??= await api.getServiceableStates();
|
||||
return all.where((s) => s.hasOpenDistricts).toList();
|
||||
}
|
||||
|
||||
/// Districts the picker offers: serviceable ones only. The full response is
|
||||
/// cached so [upcomingDistricts] can name what is opening without putting
|
||||
/// disabled tiles in front of anyone.
|
||||
Future<List<District>> loadDistricts(
|
||||
String stateCode, {
|
||||
bool refresh = false,
|
||||
}) async {
|
||||
if (refresh) districtCache.remove(stateCode);
|
||||
final all = districtCache[stateCode] ??= await api.getServiceableDistricts(
|
||||
stateCode,
|
||||
);
|
||||
return all.where((d) => d.available).toList();
|
||||
}
|
||||
|
||||
/// Names of districts in this state that are not open yet, for a quiet
|
||||
/// "Coming soon" line. Empty until the districts have been fetched.
|
||||
List<String> upcomingDistricts(String stateCode) => (districtCache[stateCode] ?? const [])
|
||||
.where((d) => !d.available)
|
||||
.map((d) => d.name)
|
||||
.toList();
|
||||
|
||||
/// Bumped whenever [slotsCache] is dropped, so a slot list already on screen
|
||||
/// re-reads instead of showing what it fetched the first time.
|
||||
int slotsEpoch = 0;
|
||||
|
||||
void _invalidateSlots() {
|
||||
slotsCache = null;
|
||||
slotsEpoch++;
|
||||
}
|
||||
|
||||
Future<List<PickupSlot>> loadSlots({bool refresh = false}) async {
|
||||
if (refresh) slotsCache = null;
|
||||
final slots = slotsCache ??= await api.getPickupSlots(pickup: draftPickup);
|
||||
for (final slot in slots) {
|
||||
_slotIndex[slot.id] = slot;
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
void clearCaches() {
|
||||
statesCache = null;
|
||||
districtCache.clear();
|
||||
_invalidateSlots();
|
||||
}
|
||||
}
|
||||
80
lib/ui/format.dart
Normal file
80
lib/ui/format.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
/// Small formatting helpers shared across screens.
|
||||
library;
|
||||
|
||||
String greetingForNow([DateTime? now]) {
|
||||
final h = (now ?? DateTime.now()).hour;
|
||||
if (h < 12) return 'Good morning';
|
||||
if (h < 17) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
|
||||
/// "Just now" / "18 min ago" / "3 hr ago" / "5 d ago"
|
||||
String relativeTime(DateTime? at, [DateTime? now]) {
|
||||
if (at == null) return '';
|
||||
final mins = (now ?? DateTime.now()).difference(at).inMinutes;
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return '$mins min ago';
|
||||
final hrs = (mins / 60).round();
|
||||
if (hrs < 24) return '$hrs hr ago';
|
||||
return '${(hrs / 24).round()} d ago';
|
||||
}
|
||||
|
||||
const _months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
];
|
||||
|
||||
/// "Today" / "Yesterday" / "29 Aug"
|
||||
String dateLabel(DateTime date, [DateTime? now]) {
|
||||
final today = now ?? DateTime.now();
|
||||
bool sameDay(DateTime a, DateTime b) =>
|
||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||
|
||||
if (sameDay(date, today)) return 'Today';
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
if (sameDay(date, yesterday)) return 'Yesterday';
|
||||
return '${date.day} ${_months[date.month - 1]}';
|
||||
}
|
||||
|
||||
/// "SEPTEMBER 2026" — the eyebrow over a tab root's title. Spelled in full
|
||||
/// rather than abbreviated: it is read once, at the top of the screen, and
|
||||
/// "SEP 2026" saves four characters nobody needed saved.
|
||||
String monthLabel(DateTime date) => '${_monthsFull[date.month - 1]} ${date.year}';
|
||||
|
||||
const _monthsFull = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
/// Initials for an avatar, e.g. "Joe Oommen" -> "JO".
|
||||
String initialsOf(String? name) {
|
||||
final n = (name ?? '').trim();
|
||||
if (n.isEmpty) return 'DM';
|
||||
final letters = n
|
||||
.split(RegExp(r'\s+'))
|
||||
.map((p) => p.isEmpty ? '' : p[0])
|
||||
.join();
|
||||
return letters
|
||||
.substring(0, letters.length < 2 ? letters.length : 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
174
lib/ui/screens/account_screen.dart
Normal file
174
lib/ui/screens/account_screen.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/app_config.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/inputs.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import 'auth/login_screen.dart';
|
||||
import 'sheets/place_search_sheet.dart';
|
||||
|
||||
/// Account — profile, preferences, and the build's environment line.
|
||||
///
|
||||
/// The profile folds into the brand header: the avatar takes the slot the other
|
||||
/// tab roots give an action, the customer's name takes the slot the title holds
|
||||
/// elsewhere, and the phone number sits in the recessed track. That removes the
|
||||
/// profile *card* the screen used to open with — a card whose entire content
|
||||
/// was three facts about the person already looking at the screen.
|
||||
class AccountScreen extends StatelessWidget {
|
||||
const AccountScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final customer = app.customer;
|
||||
|
||||
// Empty rather than a stand-in name: this screen is only ever built for a
|
||||
// signed-in customer, and a placeholder here would read as somebody's
|
||||
// actual account.
|
||||
final name = customer?.name ?? '';
|
||||
final phone = customer?.phone ?? '';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmBrandHeader(
|
||||
eyebrow: 'Account',
|
||||
title: name.isEmpty ? 'Your account' : name,
|
||||
leading: DmAvatar(initials: initialsOf(name), onBrand: true),
|
||||
action: DmIconButton(
|
||||
icon: LucideIcons.circleHelp,
|
||||
tooltip: 'Help',
|
||||
background: DmColors.onBrandRaise,
|
||||
foreground: Colors.white,
|
||||
border: Colors.transparent,
|
||||
radius: DmRadius.lg,
|
||||
size: 42,
|
||||
onPressed: () => DmToast.show(context, 'Support is on the way'),
|
||||
),
|
||||
child: phone.isEmpty
|
||||
? null
|
||||
: DmHeaderTrack(
|
||||
trailing: DmTextAction(
|
||||
label: 'Edit',
|
||||
color: Colors.white,
|
||||
minHeight: 24,
|
||||
onPressed: () => DmToast.show(context, 'Editing is coming soon'),
|
||||
),
|
||||
child: Text(
|
||||
phone,
|
||||
style: DmText.monoLg.copyWith(fontSize: 14.5, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 28),
|
||||
children: [
|
||||
const DmMicroHead('Preferences', top: 16),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
DmRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
label: 'Saved addresses',
|
||||
value: app.pickup?.title ?? 'None',
|
||||
showChevron: true,
|
||||
onTap: () async {
|
||||
final place = await showPlaceSearchSheet(context);
|
||||
if (place != null && context.mounted) {
|
||||
app.setPickup(place);
|
||||
DmToast.show(context, 'Pickup location updated');
|
||||
}
|
||||
},
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.bell,
|
||||
label: 'Notifications',
|
||||
value: 'SMS and push',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.wallet,
|
||||
label: 'Payment methods',
|
||||
value: 'UPI',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.circleHelp,
|
||||
label: 'Help and support',
|
||||
showChevron: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
// The environment line is not a control — it is what a bug
|
||||
// report needs in order to be actionable. Always shown when the
|
||||
// session came from a dev token rather than a sign-in.
|
||||
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
|
||||
const DmMicroHead('Build', brand: false),
|
||||
DmCard(
|
||||
tone: DmCardTone.sunken,
|
||||
children: [
|
||||
DmCardCell(
|
||||
// Not a DmRow: that lays a short label against a short
|
||||
// value on one line, and this value is a URL plus every
|
||||
// flag the build carries. It overflowed by 59px the
|
||||
// moment a bypass was on — which is exactly when someone
|
||||
// needs to read it.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('API', style: DmText.eyebrow),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
AppConfig.describe,
|
||||
style: DmText.mono.copyWith(
|
||||
height: 1.5,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
DmButton(
|
||||
label: 'Sign out',
|
||||
kind: DmButtonKind.ghost,
|
||||
danger: true,
|
||||
onPressed: () {
|
||||
app.signOut();
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
||||
children: [
|
||||
const TextSpan(text: 'Doormile Customer '),
|
||||
TextSpan(
|
||||
text: 'v1.0.0',
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
440
lib/ui/screens/auth/auth_scaffold.dart
Normal file
440
lib/ui/screens/auth/auth_scaffold.dart
Normal file
@@ -0,0 +1,440 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
|
||||
/// Shared chrome for the auth screens: a brand panel at the top, a white sheet
|
||||
/// below holding the form.
|
||||
///
|
||||
/// Auth is the only place the app goes full-brand — it sets the tone once, and
|
||||
/// the product itself stays quiet after that.
|
||||
/// Shared chrome for the auth screens: a full-bleed brand block, and a rounded
|
||||
/// sheet lifted over it holding the form.
|
||||
///
|
||||
/// The frame matches the Miler app's onboarding, deliberately — the two apps
|
||||
/// are the same company arriving from two directions, and a customer who has
|
||||
/// seen a Miler at the door should recognise the doorway. One block across sign
|
||||
/// in, sign up and verify, so the three read as one entrance rather than three
|
||||
/// forms that happen to follow each other.
|
||||
///
|
||||
/// Content is **top-anchored, not centred**: the question and its input sit
|
||||
/// high, where the keyboard cannot shove them around. The banner gives back
|
||||
/// half its height the moment a field takes focus, which is what fills the
|
||||
/// screen at rest without stealing room while someone is typing.
|
||||
class AuthScaffold extends StatelessWidget {
|
||||
const AuthScaffold({
|
||||
super.key,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
required this.children,
|
||||
this.footer,
|
||||
this.onBack,
|
||||
this.heroTitle,
|
||||
this.heroSub,
|
||||
this.compactBanner = false,
|
||||
this.badge = true,
|
||||
});
|
||||
|
||||
/// Heading inside the white sheet, above the form. Null on the entry screen,
|
||||
/// where the headline is [heroTitle] and sits on the crimson instead.
|
||||
final String? title;
|
||||
final String? subtitle;
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
/// Pinned below the form, above the safe area.
|
||||
final Widget? footer;
|
||||
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// The promise, set on the brand field itself — "Book a pickup from your
|
||||
/// door." The app's first screen should say what the app is for in the app's
|
||||
/// own voice, at display size, before it asks for anything. Only the entry
|
||||
/// screen carries one; the screens after it have already made the promise.
|
||||
final String? heroTitle;
|
||||
|
||||
/// One supporting line under [heroTitle].
|
||||
final String? heroSub;
|
||||
|
||||
/// Forces the short banner. For screens that raise the keyboard themselves on
|
||||
/// the first frame — otherwise they render the tall block for one frame and
|
||||
/// then animate it away, which is a splash screen wearing a different hat.
|
||||
final bool compactBanner;
|
||||
|
||||
/// The CUSTOMER label beside the mark. It belongs on the way in, where a
|
||||
/// customer might wonder which Doormile app they opened; two screens later
|
||||
/// they know, and it is just a word taking up the mark's room.
|
||||
final bool badge;
|
||||
|
||||
/// How far the sheet is pulled up over the banner.
|
||||
static const double _lift = 18;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
body: Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
// `Scaffold` consumes the bottom view inset, so a child inside
|
||||
// `body` sees zero whether the keyboard is up or not. This
|
||||
// context sits above the Scaffold and still carries the real
|
||||
// value — handing it back lets the banner react to the keyboard
|
||||
// without disturbing the Scaffold's own resizing.
|
||||
MediaQuery(
|
||||
data: MediaQuery.of(context),
|
||||
child: AuthBrandBanner(
|
||||
title: heroTitle,
|
||||
sub: heroSub,
|
||||
badge: badge,
|
||||
compact: compactBanner,
|
||||
back: onBack == null
|
||||
? null
|
||||
: _BackButton(onPressed: onBack!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, -_lift),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.canvas,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(DmRadius.xl),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
// Stretch, not the default centre: the heading block is a
|
||||
// Padding sized to its own text, so a centred column was
|
||||
// pushing "Verify your number" into the middle of the
|
||||
// sheet while the form under it ranged left.
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── The question is pinned, only the form scrolls ──
|
||||
//
|
||||
// Focusing the field made Flutter scroll it into view,
|
||||
// which carried "Verify your number" off the top of the
|
||||
// sheet: the screen asked a question and then hid it
|
||||
// the moment anyone went to answer. The heading is not
|
||||
// part of the scrollable content any more, so it cannot
|
||||
// be scrolled away by the keyboard.
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
26,
|
||||
DmSpace.pad,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title!, style: DmText.title),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(subtitle!, style: DmText.lede),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
title == null ? 26 : 24,
|
||||
DmSpace.pad,
|
||||
12,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (footer != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
4,
|
||||
DmSpace.pad,
|
||||
10 + _lift,
|
||||
),
|
||||
child: footer!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The masthead every auth screen wears.
|
||||
///
|
||||
/// ── Left-aligned, and it speaks ──
|
||||
///
|
||||
/// This was a centred lockup floating in a fixed 210pt block: correct, and
|
||||
/// completely silent. A customer opening the app for the first time was shown a
|
||||
/// logo and then asked for their phone number, with nothing in between saying
|
||||
/// what they were signing up to.
|
||||
///
|
||||
/// The block now reads top-down like the rest of the app — lockup, then the
|
||||
/// promise at display size, then one supporting line — all ranged left on the
|
||||
/// same 24pt margin the sheet below uses, so the crimson field and the white
|
||||
/// sheet share one column rather than looking like two different screens.
|
||||
///
|
||||
/// The wordmark is **knocked out, not re-exported**: `doormile-logo.png` is
|
||||
/// RGBA, so `BlendMode.srcIn` fills every non-transparent pixel white and gives
|
||||
/// a clean reverse of the red original. Shipping a second white asset would
|
||||
/// mean two logo files that drift; this one cannot.
|
||||
class AuthBrandBanner extends StatelessWidget {
|
||||
const AuthBrandBanner({
|
||||
super.key,
|
||||
this.title,
|
||||
this.sub,
|
||||
this.back,
|
||||
this.compact = false,
|
||||
this.badge = true,
|
||||
});
|
||||
|
||||
/// See [AuthScaffold.heroTitle].
|
||||
final String? title;
|
||||
final String? sub;
|
||||
|
||||
/// The back affordance, drawn above the lockup rather than floated over it.
|
||||
final Widget? back;
|
||||
|
||||
/// See [AuthScaffold.compactBanner].
|
||||
final bool compact;
|
||||
|
||||
/// See [AuthScaffold.badge].
|
||||
final bool badge;
|
||||
|
||||
/// The source wordmark is 748×100. Held explicitly so a layout change cannot
|
||||
/// squash the mark.
|
||||
static const double _markAspect = 748 / 100;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// While the keyboard is up the promise gives way: the field the customer is
|
||||
// typing into matters more than the sentence they have already read. The
|
||||
// lockup stays, so the screen never loses its identity mid-form.
|
||||
final typing = compact || MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
|
||||
// Without the badge beside it the mark is the only thing on the field, so
|
||||
// it is given the room the badge was taking rather than sitting small in a
|
||||
// corner of an otherwise empty block.
|
||||
final markWidth = badge ? (typing ? 132.0 : 150.0) : 196.0;
|
||||
|
||||
return ColoredBox(
|
||||
color: DmColors.brand,
|
||||
child: CustomPaint(
|
||||
painter: const DmArcField(opacity: 0.16),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: AnimatedSize(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
back == null ? 14 : 6,
|
||||
DmSpace.pad,
|
||||
typing ? 22 : 34,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (back != null) ...[back!, const SizedBox(height: 14)],
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Semantics(
|
||||
label: 'Doormile',
|
||||
image: true,
|
||||
child: SizedBox(
|
||||
width: markWidth,
|
||||
height: markWidth / _markAspect,
|
||||
child: ColorFiltered(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.white,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/images/doormile-logo.png',
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Which Doormile app this is. Outlined rather than
|
||||
// filled: it is a label on the mark, not a status.
|
||||
if (badge) ...[
|
||||
const SizedBox(width: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0x80FFFFFF)),
|
||||
borderRadius: DmRadius.all(5),
|
||||
),
|
||||
child: Text(
|
||||
'CUSTOMER',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (title != null && !typing) ...[
|
||||
const SizedBox(height: 26),
|
||||
// Held to a narrow measure so the headline breaks where the
|
||||
// design breaks it — two short lines, not one long one that
|
||||
// runs the full width of a phone.
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Text(
|
||||
title!,
|
||||
// Display is inked for the canvas; on the brand field
|
||||
// it knocks out white.
|
||||
style: DmText.display.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (sub != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
sub!,
|
||||
style: DmText.lede.copyWith(color: DmColors.onBrand2),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackButton extends StatelessWidget {
|
||||
const _BackButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: DmColors.onBrandRaise,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
child: InkWell(
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
onTap: onPressed,
|
||||
child: Ink(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
border: Border.all(color: const Color(0x4DFFFFFF)),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.arrowLeft,
|
||||
size: 19,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fine print shared by every auth screen.
|
||||
class AuthLegal extends StatelessWidget {
|
||||
const AuthLegal({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
"By continuing you agree to Doormile's Terms and Privacy Policy.",
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.tiny.copyWith(fontSize: 12, height: 1.55),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cross-link between sign in and sign up.
|
||||
class AuthSwitchLink extends StatelessWidget {
|
||||
const AuthSwitchLink({
|
||||
super.key,
|
||||
required this.question,
|
||||
required this.action,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String question;
|
||||
final String action;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
question,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.small.copyWith(fontSize: 13.5),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
child: Text(
|
||||
action,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13.5,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/ui/screens/auth/login_screen.dart
Normal file
170
lib/ui/screens/auth/login_screen.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/doormile_api.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
import 'otp_screen.dart';
|
||||
import 'signup_screen.dart';
|
||||
|
||||
enum LoginMode { phone, email }
|
||||
|
||||
/// Sign in — phone or email, then a 4-digit code.
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _identifier = TextEditingController();
|
||||
LoginMode _mode = LoginMode.phone;
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_identifier.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_identifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isPhone => _mode == LoginMode.phone;
|
||||
|
||||
bool get _valid => _isPhone
|
||||
? _identifier.text.trim().length >= 10
|
||||
: _identifier.text.contains('@');
|
||||
|
||||
Future<void> _continue() async {
|
||||
final raw = _identifier.text.trim();
|
||||
setState(() => _sending = true);
|
||||
final target = _isPhone ? '+91 $raw' : raw;
|
||||
try {
|
||||
final challenge = await AppScope.read(context).sendOtp(target);
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
|
||||
// ── No code sent, no code screen ──
|
||||
//
|
||||
// `sent: false` is the server saying it accepted the request but did not
|
||||
// deliver anything — the SMS gateway or the mail relay refused it. The
|
||||
// resend path on the next screen has always honoured this flag; this one
|
||||
// did not, and walked the customer to four empty boxes for a code that
|
||||
// was never issued. Everything they can type there is wrong, and the
|
||||
// failure reads to them as "I entered it wrong" rather than "nothing
|
||||
// arrived". Stay here, say what happened, let them try again.
|
||||
if (!challenge.sent) {
|
||||
DmToast.show(
|
||||
context,
|
||||
'We could not send a code to that ${_isPhone ? 'number' : 'address'}. '
|
||||
'Try again in a moment.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
OtpScreen(identifier: target, challenge: challenge),
|
||||
),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, e.message);
|
||||
} catch (_) {
|
||||
// Anything the API layer did not turn into an ApiException still has to
|
||||
// clear the spinner. Without this the button stays mid-send forever and
|
||||
// the screen offers no way forward at all — a worse failure than the one
|
||||
// being reported.
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, 'Something went wrong. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AuthScaffold(
|
||||
// The promise sits on the crimson, at display size, and the sheet gets
|
||||
// on with the form. A "Welcome back" heading in the sheet said nothing
|
||||
// the screen did not already say, and pushed the first control down.
|
||||
heroTitle: 'Book a pickup from your door.',
|
||||
heroSub: 'Track every parcel through to delivery.',
|
||||
// The CTA is pinned, not scrolled. In the scroll area it was clipped by
|
||||
// the footer the moment the keyboard came up — the one control the screen
|
||||
// exists for, hidden exactly when it is needed.
|
||||
footer: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Continue',
|
||||
busy: _sending,
|
||||
busyLabel: 'Sending code…',
|
||||
onPressed: _valid ? _continue : null,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AuthSwitchLink(
|
||||
question: 'New to Doormile?',
|
||||
action: 'Create account',
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const SignUpScreen()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const AuthLegal(),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
// The same chip the Orders filter and the address labels use. One
|
||||
// selection control across the app means the customer learns it once,
|
||||
// on the screen they meet first.
|
||||
Row(
|
||||
children: [
|
||||
for (final mode in LoginMode.values) ...[
|
||||
if (mode != LoginMode.values.first) const SizedBox(width: 8),
|
||||
DmChoiceChip(
|
||||
label: mode == LoginMode.phone ? 'Phone' : 'Email',
|
||||
selected: _mode == mode,
|
||||
expand: true,
|
||||
onTap: () => setState(() {
|
||||
_mode = mode;
|
||||
_identifier.clear();
|
||||
}),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
DmTextField(
|
||||
label: _isPhone ? 'Phone number' : 'Email address',
|
||||
controller: _identifier,
|
||||
hint: _isPhone ? '98765 43210' : 'you@example.com',
|
||||
prefix: _isPhone ? '+91' : null,
|
||||
keyboardType: _isPhone ? TextInputType.phone : TextInputType.emailAddress,
|
||||
maxLength: _isPhone ? 10 : 60,
|
||||
digitsOnly: _isPhone,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _valid ? _continue() : null,
|
||||
onClear: _identifier.text.isEmpty ? null : () => _identifier.clear(),
|
||||
),
|
||||
// Sits under the field it explains rather than floating below the
|
||||
// button, where it read as a fourth thing to deal with.
|
||||
const DmInfoBanner(
|
||||
icon: LucideIcons.shieldCheck,
|
||||
title: 'Your number stays private',
|
||||
message: "We'll send a 4-digit code to keep your parcels secure.",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
369
lib/ui/screens/auth/otp_screen.dart
Normal file
369
lib/ui/screens/auth/otp_screen.dart
Normal file
@@ -0,0 +1,369 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/doormile_api.dart';
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../shell_screen.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
|
||||
/// Verify the code. Shared by sign in and sign up.
|
||||
///
|
||||
/// How many digits and how long until a resend are the **server's** to say —
|
||||
/// they arrive on [challenge] from the call that asked for the code. Nothing
|
||||
/// here is a constant that a backend change could make wrong.
|
||||
class OtpScreen extends StatefulWidget {
|
||||
const OtpScreen({
|
||||
super.key,
|
||||
required this.identifier,
|
||||
this.name,
|
||||
this.challenge = OtpChallenge.unspecified,
|
||||
});
|
||||
|
||||
/// The phone number or email the code was sent to.
|
||||
final String identifier;
|
||||
|
||||
/// Set when arriving from sign up, so the new account keeps its name.
|
||||
final String? name;
|
||||
|
||||
/// What the server said about this challenge.
|
||||
final OtpChallenge challenge;
|
||||
|
||||
@override
|
||||
State<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> {
|
||||
late OtpChallenge _challenge = widget.challenge;
|
||||
|
||||
late final _boxes = List.generate(
|
||||
_challenge.codeLength,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
late final _focus = List.generate(
|
||||
_challenge.codeLength,
|
||||
(_) => FocusNode(),
|
||||
);
|
||||
|
||||
Timer? _ticker;
|
||||
late int _resendIn = _challenge.resendAfterSeconds;
|
||||
bool _verifying = false;
|
||||
|
||||
int get _length => _boxes.length;
|
||||
|
||||
String get _code => _boxes.map((c) => c.text).join();
|
||||
bool get _complete => _code.length == _length;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startResendTimer();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.first.requestFocus());
|
||||
}
|
||||
|
||||
void _startResendTimer() {
|
||||
_ticker?.cancel();
|
||||
setState(() => _resendIn = _challenge.resendAfterSeconds);
|
||||
if (_resendIn <= 0) return;
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (!mounted) return;
|
||||
setState(() => _resendIn--);
|
||||
if (_resendIn <= 0) t.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
for (final c in _boxes) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final f in _focus) {
|
||||
f.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _resend() async {
|
||||
late final OtpChallenge challenge;
|
||||
try {
|
||||
challenge = await AppScope.read(context).sendOtp(widget.identifier);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
DmToast.show(context, e.message);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
// The box count is fixed for the life of this screen — the controllers are
|
||||
// already built — but the resend window can move, so only that is adopted.
|
||||
_challenge = OtpChallenge(
|
||||
codeLength: _length,
|
||||
resendAfterSeconds: challenge.resendAfterSeconds,
|
||||
sent: challenge.sent,
|
||||
);
|
||||
_startResendTimer();
|
||||
DmToast.show(
|
||||
context,
|
||||
challenge.sent ? 'Code sent again' : 'We could not send a code',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _verify() async {
|
||||
setState(() => _verifying = true);
|
||||
final app = AppScope.read(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
await app.verifyOtp(widget.identifier, _code, name: widget.name);
|
||||
if (!mounted) return;
|
||||
app.detectPickupLocation();
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
await navigator.pushAndRemoveUntil(
|
||||
MaterialPageRoute<void>(builder: (_) => const ShellScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _verifying = false);
|
||||
for (final c in _boxes) {
|
||||
c.clear();
|
||||
}
|
||||
_focus.first.requestFocus();
|
||||
DmToast.show(context, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts a pasted code by spreading it across the boxes.
|
||||
void _onChanged(int index, String value) {
|
||||
if (value.length > 1) {
|
||||
final digits = value.replaceAll(RegExp(r'\D'), '');
|
||||
for (var i = 0; i < _boxes.length; i++) {
|
||||
_boxes[i].text = i < digits.length ? digits[i] : '';
|
||||
}
|
||||
_focus[digits.length.clamp(0, _length - 1)].requestFocus();
|
||||
} else if (value.isNotEmpty && index < 3) {
|
||||
_focus[index + 1].requestFocus();
|
||||
} else if (value.isEmpty && index > 0) {
|
||||
_focus[index - 1].requestFocus();
|
||||
}
|
||||
setState(() {});
|
||||
if (_complete && !_verifying) _verify();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AuthScaffold(
|
||||
compactBanner: true,
|
||||
// The mark alone, without the CUSTOMER label: by the third screen of the
|
||||
// entrance nobody is still asking which app this is, and the question on
|
||||
// this screen is a four-digit code, not a brand.
|
||||
badge: false,
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
title: 'Verify your number',
|
||||
subtitle: 'Enter the $_length-digit code we just sent.',
|
||||
footer: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Verify and continue',
|
||||
busy: _verifying,
|
||||
busyLabel: 'Verifying…',
|
||||
onPressed: _complete ? _verify : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const AuthLegal(),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
// The number is restated in mono so the digits can be checked one by
|
||||
// one — this is the screen where a transposed digit means a code that
|
||||
// never arrives — with the way back one tap away.
|
||||
DmCard(
|
||||
tone: DmCardTone.wash,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const DmTile(icon: LucideIcons.shieldCheck, tone: DmTileTone.wash),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.identifier,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.monoLg.copyWith(
|
||||
fontSize: 15.5,
|
||||
color: DmColors.brandPress,
|
||||
),
|
||||
),
|
||||
),
|
||||
DmTextAction(
|
||||
label: 'Change',
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
// Four equal boxes across the column, which is how the code is drawn
|
||||
// in the design and how every other code field a customer has used
|
||||
// behaves. Capped, though: stretched across a tablet each digit would
|
||||
// sit alone in a 150-wide slab and stop looking like a digit.
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: _OtpBox(
|
||||
controller: _boxes[i],
|
||||
focusNode: _focus[i],
|
||||
onChanged: (v) => _onChanged(i, v),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// The countdown is mono so the seconds tick in place instead of the
|
||||
// whole line reflowing every second as the glyph widths change.
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _resendIn > 0
|
||||
? Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.tiny,
|
||||
children: [
|
||||
const TextSpan(text: 'Resend available in '),
|
||||
TextSpan(
|
||||
text: '0:${_resendIn.toString().padLeft(2, '0')}',
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 13,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Text('Did not get it?', style: DmText.tiny),
|
||||
),
|
||||
DmTextAction(
|
||||
label: 'Resend',
|
||||
color: _resendIn > 0 ? DmColors.ink4 : DmColors.brand,
|
||||
onPressed: _resendIn > 0 ? null : _resend,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One digit of the code.
|
||||
///
|
||||
/// Was a filled `TextField` with no border, which read as a disabled field:
|
||||
/// four grey slabs with nothing to say which one you were typing into. It is
|
||||
/// the only thing on the screen, so it carries the state instead — empty is
|
||||
/// outlined, focused takes the brand edge and lifts, filled tints. The
|
||||
/// `TextField` inside is bare; the container owns every visual.
|
||||
class _OtpBox extends StatefulWidget {
|
||||
const _OtpBox({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
State<_OtpBox> createState() => _OtpBoxState();
|
||||
}
|
||||
|
||||
class _OtpBoxState extends State<_OtpBox> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.focusNode.addListener(_onFocus);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.focusNode.removeListener(_onFocus);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocus() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final focused = widget.focusNode.hasFocus;
|
||||
final filled = widget.controller.text.isNotEmpty;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: filled && !focused ? DmColors.brandSoft : DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(
|
||||
color: focused
|
||||
? DmColors.brand
|
||||
: filled
|
||||
? DmColors.brandLine
|
||||
: DmColors.border,
|
||||
),
|
||||
// The focus ring, exactly as the fields draw it — this is the same
|
||||
// control, one digit wide.
|
||||
boxShadow: focused
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: DmColors.brandSoft,
|
||||
spreadRadius: 3,
|
||||
blurRadius: 0,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Center(
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
onChanged: widget.onChanged,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
cursorColor: DmColors.brand,
|
||||
cursorWidth: 2,
|
||||
showCursor: true,
|
||||
style: DmText.otp,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: const InputDecoration(
|
||||
counterText: '',
|
||||
isDense: true,
|
||||
filled: false,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/ui/screens/auth/signup_screen.dart
Normal file
146
lib/ui/screens/auth/signup_screen.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/doormile_api.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import 'auth_scaffold.dart';
|
||||
import 'otp_screen.dart';
|
||||
|
||||
/// Create an account — name and phone, with email optional.
|
||||
///
|
||||
/// The same OTP screen verifies both sign in and sign up; the name is carried
|
||||
/// through so the new account is created with it.
|
||||
class SignUpScreen extends StatefulWidget {
|
||||
const SignUpScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SignUpScreen> createState() => _SignUpScreenState();
|
||||
}
|
||||
|
||||
class _SignUpScreenState extends State<SignUpScreen> {
|
||||
final _name = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [_name, _phone, _email]) {
|
||||
c.addListener(() => setState(() {}));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_name, _phone, _email]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _valid =>
|
||||
_name.text.trim().length >= 2 && _phone.text.trim().length >= 10;
|
||||
|
||||
Future<void> _continue() async {
|
||||
setState(() => _sending = true);
|
||||
final phone = '+91 ${_phone.text.trim()}';
|
||||
try {
|
||||
final challenge = await AppScope.read(context).signUp(
|
||||
name: _name.text.trim(),
|
||||
phone: phone,
|
||||
email: _email.text.trim().isEmpty ? null : _email.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => OtpScreen(
|
||||
identifier: phone,
|
||||
name: _name.text.trim(),
|
||||
challenge: challenge,
|
||||
),
|
||||
),
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
DmToast.show(context, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AuthScaffold(
|
||||
onBack: () => Navigator.of(context).maybePop(),
|
||||
// Same entrance as sign in: the headline sits on the brand field at
|
||||
// display size and the sheet starts straight into the form. Sign in and
|
||||
// sign up are one doorway with two doors, so they cannot be laid out
|
||||
// differently — a customer who bounces between them should see the
|
||||
// screen change its words, not its shape.
|
||||
heroTitle: 'Create your account.',
|
||||
heroSub: 'A few details and you can book your first pickup.',
|
||||
// Pinned for the same reason as sign in: a three-field form plus the
|
||||
// keyboard leaves no room, and the CTA must not be the thing that loses.
|
||||
footer: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Continue',
|
||||
busy: _sending,
|
||||
busyLabel: 'Sending code…',
|
||||
onPressed: _valid ? _continue : null,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
AuthSwitchLink(
|
||||
question: 'Already have an account?',
|
||||
action: 'Sign in',
|
||||
onTap: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const AuthLegal(),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
DmTextField(
|
||||
label: 'Full name',
|
||||
controller: _name,
|
||||
hint: 'Joe Oommen',
|
||||
keyboardType: TextInputType.name,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
DmTextField(
|
||||
label: 'Phone number',
|
||||
controller: _phone,
|
||||
hint: '98765 43210',
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
DmTextField(
|
||||
label: 'Email',
|
||||
optional: true,
|
||||
controller: _email,
|
||||
hint: 'you@example.com',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _valid ? _continue() : null,
|
||||
),
|
||||
// The same reassurance sign in carries, in the same wash card, under
|
||||
// the field it is about.
|
||||
const DmInfoBanner(
|
||||
icon: LucideIcons.shieldCheck,
|
||||
title: 'We verify every number',
|
||||
message: "A 4-digit code confirms it's you and keeps your parcels "
|
||||
'secure.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/ui/screens/booking/booking_routes.dart
Normal file
43
lib/ui/screens/booking/booking_routes.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Named steps in the booking flow.
|
||||
///
|
||||
/// Naming the routes lets Review's "Edit" actions pop straight back to the
|
||||
/// step being corrected instead of stacking a second copy of it.
|
||||
class BookingRoutes {
|
||||
BookingRoutes._();
|
||||
|
||||
static const pickup = 'booking/pickup';
|
||||
static const destination = 'booking/destination';
|
||||
static const slot = 'booking/slot';
|
||||
static const review = 'booking/review';
|
||||
static const confirmed = 'booking/confirmed';
|
||||
static const tracking = 'tracking';
|
||||
}
|
||||
|
||||
/// A page route tagged with its step name.
|
||||
Route<T> bookingRoute<T>(String name, Widget Function(BuildContext) builder) {
|
||||
return MaterialPageRoute<T>(
|
||||
settings: RouteSettings(name: name),
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns to an earlier step if it is still on the stack, otherwise pushes a
|
||||
/// fresh copy of it. The navigator is captured up front because popping
|
||||
/// unmounts the calling screen.
|
||||
void editStep(
|
||||
BuildContext context,
|
||||
String name,
|
||||
Widget Function(BuildContext) builder,
|
||||
) {
|
||||
final navigator = Navigator.of(context);
|
||||
var found = false;
|
||||
navigator.popUntil((route) {
|
||||
if (route.settings.name == name) found = true;
|
||||
return found || route.isFirst;
|
||||
});
|
||||
if (!found) {
|
||||
navigator.push(bookingRoute<void>(name, builder));
|
||||
}
|
||||
}
|
||||
263
lib/ui/screens/booking/confirmed_screen.dart
Normal file
263
lib/ui/screens/booking/confirmed_screen.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/app_config.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/misc.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../../widgets/route_rail.dart';
|
||||
import '../tracking_screen.dart';
|
||||
import 'booking_routes.dart';
|
||||
|
||||
/// Booking confirmed.
|
||||
///
|
||||
/// A booking reference and a reserved window — not a shipment. The tracking
|
||||
/// number appears later, once the Miler completes pickup.
|
||||
///
|
||||
/// ── What this screen stopped doing ──
|
||||
///
|
||||
/// It opened with a green tick in a circle, then a map, then a reference, then
|
||||
/// a summary card, then two banners: five surfaces to say one thing. The tick
|
||||
/// was also the only green in the product, which made the app's happiest moment
|
||||
/// look like a system dialog rather than like Doormile.
|
||||
///
|
||||
/// It is one crimson card now — the payoff — followed by the reference, the
|
||||
/// route as a thread, and the live search for a Miler as a row on that same
|
||||
/// card. The map is gone: the customer chose this route two screens ago and a
|
||||
/// second picture of it says nothing new while the parcel has not moved.
|
||||
class ConfirmedScreen extends StatefulWidget {
|
||||
const ConfirmedScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ConfirmedScreen> createState() => _ConfirmedScreenState();
|
||||
}
|
||||
|
||||
class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _pop = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 520),
|
||||
)..forward();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pop.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final booking = app.trackedBooking;
|
||||
final slot = booking == null ? null : app.slotById(booking.slotId);
|
||||
final packages = booking?.totalPackages ?? 1;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 22, DmSpace.pad, 24),
|
||||
children: [
|
||||
DmCard(
|
||||
tone: DmCardTone.fill,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 22, 18, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ScaleTransition(
|
||||
scale: CurvedAnimation(
|
||||
parent: _pop,
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.onBrandRaise,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Pickup booked',
|
||||
style: DmText.display.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
booking == null
|
||||
? "We've reserved your slot."
|
||||
: "We've reserved your slot. A nearby Miler is being "
|
||||
'assigned to collect '
|
||||
'${packages == 1 ? 'your package' : 'your packages'}.',
|
||||
style: DmText.lede.copyWith(color: DmColors.onBrand2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (booking != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
DmCard(
|
||||
tone: DmCardTone.wash,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 13, 8, 13),
|
||||
child: DmCopyRow(
|
||||
label: 'Booking reference',
|
||||
value: booking.reference,
|
||||
onWash: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
child: DmRouteRail(
|
||||
pickup: booking.pickup.title,
|
||||
pickupNote: booking.pickup.sub,
|
||||
drops: [
|
||||
for (final group in booking.destinations)
|
||||
DmRailStop(
|
||||
label: group.destination.label,
|
||||
note: booking.isMultiDestination
|
||||
? null
|
||||
: group.details.exactAddress,
|
||||
trailing:
|
||||
'${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DmCardCell(
|
||||
background: DmColors.brandSoft,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.calendarCheck,
|
||||
size: 15,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
// The day rides with the label so the window itself
|
||||
// stays on one line. "Scheduled window" against
|
||||
// "Today · 2:00 – 4:00 PM" broke across two, and
|
||||
// wrapped a time range mid-range.
|
||||
slot == null
|
||||
? 'Scheduled window'
|
||||
: 'Scheduled ${slot.day.toLowerCase()}',
|
||||
style: DmText.small.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
slot?.window ?? '—',
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.cardTitle.copyWith(
|
||||
color: DmColors.brandPress,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// The search is live from the moment this screen opens, so it
|
||||
// belongs on the card rather than in a banner underneath it.
|
||||
DmCardCell(
|
||||
background: DmColors.brandSoft,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const DmPill('Searching', tone: DmPillTone.live),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Finding the nearest Miler',
|
||||
style: DmText.small.copyWith(
|
||||
color: DmColors.brand,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 15,
|
||||
height: 15,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
strokeCap: StrokeCap.round,
|
||||
valueColor: AlwaysStoppedAnimation(DmColors.brand),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// The one screen where a fake booking is most convincing, so it
|
||||
// is the one screen that has to admit it.
|
||||
if (AppConfig.useDevData)
|
||||
const DmInfoBanner(
|
||||
icon: LucideIcons.wifiOff,
|
||||
tone: DmTagTone.warn,
|
||||
title: 'Offline dev data — nothing was sent',
|
||||
message: 'This build is not talking to a server, so this '
|
||||
'booking exists only on this phone and will not appear '
|
||||
'in the admin console.',
|
||||
)
|
||||
else
|
||||
const DmInfoBanner(
|
||||
icon: LucideIcons.package,
|
||||
tone: DmTagTone.neutral,
|
||||
title: 'Keep the parcel ready',
|
||||
message: 'Sealed and labelled with the reference above.',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Track pickup',
|
||||
icon: LucideIcons.arrowRight,
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
|
||||
),
|
||||
),
|
||||
DmButton(
|
||||
label: 'Back to home',
|
||||
kind: DmButtonKind.ghost,
|
||||
onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
406
lib/ui/screens/booking/destination_screen.dart
Normal file
406
lib/ui/screens/booking/destination_screen.dart
Normal file
@@ -0,0 +1,406 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../../widgets/states.dart';
|
||||
import 'booking_routes.dart';
|
||||
import 'slot_screen.dart';
|
||||
|
||||
/// Step 2 — where the packages are going.
|
||||
///
|
||||
/// Opens as a single destination, which is the common case: pick a state, pick
|
||||
/// a district, done. Package count is never asked for — it keeps its model
|
||||
/// default of 1 — and sending to a second place is a secondary action that only
|
||||
/// appears once the first one is settled. A customer with one package to one
|
||||
/// district never sees the multi-destination machinery.
|
||||
class DestinationScreen extends StatefulWidget {
|
||||
const DestinationScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DestinationScreen> createState() => _DestinationScreenState();
|
||||
}
|
||||
|
||||
class _DestinationScreenState extends State<DestinationScreen> {
|
||||
/// Which block is open for editing. Null when every block is settled, so
|
||||
/// coming back from Review shows the chosen places rather than re-opening
|
||||
/// the picker.
|
||||
int? _editing;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final groups = AppScope.read(context).draftDestinations;
|
||||
final firstIncomplete = groups.indexWhere((g) => !g.isComplete);
|
||||
_editing = firstIncomplete == -1 ? null : firstIncomplete;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final groups = app.draftDestinations;
|
||||
final limits = app.limits;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Schedule pickup'),
|
||||
body: Column(
|
||||
children: [
|
||||
const DmStepHeader(step: 2, total: 4, label: 'Destination'),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
28,
|
||||
),
|
||||
children: [
|
||||
// The question is the instruction. The line under it said
|
||||
// "Choose from the areas we currently serve", which is what the
|
||||
// list below already demonstrates by only containing those
|
||||
// areas — a caption for a control that captions itself.
|
||||
Text('Where are you sending it?', style: DmText.title),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
for (var i = 0; i < groups.length; i++) ...[
|
||||
_DestinationBlock(
|
||||
index: i,
|
||||
group: groups[i],
|
||||
editing: _editing == i || !groups[i].isComplete,
|
||||
showRemove: groups.length > 1,
|
||||
onEdit: () => setState(() => _editing = i),
|
||||
onSettled: () => setState(() => _editing = null),
|
||||
onRemove: () {
|
||||
app.removeDestination(i);
|
||||
setState(() => _editing = null);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Offered only where the backend allows more than one place.
|
||||
// While `maxDestinations` is 1 there is nothing to explain and
|
||||
// nothing to disable — a control that can never be used is
|
||||
// better absent than permanently greyed.
|
||||
if (app.allowsMultipleDestinations)
|
||||
_AddDestination(
|
||||
enabled: app.canAddDestination,
|
||||
reason: _capReason(app, limits),
|
||||
onTap: () {
|
||||
if (app.addDestination()) {
|
||||
setState(
|
||||
() => _editing = app.draftDestinations.length - 1,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Next',
|
||||
icon: LucideIcons.arrowRight,
|
||||
onPressed: app.draftReady
|
||||
? () => Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.slot,
|
||||
(_) => const SlotScreen(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Friendly, non-alarming explanation when a cap blocks another place.
|
||||
String? _capReason(dynamic app, BookingLimits limits) {
|
||||
if (app.canAddDestination as bool) return null;
|
||||
// Mid-selection is not a problem to explain — the action simply is not
|
||||
// offered yet.
|
||||
if (!(app.draftReady as bool)) return null;
|
||||
if ((app.draftDestinations as List).length >= limits.maxDestinations) {
|
||||
return 'Up to ${limits.maxDestinations} places in one pickup. '
|
||||
'Book another pickup for more.';
|
||||
}
|
||||
return 'Up to ${limits.maxPackages} packages in one pickup.';
|
||||
}
|
||||
}
|
||||
|
||||
/// One place: state, district, how many packages, optional details.
|
||||
class _DestinationBlock extends StatelessWidget {
|
||||
const _DestinationBlock({
|
||||
required this.index,
|
||||
required this.group,
|
||||
required this.editing,
|
||||
required this.showRemove,
|
||||
required this.onEdit,
|
||||
required this.onSettled,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final DestinationGroup group;
|
||||
final bool editing;
|
||||
final bool showRemove;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onSettled;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedSize(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
alignment: Alignment.topCenter,
|
||||
child: editing
|
||||
? _Picker(index: index, group: group, onSettled: onSettled)
|
||||
: _Settled(
|
||||
index: index,
|
||||
group: group,
|
||||
showRemove: showRemove,
|
||||
onEdit: onEdit,
|
||||
onRemove: onRemove,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose the state, then the districts open beneath it.
|
||||
class _Picker extends StatelessWidget {
|
||||
const _Picker({
|
||||
required this.index,
|
||||
required this.group,
|
||||
required this.onSettled,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final DestinationGroup group;
|
||||
final VoidCallback onSettled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final destination = group.destination;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const DmMicroHead('Sending to', top: 0, bottom: 10),
|
||||
DmAsyncList<ServiceArea>(
|
||||
skeletonRows: 1,
|
||||
load: ({bool refresh = false}) => app.loadStates(refresh: refresh),
|
||||
emptyTitle: 'No service areas available',
|
||||
emptyMessage: "We're not accepting new pickups right now. Please check back shortly.",
|
||||
builder: (context, states) => SizedBox(
|
||||
height: 42,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: states.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, i) => DmChoiceChip(
|
||||
label: states[i].name,
|
||||
selected: destination.stateCode == states[i].code,
|
||||
onTap: () => app.selectStateFor(index, states[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (destination.stateCode != null) ...[
|
||||
const SizedBox(height: 18),
|
||||
const DmMicroHead('District', top: 0, bottom: 10),
|
||||
DmAsyncList<District>(
|
||||
key: ValueKey('districts-${destination.stateCode}'),
|
||||
skeletonRows: 3,
|
||||
load: ({bool refresh = false}) =>
|
||||
app.loadDistricts(destination.stateCode!, refresh: refresh),
|
||||
emptyTitle: 'No districts open here yet',
|
||||
emptyMessage:
|
||||
"We're not delivering inside ${destination.stateName} right now. "
|
||||
'Pick another state above.',
|
||||
emptyActionLabel: 'Got it',
|
||||
onEmptyAction: () {},
|
||||
builder: (context, districts) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: districts.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
// The cell grows with the reader's text size rather than
|
||||
// clipping the district name.
|
||||
// One per row as soon as the reader's text grows, so the
|
||||
// place name always has room.
|
||||
crossAxisCount:
|
||||
MediaQuery.textScalerOf(context).scale(1) > 1.15
|
||||
? 1
|
||||
: 2,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisExtent: MediaQuery.textScalerOf(context).scale(70),
|
||||
),
|
||||
itemBuilder: (context, i) {
|
||||
final d = districts[i];
|
||||
return DmGridTile(
|
||||
// Just the place. The delivery promise appears on the
|
||||
// settled card, where it actually informs a decision.
|
||||
label: d.name,
|
||||
selected: destination.districtCode == d.code,
|
||||
onTap: () {
|
||||
app.selectDistrictFor(index, d);
|
||||
onSettled();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
// Places we are opening are named once, quietly — never as a
|
||||
// grid of tiles the customer cannot use.
|
||||
if (app.upcomingDistricts(destination.stateCode!).isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12, left: 2, right: 2),
|
||||
child: Text(
|
||||
'Coming soon: '
|
||||
'${app.upcomingDistricts(destination.stateCode!).join(', ')}',
|
||||
style: DmText.small.copyWith(fontSize: 12.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A chosen place, collapsed to a card with its package count.
|
||||
class _Settled extends StatelessWidget {
|
||||
const _Settled({
|
||||
required this.index,
|
||||
required this.group,
|
||||
required this.showRemove,
|
||||
required this.onEdit,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final DestinationGroup group;
|
||||
final bool showRemove;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCard(
|
||||
tone: DmCardTone.wash,
|
||||
children: [
|
||||
DmCardRow(
|
||||
tone: DmCardTone.wash,
|
||||
onTap: onEdit,
|
||||
leading: const DmTile(
|
||||
icon: LucideIcons.mapPin,
|
||||
tone: DmTileTone.wash,
|
||||
),
|
||||
// Numbered only when there is more than one to tell apart.
|
||||
eyebrow: showRemove ? 'Destination ${index + 1}' : 'Sending to',
|
||||
title: group.destination.label,
|
||||
subtitle: group.district?.promise,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DmTextAction(label: 'Change', onPressed: onEdit),
|
||||
if (showRemove)
|
||||
IconButton(
|
||||
onPressed: onRemove,
|
||||
icon: const Icon(LucideIcons.x, size: 17),
|
||||
color: DmColors.ink3,
|
||||
tooltip: 'Remove this destination',
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// ── Two rows removed: package count, and exact delivery details ──
|
||||
//
|
||||
// A destination block was a card containing a divider, a stepper in
|
||||
// its own row, another divider, and a row that opened a bottom sheet
|
||||
// for optional detail — four decisions stacked under an address the
|
||||
// customer had already given. Almost every booking is one package to
|
||||
// one door, so the common path was paying for two controls it never
|
||||
// touched.
|
||||
//
|
||||
// `packageCount` keeps its model default and the read-only screens
|
||||
// still show it; what is gone is asking. The delivery-details sheet
|
||||
// is deleted outright — see `sheets/`.
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The quiet secondary action that unlocks multi-destination pickups.
|
||||
class _AddDestination extends StatelessWidget {
|
||||
const _AddDestination({
|
||||
required this.enabled,
|
||||
required this.reason,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final String? reason;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!enabled) {
|
||||
return reason == null
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||
child: Text(reason!, style: DmText.small),
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.circlePlus,
|
||||
size: 19,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Send to another place',
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 15,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
324
lib/ui/screens/booking/pickup_location_screen.dart
Normal file
324
lib/ui/screens/booking/pickup_location_screen.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/location_service.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../../state/app_state.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/map_panel.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../sheets/place_search_sheet.dart';
|
||||
import 'booking_routes.dart';
|
||||
import 'destination_screen.dart';
|
||||
|
||||
/// Step 1 — where we collect from.
|
||||
///
|
||||
/// One card: a real map on top with the pin locked to its centre, and the
|
||||
/// address it resolves to in the box directly underneath. Drag the map, the
|
||||
/// address follows. Nothing is typed unless the customer wants to.
|
||||
class PickupLocationScreen extends StatefulWidget {
|
||||
const PickupLocationScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PickupLocationScreen> createState() => _PickupLocationScreenState();
|
||||
}
|
||||
|
||||
class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
/// True between the first touch on the map and the address coming back.
|
||||
bool _pinMoving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Arriving without a pin — ask the device where we are.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final app = AppScope.read(context);
|
||||
if (app.pickup == null && !app.resolvingPickup) {
|
||||
app.detectPickupLocation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _search(AppState app) async {
|
||||
final picked = await showPlaceSearchSheet(context);
|
||||
if (picked == null || !mounted) return;
|
||||
app.setPickup(picked);
|
||||
setState(() => _pinMoving = false);
|
||||
DmToast.show(context, 'Pickup location updated');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final place = app.pickup;
|
||||
final resolving = app.resolvingPickup || _pinMoving;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Schedule pickup'),
|
||||
body: Column(
|
||||
children: [
|
||||
const DmStepHeader(step: 1, total: 4, label: 'Pickup point'),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0),
|
||||
child: _Intro(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// The map and the address it resolves to are one object, so they get
|
||||
// one card. Not a ListView: the map must win vertical drags.
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
14,
|
||||
),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DmMapPanel(
|
||||
lat: app.mapLat,
|
||||
lng: app.mapLng,
|
||||
deviceLat: app.deviceLat,
|
||||
deviceLng: app.deviceLng,
|
||||
deviceAccuracy: app.deviceAccuracy,
|
||||
loading: resolving,
|
||||
borderRadius: BorderRadius.zero,
|
||||
onLocate: () {
|
||||
setState(() => _pinMoving = false);
|
||||
app.detectPickupLocation();
|
||||
},
|
||||
onPinMoveStart: () {
|
||||
if (!_pinMoving) setState(() => _pinMoving = true);
|
||||
},
|
||||
onPinMoved: (lat, lng) {
|
||||
setState(() => _pinMoving = false);
|
||||
app.setPickupFromPin(lat, lng);
|
||||
},
|
||||
),
|
||||
),
|
||||
_AddressBox(
|
||||
title: place?.title,
|
||||
sub: place?.sub,
|
||||
resolving: resolving,
|
||||
onChange: () => _search(app),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (app.locationDenial != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
12,
|
||||
),
|
||||
child: _LocationNotice(
|
||||
denial: app.locationDenial!,
|
||||
onSettings: app.openLocationSettings,
|
||||
onSearch: () => _search(app),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Next',
|
||||
icon: LucideIcons.arrowRight,
|
||||
// Gated on the pin, not just the address: a booking without
|
||||
// coordinates is one the Miler cannot be routed to.
|
||||
onPressed: !app.hasPickupPoint || resolving
|
||||
? null
|
||||
: () {
|
||||
app.draftPickup = place;
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.destination,
|
||||
(_) => const DestinationScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Intro extends StatelessWidget {
|
||||
const _Intro();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Where should we pick up?', style: DmText.title),
|
||||
const SizedBox(height: 8),
|
||||
Text('Drag the map to move the pin.', style: DmText.lede),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The address under the map. It is the same card, so a hairline separates
|
||||
/// them rather than a gap.
|
||||
class _AddressBox extends StatelessWidget {
|
||||
const _AddressBox({
|
||||
required this.title,
|
||||
required this.sub,
|
||||
required this.resolving,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final String? sub;
|
||||
final bool resolving;
|
||||
final VoidCallback onChange;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 10, 14),
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
border: Border(top: BorderSide(color: DmColors.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// The resolved address is the map's answer, so it takes the wash: it
|
||||
// is brand-owned context, not a field waiting to be filled.
|
||||
const DmTile(icon: LucideIcons.mapPin, tone: DmTileTone.wash),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: DmMotion.base,
|
||||
child: resolving
|
||||
? const _ResolvingLines(key: ValueKey('resolving'))
|
||||
: Column(
|
||||
key: ValueKey('$title|$sub'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title ?? 'Locating you…',
|
||||
key: const ValueKey('pickupAddressTitle'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.cardTitle,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
sub ?? 'Please wait a moment',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.small.copyWith(color: DmColors.brandPress),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
DmTextAction(label: 'Change', onPressed: onChange),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the address box shows while the pin is moving: the shape of an
|
||||
/// address, so the card never collapses or jumps.
|
||||
class _ResolvingLines extends StatelessWidget {
|
||||
const _ResolvingLines({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Getting address…',
|
||||
style: DmText.cardTitle.copyWith(color: DmColors.ink3),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 148,
|
||||
height: 9,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brandLine,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown only when the device refused us a position. Says what happened and
|
||||
/// offers the way out, rather than leaving the pin sitting on a default.
|
||||
class _LocationNotice extends StatelessWidget {
|
||||
const _LocationNotice({
|
||||
required this.denial,
|
||||
required this.onSettings,
|
||||
required this.onSearch,
|
||||
});
|
||||
|
||||
final LocationDenial denial;
|
||||
final VoidCallback onSettings;
|
||||
final VoidCallback onSearch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = denial == LocationDenial.deniedForever;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.mapPinOff,
|
||||
size: 18,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
LocationFix.denied(denial).message,
|
||||
style: DmText.small.copyWith(color: DmColors.ink2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DmChipButton(
|
||||
label: settings ? 'Settings' : 'Search',
|
||||
onPressed: settings ? onSettings : onSearch,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
377
lib/ui/screens/booking/review_screen.dart
Normal file
377
lib/ui/screens/booking/review_screen.dart
Normal file
@@ -0,0 +1,377 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../data/doormile_api.dart';
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../../widgets/route_map.dart';
|
||||
import '../../widgets/route_rail.dart';
|
||||
import '../../widgets/summary.dart';
|
||||
import '../sheets/place_search_sheet.dart';
|
||||
import 'booking_routes.dart';
|
||||
import 'confirmed_screen.dart';
|
||||
import 'destination_screen.dart';
|
||||
import 'slot_screen.dart';
|
||||
|
||||
/// Step 4 — review.
|
||||
///
|
||||
/// Reads the same whether the pickup carries one package to one district or
|
||||
/// six across three. Nothing is charged here: the fare is indicative until the
|
||||
/// Miler verifies the packages at the door.
|
||||
class ReviewScreen extends StatefulWidget {
|
||||
const ReviewScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ReviewScreen> createState() => _ReviewScreenState();
|
||||
}
|
||||
|
||||
class _ReviewScreenState extends State<ReviewScreen> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _confirm() async {
|
||||
setState(() => _busy = true);
|
||||
final app = AppScope.read(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
await app.confirmBooking();
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
navigator.pushAndRemoveUntil(
|
||||
bookingRoute<void>(BookingRoutes.confirmed, (_) => const ConfirmedScreen()),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
DmToast.show(context, e.message);
|
||||
// A slot that filled up, or whose window passed while this screen was
|
||||
// open, is not something the customer can fix from Review — the only
|
||||
// move is to pick again. `confirmBooking` has already dropped the stale
|
||||
// list, so going back re-reads it.
|
||||
if (e.needsFreshSlots) {
|
||||
navigator.popUntil(
|
||||
(route) => route.settings.name == BookingRoutes.slot || route.isFirst,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final pickup = app.draftPickup ?? app.pickup;
|
||||
final slot = app.slotById(app.draftSlotId);
|
||||
final groups = app.draftDestinations;
|
||||
final multi = groups.length > 1;
|
||||
final packages = app.draftTotalPackages;
|
||||
final fare = app.draftFare;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Schedule pickup'),
|
||||
body: Column(
|
||||
children: [
|
||||
const DmStepHeader(step: 4, total: 4, label: 'Review'),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
|
||||
children: [
|
||||
// "Pay nothing now" belongs beside the amount, where the
|
||||
// summary already states it — not as a paragraph under the
|
||||
// heading that has to be read before the thing it describes.
|
||||
Text('Review pickup details', style: DmText.title),
|
||||
const SizedBox(height: 18),
|
||||
DmCard(
|
||||
children: [
|
||||
DmRouteMap(
|
||||
height: 178,
|
||||
radius: 0,
|
||||
pickup: pickup,
|
||||
destination: groups.first.hub,
|
||||
pickupLabel: pickup?.title,
|
||||
destinationLabel: multi
|
||||
? '${groups.length} destinations'
|
||||
: groups.first.destination.districtName,
|
||||
badge: _MapBadge(
|
||||
icon: multi ? LucideIcons.route : LucideIcons.arrowRight,
|
||||
label: multi
|
||||
? '${groups.length} destinations'
|
||||
: 'Door to door',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DmSummaryCard(
|
||||
children: [
|
||||
// One journey, one rail: pickup connected to everywhere it
|
||||
// is going, exactly as an order card reads.
|
||||
_RouteSection(
|
||||
pickup: pickup,
|
||||
groups: groups,
|
||||
onEditPickup: () async {
|
||||
final picked = await showPlaceSearchSheet(context);
|
||||
if (picked != null && context.mounted) {
|
||||
app.setPickup(picked);
|
||||
app.draftPickup = picked;
|
||||
}
|
||||
},
|
||||
onChangeDestinations: () => editStep(
|
||||
context,
|
||||
BookingRoutes.destination,
|
||||
(_) => const DestinationScreen(),
|
||||
),
|
||||
),
|
||||
DmSummaryRow(
|
||||
label: 'Pickup time',
|
||||
value: slot?.label ?? '—',
|
||||
actionLabel: 'Change',
|
||||
onAction: () => editStep(
|
||||
context,
|
||||
BookingRoutes.slot,
|
||||
(_) => const SlotScreen(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (multi) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.package,
|
||||
size: 17,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'$packages packages · ${groups.length} destinations',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(fontSize: 14),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'One Miler visit',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.small,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
DmNote(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.small.copyWith(color: DmColors.ink2, height: 1.5),
|
||||
children: [
|
||||
const TextSpan(text: 'This books '),
|
||||
const TextSpan(
|
||||
text: 'one Miler visit',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: DmColors.ink,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: '. Tracking is created once your packages are '
|
||||
'collected, and cancelling is free until then. '
|
||||
'The amount below covers ',
|
||||
),
|
||||
TextSpan(
|
||||
text: '$packages '
|
||||
'${packages == 1 ? 'package' : 'packages'} and is '
|
||||
'confirmed after the Miler verifies them at your '
|
||||
'door.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: [
|
||||
// The price sits directly above the button that agrees to it. It
|
||||
// used to live in a card halfway up the page, which meant the last
|
||||
// thing on screen before "Book Pickup" could easily be a note about
|
||||
// cancellation rather than the amount.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Estimated total', style: DmText.small),
|
||||
// The method is its own line: appended to the label it
|
||||
// made one long string that truncated mid-word, and the
|
||||
// customer reads the two facts separately anyway.
|
||||
if (fare?.paymentMethod != null)
|
||||
Text(
|
||||
fare!.paymentMethod!,
|
||||
style: DmText.tiny.copyWith(fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
fare?.range ?? 'Calculating…',
|
||||
style: DmText.monoLg.copyWith(fontSize: 20, color: DmColors.brand),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DmButton(
|
||||
label: 'Book pickup',
|
||||
icon: LucideIcons.arrowRight,
|
||||
busy: _busy,
|
||||
busyLabel: 'Booking…',
|
||||
onPressed: app.draftReady ? _confirm : null,
|
||||
),
|
||||
DmButton(
|
||||
label: 'Edit details',
|
||||
kind: DmButtonKind.ghost,
|
||||
onPressed: _busy ? null : () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pickup and every destination on one rail, with the two edit actions.
|
||||
class _RouteSection extends StatelessWidget {
|
||||
const _RouteSection({
|
||||
required this.pickup,
|
||||
required this.groups,
|
||||
required this.onEditPickup,
|
||||
required this.onChangeDestinations,
|
||||
});
|
||||
|
||||
final Place? pickup;
|
||||
final List<DestinationGroup> groups;
|
||||
final VoidCallback onEditPickup;
|
||||
final VoidCallback onChangeDestinations;
|
||||
|
||||
bool get _anyMissingDetails => groups.any(
|
||||
(g) => g.details.exactAddress == null && g.details.recipient == null,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final multi = groups.length > 1;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DmRouteRail(
|
||||
pickupEyebrow: 'Pickup',
|
||||
pickup: pickup?.title ?? '—',
|
||||
pickupNote: pickup?.sub,
|
||||
pickupTrailing: DmTextAction(
|
||||
label: 'Edit',
|
||||
onPressed: onEditPickup,
|
||||
minHeight: 32,
|
||||
),
|
||||
dropsEyebrow: 'Sending to',
|
||||
drops: [
|
||||
for (final group in groups)
|
||||
DmRailStop(
|
||||
label: group.destination.label,
|
||||
trailing:
|
||||
'${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
note: multi
|
||||
? null
|
||||
: (group.details.exactAddress ??
|
||||
(group.details.recipient == null
|
||||
? 'Not added — the Miler can confirm this at pickup.'
|
||||
: 'Recipient · ${group.details.recipient}')),
|
||||
),
|
||||
],
|
||||
),
|
||||
// With several places, say it once rather than per line.
|
||||
if (multi && _anyMissingDetails)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8, left: 30),
|
||||
child: Text(
|
||||
'Exact addresses not added — the Miler can confirm these at pickup.',
|
||||
style: DmText.small.copyWith(fontSize: 12.5),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: DmTextAction(
|
||||
label: multi ? 'Change destinations' : 'Change destination',
|
||||
onPressed: onChangeDestinations,
|
||||
minHeight: 36,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapBadge extends StatelessWidget {
|
||||
const _MapBadge({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(6),
|
||||
boxShadow: DmShadow.float,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: DmColors.brand),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
206
lib/ui/screens/booking/slot_screen.dart
Normal file
206
lib/ui/screens/booking/slot_screen.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/option_tile.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../../widgets/states.dart';
|
||||
import '../sheets/place_search_sheet.dart';
|
||||
import 'booking_routes.dart';
|
||||
import 'review_screen.dart';
|
||||
|
||||
/// Step 2 — Pickup timing.
|
||||
///
|
||||
/// Confirms where we are collecting from and which window the customer wants,
|
||||
/// showing only what the backend still has capacity for.
|
||||
class SlotScreen extends StatefulWidget {
|
||||
const SlotScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SlotScreen> createState() => _SlotScreenState();
|
||||
}
|
||||
|
||||
class _SlotScreenState extends State<SlotScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final pickup = app.draftPickup ?? app.pickup;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Schedule pickup'),
|
||||
body: Column(
|
||||
children: [
|
||||
const DmStepHeader(step: 3, total: 4, label: 'Pickup time'),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
|
||||
children: [
|
||||
// Reassurance, not instruction: "A verified Miler arrives at
|
||||
// your door inside the window you pick" restated the heading,
|
||||
// the product, and the list of windows underneath it. The
|
||||
// customer is here to pick a time.
|
||||
Text('When should we come?', style: DmText.title),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Where we are collecting from ──
|
||||
//
|
||||
// A wash card, because it is brand-owned context rather than a
|
||||
// field: the customer already chose this address on the screen
|
||||
// before, and it is here to be confirmed at a glance, not
|
||||
// re-entered. Change is the only control on it.
|
||||
DmCard(
|
||||
tone: DmCardTone.wash,
|
||||
children: [
|
||||
DmCardRow(
|
||||
tone: DmCardTone.wash,
|
||||
leading: const DmTile(
|
||||
icon: LucideIcons.mapPin,
|
||||
tone: DmTileTone.wash,
|
||||
),
|
||||
eyebrow: 'Collecting from',
|
||||
title: pickup?.title ?? 'Locating you…',
|
||||
subtitle: pickup?.sub,
|
||||
trailing: DmTextAction(
|
||||
label: 'Change',
|
||||
onPressed: () async {
|
||||
final picked = await showPlaceSearchSheet(context);
|
||||
if (picked != null && context.mounted) {
|
||||
app.setPickup(picked);
|
||||
app.draftPickup = picked;
|
||||
DmToast.show(context, 'Pickup location updated');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
|
||||
// ---- available windows --------------------------------------
|
||||
// One eyebrow, not two. "Local time (IST)" sat opposite this
|
||||
// one explaining a timezone the customer is standing in.
|
||||
const DmMicroHead('Available windows', top: 0, bottom: 10),
|
||||
DmAsyncList<PickupSlot>(
|
||||
// Re-reads whenever the slot list is invalidated — starting a
|
||||
// booking, or the server refusing a window that has passed.
|
||||
reloadToken: app.slotsEpoch,
|
||||
load: ({bool refresh = false}) => app.loadSlots(refresh: refresh),
|
||||
emptyIcon: LucideIcons.clock,
|
||||
emptyTitle: 'No pickup slots left today',
|
||||
emptyMessage:
|
||||
'All windows are full right now. Try again in a little while.',
|
||||
builder: (context, slots) {
|
||||
final days = <String>[];
|
||||
for (final slot in slots) {
|
||||
if (!days.contains(slot.day)) days.add(slot.day);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final day in days) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10, top: 4),
|
||||
child: Text(day, style: DmText.sectionHead),
|
||||
),
|
||||
for (final slot in slots.where((s) => s.day == day))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _SlotCard(
|
||||
slot: slot,
|
||||
selected: app.draftSlotId == slot.id,
|
||||
onTap: () => app.selectSlot(slot),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Review booking',
|
||||
icon: LucideIcons.arrowRight,
|
||||
onPressed: app.draftSlotId == null
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.review,
|
||||
(_) => const ReviewScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One window.
|
||||
///
|
||||
/// Selection **fills** here (see [DmOptionTile]): on a list of five windows a
|
||||
/// tinted card was too quiet to answer "which one did I pick?" at a glance,
|
||||
/// and this is the last decision before the customer commits to a price.
|
||||
class _SlotCard extends StatelessWidget {
|
||||
const _SlotCard({required this.slot, required this.selected, required this.onTap});
|
||||
|
||||
final PickupSlot slot;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = slot.available;
|
||||
|
||||
return DmOptionTile(
|
||||
title: slot.window,
|
||||
// The time is the thing being chosen, so it is set in the display face
|
||||
// rather than at row weight.
|
||||
titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2),
|
||||
subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'),
|
||||
selected: selected,
|
||||
enabled: enabled,
|
||||
onTap: onTap,
|
||||
tags: [
|
||||
if (slot.tag != null)
|
||||
DmTag(
|
||||
slot.tag!,
|
||||
tone: selected ? DmTagTone.onFill : DmTagTone.brand,
|
||||
icon: LucideIcons.zap,
|
||||
),
|
||||
if (slot.milersNearby > 0)
|
||||
DmTag(
|
||||
'${slot.milersNearby} Milers nearby',
|
||||
tone: selected ? DmTagTone.onFill : DmTagTone.neutral,
|
||||
icon: LucideIcons.userRound,
|
||||
),
|
||||
],
|
||||
// A full window offers the only thing left to do with it.
|
||||
trailing: enabled
|
||||
? null
|
||||
: DmTextAction(
|
||||
label: 'Notify me',
|
||||
color: DmColors.ink3,
|
||||
onPressed: () =>
|
||||
DmToast.show(context, "We'll let you know if it frees up"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
345
lib/ui/screens/home_screen.dart
Normal file
345
lib/ui/screens/home_screen.dart
Normal file
@@ -0,0 +1,345 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../widgets/route_rail.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'booking/pickup_location_screen.dart';
|
||||
import 'shell_screen.dart';
|
||||
import 'sheets/place_search_sheet.dart';
|
||||
import 'tracking_screen.dart';
|
||||
|
||||
/// Home — one question, one dominant action, and whatever is live right now.
|
||||
/// Nothing else competes; history belongs to the Orders tab.
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
|
||||
final firstRun = app.orders.isEmpty;
|
||||
|
||||
// The pickup point is already confirmed on this screen, so booking starts
|
||||
// at the destination — two taps from here to a reserved slot.
|
||||
void startBooking() {
|
||||
AppScope.read(context).startBooking();
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.pickup,
|
||||
(_) => const PickupLocationScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmBrandHeader(
|
||||
eyebrow: 'Welcome, ${_firstName(app.customer?.name)}',
|
||||
title: firstRun ? 'Send your first parcel' : 'Send a parcel',
|
||||
action: InkResponse(
|
||||
onTap: () => ShellScreen.of(context)?.goToTab(2),
|
||||
radius: 26,
|
||||
child: DmAvatar(initials: initialsOf(app.customer?.name), onBrand: true),
|
||||
),
|
||||
// The address is the one fact every booking starts from, so it sits
|
||||
// in the header as a live value the customer can change in place —
|
||||
// not as the first question of the flow.
|
||||
child: DmHeaderTrack(
|
||||
leading: const DmLiveDot(size: 6, color: Colors.white),
|
||||
trailing: const Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 16,
|
||||
color: DmColors.onBrand3,
|
||||
),
|
||||
onTap: () async {
|
||||
final place = await showPlaceSearchSheet(context);
|
||||
if (place != null && context.mounted) app.setPickup(place);
|
||||
},
|
||||
child: Text(
|
||||
app.pickup?.title ?? 'Set a pickup address',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.cardTitle.copyWith(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
backgroundColor: DmColors.surface,
|
||||
onRefresh: () async {
|
||||
await Future.wait([app.detectPickupLocation(), app.refreshOrders()]);
|
||||
},
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28),
|
||||
children: [
|
||||
_BookCta(onTap: startBooking),
|
||||
if (active != null) ...[
|
||||
const DmMicroHead('Happening now'),
|
||||
_ActiveCard(booking: active),
|
||||
],
|
||||
if (firstRun) const _HowItWorks(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _firstName(String? name) {
|
||||
final first = (name ?? '').trim().split(RegExp(r'\s+')).first;
|
||||
return first.isEmpty ? 'there' : first;
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's one action, as a filled crimson card.
|
||||
///
|
||||
/// It was a 68pt bar reading BOOK in tracked capitals — a word with no object,
|
||||
/// which on first run told a new customer nothing about what the app does. The
|
||||
/// card says what will happen ("a Miler collects from your door in a window you
|
||||
/// choose") and puts the action inside it, so the promise and the button are
|
||||
/// one object rather than a shout followed by an explanation elsewhere.
|
||||
class _BookCta extends StatelessWidget {
|
||||
const _BookCta({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCard(
|
||||
tone: DmCardTone.fill,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Book a pickup',
|
||||
style: DmText.heading.copyWith(fontSize: 19, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'A Miler collects from your door in a window you choose.',
|
||||
style: DmText.small.copyWith(color: DmColors.onBrand2),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// White on crimson: inside a filled card the primary action has
|
||||
// to invert, or it disappears into its own background.
|
||||
_WhiteButton(label: 'Start booking', onTap: onTap),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WhiteButton extends StatefulWidget {
|
||||
const _WhiteButton({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_WhiteButton> createState() => _WhiteButtonState();
|
||||
}
|
||||
|
||||
class _WhiteButtonState extends State<_WhiteButton> {
|
||||
bool _down = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: widget.label,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => setState(() => _down = true),
|
||||
onTapUp: (_) => setState(() => _down = false),
|
||||
onTapCancel: () => setState(() => _down = false),
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
// A minimum, not a height: at large accessibility text the label
|
||||
// needs the extra line, and a fixed 46 would clip it.
|
||||
constraints: const BoxConstraints(minHeight: 46),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _down ? DmColors.brandSoft : Colors.white,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.label,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.button.copyWith(fontSize: 15, color: DmColors.brand),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
const Icon(LucideIcons.arrowRight, size: 18, color: DmColors.brand),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown on first run only: three numbered rows that match the booking flow
|
||||
/// exactly, so the first send has no surprises in it.
|
||||
class _HowItWorks extends StatelessWidget {
|
||||
const _HowItWorks();
|
||||
|
||||
static const _steps = [
|
||||
('Tell us where it goes', 'City, address and recipient'),
|
||||
('Pick a two-hour window', 'With live Miler availability'),
|
||||
('Hand it over and track', 'Door to hub to doorstep, with proof'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const DmMicroHead('How it works'),
|
||||
DmCard(
|
||||
children: [
|
||||
for (var i = 0; i < _steps.length; i++)
|
||||
DmCardRow(
|
||||
leading: DmTile(label: '${i + 1}', tone: DmTileTone.brand),
|
||||
title: _steps[i].$1,
|
||||
subtitle: _steps[i].$2,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActiveCard extends StatelessWidget {
|
||||
const _ActiveCard({required this.booking});
|
||||
|
||||
final Booking booking;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.read(context);
|
||||
final slot = app.slotById(booking.slotId);
|
||||
final beforeHandover = booking.stage.index <= JourneyStage.pickedUp.index;
|
||||
final windowLabel = slot == null
|
||||
? 'Pickup scheduled'
|
||||
: '${slot.day} · ${slot.window}';
|
||||
|
||||
final courierLine = switch (booking.stage) {
|
||||
JourneyStage.booked => 'Matching the nearest Miler',
|
||||
JourneyStage.assigned when booking.miler != null =>
|
||||
'${booking.miler!.name} will collect your parcel',
|
||||
JourneyStage.onTheWay when booking.miler != null =>
|
||||
'${booking.miler!.name} is on route'
|
||||
'${booking.milerDistanceKm == null ? '' : ' · ${booking.milerDistanceKm} km away'}',
|
||||
JourneyStage.arrived when booking.miler != null =>
|
||||
'${booking.miler!.name} is at your door',
|
||||
_ =>
|
||||
booking.isMultiDestination
|
||||
? 'To ${booking.destinations.length} destinations'
|
||||
: 'To ${booking.destination.label}',
|
||||
};
|
||||
|
||||
return DmCard(
|
||||
onTap: () {
|
||||
app.openTracking(booking.reference);
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
|
||||
);
|
||||
},
|
||||
children: [
|
||||
DmCardCell(
|
||||
background: DmColors.brandSoft,
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(child: DmPill(booking.stage.milestoneLabel)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
booking.displayId,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.mono,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DmCardCell(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DmRouteRail(
|
||||
compact: true,
|
||||
pickup: booking.pickup.title,
|
||||
drop: booking.isMultiDestination
|
||||
? '${booking.destinations.length} destinations'
|
||||
: booking.destination.label,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(courierLine, style: DmText.small),
|
||||
],
|
||||
),
|
||||
),
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
beforeHandover ? LucideIcons.clock : LucideIcons.truck,
|
||||
size: 15,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
beforeHandover ? windowLabel : 'On its way',
|
||||
style: DmText.small,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
DmTextAction(
|
||||
label: 'Track',
|
||||
icon: LucideIcons.arrowRight,
|
||||
onPressed: () {
|
||||
app.openTracking(booking.reference);
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.tracking,
|
||||
(_) => const TrackingScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
619
lib/ui/screens/order_details_screen.dart
Normal file
619
lib/ui/screens/order_details_screen.dart
Normal file
@@ -0,0 +1,619 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/milestones.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../widgets/pieces.dart';
|
||||
import '../widgets/route_rail.dart';
|
||||
import '../widgets/states.dart';
|
||||
import '../widgets/summary.dart';
|
||||
|
||||
/// A finished order, in full: where it went, what happened when, and exactly
|
||||
/// what it cost.
|
||||
class OrderDetailsScreen extends StatelessWidget {
|
||||
const OrderDetailsScreen({super.key, required this.reference});
|
||||
|
||||
final String reference;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final booking = app.byReference(reference);
|
||||
|
||||
if (booking == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Order details'),
|
||||
body: const DmEmptyState(
|
||||
icon: LucideIcons.package,
|
||||
title: 'Order not found',
|
||||
message: 'It may have been removed.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final cancelled = booking.isCancelled;
|
||||
final slot = app.slotById(booking.slotId);
|
||||
final recipient = booking.details.recipient;
|
||||
final fare = booking.fare;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Order details'),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
|
||||
children: [
|
||||
// ── The outcome, as the screen's one filled surface ──
|
||||
//
|
||||
// Delivered used to be an eyebrow, a pill, a 32pt word and a date,
|
||||
// all lying flat on the canvas above a stack of white cards. The
|
||||
// journey is over; this is the moment the app should feel like
|
||||
// something happened, so it gets the crimson card and everything
|
||||
// underneath it stays a record.
|
||||
if (cancelled)
|
||||
DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const DmPill('Cancelled', tone: DmPillTone.off),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
booking.displayId,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.mono,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Booking cancelled',
|
||||
style: DmText.display.copyWith(fontSize: 25),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Cancelled on ${dateLabel(booking.createdAt)}. '
|
||||
'You were not charged.',
|
||||
style: DmText.small,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
DmCard(
|
||||
tone: DmCardTone.fill,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.onBrandRaise,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 22,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
recipient == null
|
||||
? 'Delivered'
|
||||
: 'Delivered to $recipient',
|
||||
style: DmText.display.copyWith(
|
||||
fontSize: 26,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${booking.destination.districtName} · '
|
||||
'${dateLabel(booking.deliveredAt ?? booking.createdAt)}',
|
||||
style: DmText.small.copyWith(color: DmColors.onBrand2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.package,
|
||||
size: 15,
|
||||
color: DmColors.onBrand3,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
booking.packageSummary ??
|
||||
'${booking.totalPackages} '
|
||||
'${booking.totalPackages == 1 ? 'package' : 'packages'}',
|
||||
style: DmText.small.copyWith(color: DmColors.onBrand2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
booking.displayId,
|
||||
style: DmText.monoSm.copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// ---- where it went ------------------------------------------------
|
||||
_Card(
|
||||
child: DmRouteRail(
|
||||
pickup: booking.pickup.title,
|
||||
pickupNote: booking.pickup.sub,
|
||||
drop: booking.isMultiDestination
|
||||
? '${booking.destinations.length} destinations'
|
||||
: booking.destination.label,
|
||||
dropNote:
|
||||
booking.details.exactAddress ??
|
||||
(recipient == null ? null : 'Recipient · $recipient'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// ---- what the Miler actually collected ----------------------------
|
||||
if (booking.destinations.any((d) => d.verification != null)) ...[
|
||||
_VerificationCard(booking: booking),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
|
||||
// ---- each place it went to, once there was more than one ----------
|
||||
if (booking.isMultiDestination) ...[
|
||||
_Card(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 4),
|
||||
child: Text(
|
||||
'PACKAGES',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
),
|
||||
),
|
||||
for (final group in booking.destinations)
|
||||
_Line(
|
||||
label: group.destination.label,
|
||||
value:
|
||||
'${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
|
||||
// ---- what it cost -------------------------------------------------
|
||||
if (!cancelled) ...[
|
||||
_Card(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'PAYMENT',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'₹${booking.amountPaid ?? fare?.max ?? 0}',
|
||||
style: DmText.monoLg.copyWith(fontSize: 22),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.hairline,
|
||||
),
|
||||
if (fare?.parcel != null)
|
||||
_Line(label: 'Parcel', value: fare!.parcel!),
|
||||
_Line(
|
||||
label: 'Base fare',
|
||||
value: fare == null ? '—' : '₹${fare.min}',
|
||||
),
|
||||
if (booking.routeKm != null)
|
||||
_Line(label: 'Distance', value: '${booking.routeKm} km'),
|
||||
_Line(
|
||||
label: 'Weight adjustment',
|
||||
value: fare == null || booking.amountPaid == null
|
||||
? '—'
|
||||
: '₹${booking.amountPaid! - fare.min}',
|
||||
),
|
||||
if (booking.totalWeightLabel != null)
|
||||
_Line(
|
||||
label: 'Verified weight',
|
||||
value: booking.totalWeightLabel!,
|
||||
),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.hairline,
|
||||
),
|
||||
_Line(
|
||||
label: 'Total paid',
|
||||
value: '₹${booking.amountPaid ?? fare?.max ?? 0}',
|
||||
strong: true,
|
||||
),
|
||||
if (fare?.paymentMethod != null)
|
||||
_Line(label: 'Method', value: fare!.paymentMethod!),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
|
||||
// ---- the facts ----------------------------------------------------
|
||||
DmSummaryCard(
|
||||
children: [
|
||||
DmSummaryRow(
|
||||
label: 'Booking reference',
|
||||
value: booking.reference,
|
||||
mono: true,
|
||||
),
|
||||
for (final group in booking.destinations)
|
||||
if (group.trackingId != null)
|
||||
DmSummaryRow(
|
||||
label: booking.isMultiDestination
|
||||
? 'Tracking · ${group.destination.districtName}'
|
||||
: 'Tracking number',
|
||||
value: group.trackingId!,
|
||||
mono: true,
|
||||
),
|
||||
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
|
||||
if (recipient != null)
|
||||
DmSummaryRow(label: 'Recipient', value: recipient),
|
||||
if (booking.miler != null)
|
||||
DmSummaryRow(
|
||||
label: 'Collected by',
|
||||
value: booking.miler!.name,
|
||||
secondary: booking.miler!.vehicle,
|
||||
),
|
||||
if (cancelled && booking.cancelReason != null)
|
||||
DmSummaryRow(
|
||||
label: 'Cancellation reason',
|
||||
value: booking.cancelReason!,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// ---- what happened, when -----------------------------------------
|
||||
if (!cancelled) ...[
|
||||
const SizedBox(height: 10),
|
||||
DmMilestones(booking: booking, title: 'Journey'),
|
||||
],
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Need help with this order?',
|
||||
kind: DmButtonKind.outline,
|
||||
onPressed: () => DmToast.show(
|
||||
context,
|
||||
'Support chat is not available yet',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Weight and photographs taken by the Miler at the door.
|
||||
///
|
||||
/// This is the customer's record of what was handed over, and the weight the
|
||||
/// final price was calculated from.
|
||||
class _VerificationCard extends StatelessWidget {
|
||||
const _VerificationCard({required this.booking});
|
||||
|
||||
final Booking booking;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = booking.destinations
|
||||
.where((g) => g.verification != null)
|
||||
.toList();
|
||||
final first = groups.first.verification!;
|
||||
|
||||
return _Card(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'COLLECTED AT YOUR DOOR',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Flexible(
|
||||
child: DmTag(
|
||||
'Miler verified',
|
||||
tone: DmTagTone.ok,
|
||||
icon: LucideIcons.badgeCheck,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < groups.length; i++) ...[
|
||||
if (i > 0)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 18),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.hairline,
|
||||
),
|
||||
),
|
||||
_VerifiedGroup(
|
||||
group: groups[i],
|
||||
showDestination: booking.isMultiDestination,
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 4, 18, 16),
|
||||
child: Text(
|
||||
'Weighed and photographed by '
|
||||
'${first.capturedBy ?? 'your Miler'} on '
|
||||
'${dateLabel(first.capturedAt)}.',
|
||||
style: DmText.small.copyWith(fontSize: 12.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VerifiedGroup extends StatelessWidget {
|
||||
const _VerifiedGroup({required this.group, required this.showDestination});
|
||||
|
||||
final DestinationGroup group;
|
||||
final bool showDestination;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final verification = group.verification!;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 12, 18, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
showDestination
|
||||
? group.destination.label
|
||||
: '${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
style: DmText.bodyStrong.copyWith(fontSize: 15),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
verification.weightLabel,
|
||||
style: DmText.monoLg.copyWith(fontSize: 17),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showDestination) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
style: DmText.small,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 76,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: verification.photos.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, i) =>
|
||||
_ParcelPhoto(
|
||||
source: verification.photos[i],
|
||||
index: i + 1,
|
||||
total: verification.photos.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A photograph the Miler took of this parcel at pickup.
|
||||
///
|
||||
/// [source] is whatever `ParcelVerification.photos` carried. It is a fetchable
|
||||
/// URL once the images are stored remotely, and until then it is an opaque
|
||||
/// reference — so the frame is drawn either way and the image only when there
|
||||
/// is genuinely one to load. A broken URL falls back to the same frame rather
|
||||
/// than a broken-image glyph.
|
||||
class _ParcelPhoto extends StatelessWidget {
|
||||
const _ParcelPhoto({
|
||||
required this.source,
|
||||
required this.index,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
final String source;
|
||||
final int index;
|
||||
final int total;
|
||||
|
||||
bool get _isFetchable =>
|
||||
source.startsWith('http://') || source.startsWith('https://');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: 'Pickup photo $index of $total',
|
||||
image: true,
|
||||
child: Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const Center(
|
||||
child: Icon(
|
||||
LucideIcons.package,
|
||||
size: 26,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
if (_isFetchable)
|
||||
Image.network(
|
||||
source,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
Positioned(
|
||||
left: 6,
|
||||
bottom: 5,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$index of $total',
|
||||
style: DmText.monoSm.copyWith(fontSize: 10.5, color: DmColors.ink),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Card extends StatelessWidget {
|
||||
const _Card({required this.child, this.padding = const EdgeInsets.all(16)});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Line extends StatelessWidget {
|
||||
const _Line({required this.label, required this.value, this.strong = false});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final bool strong;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: strong ? DmText.cardTitle : DmText.small,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Figures are mono so a column of them lines up and can be compared
|
||||
// without being read.
|
||||
//
|
||||
// Sized to its own content rather than made a second flex child: two
|
||||
// flex children split the row between them and left the figure
|
||||
// floating in the middle instead of on the card's right edge. The
|
||||
// cap is what keeps that honest at large text — a long value ("Cash
|
||||
// at doorstep" at 1.8x) wraps inside it instead of pushing the row
|
||||
// off the card.
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 170),
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.mono.copyWith(
|
||||
fontSize: strong ? 15 : 13.5,
|
||||
color: strong ? DmColors.brand : DmColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/ui/screens/order_row.dart
Normal file
153
lib/ui/screens/order_row.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../widgets/route_rail.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'order_details_screen.dart';
|
||||
import 'tracking_screen.dart';
|
||||
|
||||
/// One row in Orders, as a pickup → drop card.
|
||||
///
|
||||
/// Before collection the card is the pickup. Afterwards each destination has
|
||||
/// its own tracking number and journey, so it gets its own card with a quiet
|
||||
/// line back to the visit they were collected in.
|
||||
///
|
||||
/// ── Status first, reference beside it ──
|
||||
///
|
||||
/// The state and the identifier sit together in a washed strip across the top,
|
||||
/// and the route sits underneath. A customer scanning this list is asking one
|
||||
/// question — what is happening to my parcel — and the answer is now the first
|
||||
/// thing on every card, at the same height on each, instead of being a pill
|
||||
/// tucked under a route of variable length.
|
||||
class OrderCard extends StatelessWidget {
|
||||
const OrderCard({super.key, required this.entry});
|
||||
|
||||
final OrderEntry entry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.read(context);
|
||||
final booking = entry.booking;
|
||||
final group = entry.group;
|
||||
final live = booking.status == BookingStatus.active;
|
||||
|
||||
final stage = group?.stage ?? booking.stage;
|
||||
final (label, tone) = switch (booking.status) {
|
||||
BookingStatus.cancelled => ('Cancelled', DmPillTone.off),
|
||||
BookingStatus.completed => ('Delivered', DmPillTone.done),
|
||||
BookingStatus.active => (
|
||||
stage.milestoneLabel,
|
||||
// Crimson-filled only while something is actually moving towards the
|
||||
// customer. Everything else in progress holds the outline, so a list
|
||||
// of six active orders has at most a couple of filled pills on it.
|
||||
_isMoving(stage) ? DmPillTone.live : DmPillTone.neutral,
|
||||
),
|
||||
};
|
||||
|
||||
final drop = entry.isPickup
|
||||
? (booking.isMultiDestination
|
||||
? '${booking.destinations.length} destinations'
|
||||
: booking.destination.label)
|
||||
: group!.destination.label;
|
||||
|
||||
final reference = entry.isPickup
|
||||
? booking.reference
|
||||
: (group!.trackingId ?? booking.reference);
|
||||
|
||||
final packages = entry.isPickup ? booking.totalPackages : group!.packageCount;
|
||||
final meta = [
|
||||
'$packages ${packages == 1 ? 'package' : 'packages'}',
|
||||
if (entry.isPickup && booking.isMultiDestination)
|
||||
'${booking.destinations.length} destinations',
|
||||
if (booking.status == BookingStatus.completed && booking.amountPaid != null)
|
||||
'₹${booking.amountPaid} paid'
|
||||
else
|
||||
dateLabel(booking.createdAt),
|
||||
].join(' · ');
|
||||
|
||||
return DmCard(
|
||||
// The spine marks the one card the customer has to do something about.
|
||||
rail: live && stage == JourneyStage.arrived,
|
||||
onTap: () {
|
||||
app.openTracking(booking.reference);
|
||||
Navigator.of(context).push(
|
||||
live
|
||||
? bookingRoute<void>(
|
||||
BookingRoutes.tracking,
|
||||
(_) => TrackingScreen(focusedDestination: entry.destinationIndex),
|
||||
)
|
||||
: MaterialPageRoute<void>(
|
||||
builder: (_) => OrderDetailsScreen(reference: booking.reference),
|
||||
),
|
||||
);
|
||||
},
|
||||
children: [
|
||||
DmCardCell(
|
||||
background: DmColors.brandSoft,
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(child: DmPill(label, tone: tone)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
reference,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.mono,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DmRouteRail(
|
||||
compact: true,
|
||||
pickup: booking.pickup.title,
|
||||
drop: drop,
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
Text(meta, style: DmText.tiny),
|
||||
if (entry.collectedTogether) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
||||
children: [
|
||||
const TextSpan(text: 'Collected on '),
|
||||
TextSpan(
|
||||
text: booking.reference,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// True while the parcel — or the Miler coming for it — is physically on the
|
||||
/// move, which is the only time the loud pill is earned.
|
||||
static bool _isMoving(JourneyStage stage) => switch (stage) {
|
||||
JourneyStage.booked ||
|
||||
JourneyStage.onTheWay ||
|
||||
JourneyStage.arrived ||
|
||||
JourneyStage.outForDelivery =>
|
||||
true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
94
lib/ui/screens/orders_screen.dart
Normal file
94
lib/ui/screens/orders_screen.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/states.dart';
|
||||
import 'order_row.dart';
|
||||
|
||||
/// Orders — current bookings and past ones, as route cards.
|
||||
///
|
||||
/// The month, the title and the filter all live inside one crimson field at
|
||||
/// the top. That is deliberate: the filter is this screen's only control, and
|
||||
/// recessing it into the header means the list below can be pure content with
|
||||
/// no chrome of its own — no title bar competing with the first card, no
|
||||
/// separate count row, because each tab carries its own figure.
|
||||
class OrdersScreen extends StatelessWidget {
|
||||
const OrdersScreen({super.key});
|
||||
|
||||
static const _tabs = BookingStatus.values;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final rows = app.entriesOf(app.ordersTab);
|
||||
final index = _tabs.indexOf(app.ordersTab);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmBrandHeader(
|
||||
eyebrow: monthLabel(DateTime.now()),
|
||||
title: 'Orders',
|
||||
action: DmIconButton(
|
||||
icon: LucideIcons.refreshCw,
|
||||
tooltip: 'Refresh',
|
||||
background: DmColors.onBrandRaise,
|
||||
foreground: Colors.white,
|
||||
border: Colors.transparent,
|
||||
radius: DmRadius.lg,
|
||||
size: 42,
|
||||
onPressed: app.refreshOrders,
|
||||
),
|
||||
child: DmSegmented(
|
||||
labels: const ['Active', 'Completed', 'Cancelled'],
|
||||
counts: [for (final tab in _tabs) app.entriesOf(tab).length],
|
||||
index: index < 0 ? 0 : index,
|
||||
onChanged: (i) => app.setOrdersTab(_tabs[i]),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
backgroundColor: DmColors.surface,
|
||||
onRefresh: app.refreshOrders,
|
||||
child: ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28),
|
||||
itemCount: rows.isEmpty ? 1 : rows.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) =>
|
||||
rows.isEmpty ? _empty(app.ordersTab) : OrderCard(entry: rows[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _empty(BookingStatus tab) {
|
||||
final (title, message) = switch (tab) {
|
||||
BookingStatus.active => (
|
||||
'No active pickups',
|
||||
'Book a pickup and it will show up here.',
|
||||
),
|
||||
BookingStatus.completed => (
|
||||
'Nothing completed yet',
|
||||
'Delivered orders will appear here.',
|
||||
),
|
||||
BookingStatus.cancelled => (
|
||||
'No cancelled bookings',
|
||||
'Anything you cancel will appear here.',
|
||||
),
|
||||
};
|
||||
return DmEmptyState(
|
||||
icon: LucideIcons.package,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/ui/screens/sheets/cancel_sheet.dart
Normal file
140
lib/ui/screens/sheets/cancel_sheet.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
|
||||
/// 17. Cancel booking confirmation.
|
||||
///
|
||||
/// Only reachable while the backend still allows cancellation — see
|
||||
/// `DoormileApi.isCancellable`. Returns true when the booking was cancelled.
|
||||
Future<bool?> showCancelSheet(BuildContext context, Booking booking) {
|
||||
return showDmSheet<bool>(
|
||||
context: context,
|
||||
builder: (context) => _CancelBody(booking: booking),
|
||||
);
|
||||
}
|
||||
|
||||
class _CancelBody extends StatefulWidget {
|
||||
const _CancelBody({required this.booking});
|
||||
final Booking booking;
|
||||
|
||||
@override
|
||||
State<_CancelBody> createState() => _CancelBodyState();
|
||||
}
|
||||
|
||||
class _CancelBodyState extends State<_CancelBody> {
|
||||
static const _reasons = [
|
||||
'Booked by mistake',
|
||||
'Package not ready',
|
||||
'Sending it another day',
|
||||
'Changed the destination',
|
||||
'Other',
|
||||
];
|
||||
|
||||
String? _reason;
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _cancel() async {
|
||||
setState(() => _busy = true);
|
||||
final app = AppScope.read(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
await app.cancelBooking(widget.booking, _reason);
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
navigator.pop(true);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
DmToast.show(context, "Couldn't cancel. Try again.");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.read(context);
|
||||
final slot = app.slotById(widget.booking.slotId);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmSheetHeader(
|
||||
title: 'Cancel this pickup?',
|
||||
subtitle:
|
||||
'${widget.booking.reference} · ${slot?.label ?? 'Pickup scheduled'}. '
|
||||
'You can cancel free of charge until the Miler collects your package.',
|
||||
),
|
||||
Text('REASON (OPTIONAL)', style: DmText.eyebrow.copyWith(color: DmColors.brand)),
|
||||
const SizedBox(height: 10),
|
||||
for (final reason in _reasons) ...[
|
||||
_ReasonTile(
|
||||
label: reason,
|
||||
selected: _reason == reason,
|
||||
onTap: () => setState(() => _reason = reason),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
DmButton(
|
||||
label: 'Keep my pickup',
|
||||
onPressed: _busy ? null : () => Navigator.of(context).pop(false),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DmButton(
|
||||
label: 'Yes, cancel pickup',
|
||||
kind: DmButtonKind.ghost,
|
||||
danger: true,
|
||||
busy: _busy,
|
||||
busyLabel: 'Cancelling…',
|
||||
onPressed: _cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ReasonTile extends StatelessWidget {
|
||||
const _ReasonTile({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
constraints: const BoxConstraints(minHeight: 46),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.brand : DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
border: Border.all(
|
||||
color: selected ? DmColors.brand : DmColors.border,
|
||||
),
|
||||
boxShadow: selected ? DmShadow.liftSm : null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13,
|
||||
color: selected ? Colors.white : DmColors.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/ui/screens/sheets/place_search_sheet.dart
Normal file
177
lib/ui/screens/sheets/place_search_sheet.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/models.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../../state/app_state.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/states.dart';
|
||||
|
||||
/// Alias used from Review, where the sheet edits the pickup point in place.
|
||||
Future<Place?> showPlaceSearchSheetForReview(BuildContext context) =>
|
||||
showPlaceSearchSheet(context);
|
||||
|
||||
/// Pickup-location picker. Returns the chosen [Place], or null if dismissed.
|
||||
Future<Place?> showPlaceSearchSheet(BuildContext context) {
|
||||
return showDmSheet<Place>(
|
||||
context: context,
|
||||
builder: (context) => const _PlaceSearchBody(),
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaceSearchBody extends StatefulWidget {
|
||||
const _PlaceSearchBody();
|
||||
|
||||
@override
|
||||
State<_PlaceSearchBody> createState() => _PlaceSearchBodyState();
|
||||
}
|
||||
|
||||
class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
|
||||
final _query = TextEditingController();
|
||||
Timer? _debounce;
|
||||
late final AppState _app = AppScope.read(context);
|
||||
late Future<List<Place>> _results = _app.searchPlaces('');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 220), () {
|
||||
setState(() => _results = _app.searchPlaces(value));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const DmSheetHeader(
|
||||
title: 'Pickup location',
|
||||
subtitle: 'Search or pick a saved place.',
|
||||
),
|
||||
TextField(
|
||||
controller: _query,
|
||||
onChanged: _onChanged,
|
||||
autofocus: false,
|
||||
cursorColor: DmColors.brand,
|
||||
style: DmText.field,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search street, area or landmark',
|
||||
hintStyle: DmText.field.copyWith(
|
||||
color: DmColors.ink3,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
LucideIcons.search,
|
||||
size: 20,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: DmColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
border: _border(DmColors.lineStrong, 1),
|
||||
enabledBorder: _border(DmColors.lineStrong, 1),
|
||||
focusedBorder: _border(DmColors.brand, 2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: FutureBuilder<List<Place>>(
|
||||
future: _results,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: DmSkeleton(rows: 3),
|
||||
);
|
||||
}
|
||||
final places = snap.data ?? const <Place>[];
|
||||
if (places.isEmpty) {
|
||||
return const DmEmptyState(
|
||||
icon: LucideIcons.searchX,
|
||||
title: 'No places found',
|
||||
message: 'Try a different street or landmark.',
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
for (final place in places)
|
||||
_PlaceRow(
|
||||
place: place,
|
||||
onTap: () => Navigator.of(context).pop(place),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
OutlineInputBorder _border(Color color, double width) => OutlineInputBorder(
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
borderSide: BorderSide(color: color, width: width),
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaceRow extends StatelessWidget {
|
||||
const _PlaceRow({required this.place, required this.onTap});
|
||||
|
||||
final Place place;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 60),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: DmColors.line)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const DmTile(icon: LucideIcons.mapPin),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
place.title,
|
||||
style: DmText.cardTitle,
|
||||
),
|
||||
Text(
|
||||
place.sub,
|
||||
style: DmText.small,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
201
lib/ui/screens/shell_screen.dart
Normal file
201
lib/ui/screens/shell_screen.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
import 'account_screen.dart';
|
||||
import 'home_screen.dart';
|
||||
import 'orders_screen.dart';
|
||||
|
||||
/// Bottom-navigation shell: Home · Orders · Account.
|
||||
///
|
||||
/// Booking and tracking screens are pushed on top, so the tab bar only ever
|
||||
/// shows on the three destinations that need it.
|
||||
class ShellScreen extends StatefulWidget {
|
||||
const ShellScreen({super.key, this.initialTab = 0});
|
||||
|
||||
final int initialTab;
|
||||
|
||||
static ShellScreenState? of(BuildContext context) =>
|
||||
context.findAncestorStateOfType<ShellScreenState>();
|
||||
|
||||
@override
|
||||
State<ShellScreen> createState() => ShellScreenState();
|
||||
}
|
||||
|
||||
class ShellScreenState extends State<ShellScreen> {
|
||||
late int _index = widget.initialTab;
|
||||
|
||||
void goToTab(int index) {
|
||||
if (index == _index) return;
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _index = index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: const [HomeScreen(), OrdersScreen(), AccountScreen()],
|
||||
),
|
||||
bottomNavigationBar: _TabBar(index: _index, onChanged: goToTab),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab bar, fixed to the bottom edge.
|
||||
///
|
||||
/// ── Why it is no longer a floating pill-shaped bar ──
|
||||
///
|
||||
/// It used to hover 10pt above the bottom with a 28pt radius and a shadow
|
||||
/// under it, which put a second rounded surface between the content and the
|
||||
/// gesture bar and made every screen end in two stacked edges. It now sits on
|
||||
/// the edge with a single hairline above it, like the footers in the rest of
|
||||
/// the app.
|
||||
///
|
||||
/// The indicator does the work instead: a crimson pill that travels to the tab
|
||||
/// you pick, with the glyph turning white as it arrives. Selection reads as one
|
||||
/// object moving rather than three tabs changing colour in place — and the
|
||||
/// label under it stays crimson, so the tab is still identifiable in the
|
||||
/// half-second the pill is in flight.
|
||||
class _TabBar extends StatelessWidget {
|
||||
const _TabBar({required this.index, required this.onChanged});
|
||||
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
/// Lucide, not Material. One 1.5px stroke family at one weight, so the three
|
||||
/// glyphs read as a set instead of three different drawing styles — and the
|
||||
/// same set the pushed screens use.
|
||||
static const _items = [
|
||||
(label: 'Home', icon: LucideIcons.house),
|
||||
(label: 'Orders', icon: LucideIcons.package),
|
||||
(label: 'Account', icon: LucideIcons.userRound),
|
||||
];
|
||||
|
||||
/// The travelling pill's size. Narrower than its slot so it reads as an
|
||||
/// object sitting in the row rather than as a selected cell.
|
||||
static const double _pillWidth = 62;
|
||||
static const double _pillHeight = 34;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border(top: BorderSide(color: DmColors.border)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 9, 6, 7),
|
||||
child: SizedBox(
|
||||
// The row is the pill plus the label, and the label is type: at
|
||||
// 1.8x accessibility text a fixed 58 clipped it by two points. The
|
||||
// bar gets taller instead, which is what a larger-text user asked
|
||||
// for.
|
||||
height: _pillHeight +
|
||||
5 +
|
||||
MediaQuery.textScalerOf(context).scale(11.5) * 1.3,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final slot = constraints.maxWidth / _items.length;
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.glide,
|
||||
left: slot * index + (slot - _pillWidth) / 2,
|
||||
width: _pillWidth,
|
||||
top: 0,
|
||||
height: _pillHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: DmShadow.liftSm,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
for (var i = 0; i < _items.length; i++)
|
||||
Expanded(
|
||||
child: _TabButton(
|
||||
label: _items[i].label,
|
||||
icon: _items[i].icon,
|
||||
selected: index == i,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabButton extends StatelessWidget {
|
||||
const _TabButton({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _TabBar._pillHeight,
|
||||
child: Center(
|
||||
child: AnimatedSwitcher(
|
||||
duration: DmMotion.base,
|
||||
child: Icon(
|
||||
icon,
|
||||
key: ValueKey(selected),
|
||||
size: 22,
|
||||
color: selected ? Colors.white : DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 11.5,
|
||||
height: 1.0,
|
||||
color: selected ? DmColors.brand : DmColors.ink3,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
878
lib/ui/screens/tracking_screen.dart
Normal file
878
lib/ui/screens/tracking_screen.dart
Normal file
@@ -0,0 +1,878 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../data/app_config.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../../state/app_state.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/milestones.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../widgets/pieces.dart';
|
||||
import '../widgets/route_map.dart';
|
||||
import '../widgets/route_rail.dart';
|
||||
import '../widgets/states.dart';
|
||||
import '../widgets/summary.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'booking/pickup_location_screen.dart';
|
||||
import 'sheets/cancel_sheet.dart';
|
||||
|
||||
/// Live tracking.
|
||||
///
|
||||
/// One screen for the whole journey. While a Miler is being matched it shows a
|
||||
/// radar; once one is assigned it shows their live position, who they are, and
|
||||
/// how to reach them; after pickup it follows the order to the door.
|
||||
/// Cancellation is offered only while the backend still allows it.
|
||||
class TrackingScreen extends StatelessWidget {
|
||||
const TrackingScreen({super.key, this.focusedDestination});
|
||||
|
||||
/// When set, this screen follows one destination's own order rather than the
|
||||
/// pickup as a whole. Only meaningful once the orders exist.
|
||||
final int? focusedDestination;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final booking = app.trackedBooking;
|
||||
|
||||
if (booking == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Live tracking'),
|
||||
body: const DmEmptyState(
|
||||
icon: LucideIcons.package,
|
||||
title: 'Nothing to track',
|
||||
message: 'Book a pickup to get started.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final cancelled = booking.isCancelled;
|
||||
final slot = app.slotById(booking.slotId);
|
||||
|
||||
// A focused destination follows its own journey; the pickup follows its own.
|
||||
final focused =
|
||||
(focusedDestination != null &&
|
||||
focusedDestination! < booking.destinations.length &&
|
||||
booking.destinations[focusedDestination!].trackingId != null)
|
||||
? booking.destinations[focusedDestination!]
|
||||
: null;
|
||||
final stage = focused?.stage ?? booking.stage;
|
||||
|
||||
final milerEnRoute =
|
||||
!cancelled &&
|
||||
booking.miler != null &&
|
||||
stage.index >= JourneyStage.assigned.index &&
|
||||
stage.index <= JourneyStage.pickedUp.index;
|
||||
final assigning = !cancelled && stage == JourneyStage.booked;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: DmTopBar(
|
||||
title: cancelled ? 'Booking' : 'Live tracking',
|
||||
// The reference belongs in the bar, not in a card three scrolls down:
|
||||
// it is what a customer reads out when they call about this parcel.
|
||||
trailing: Text(
|
||||
focused?.trackingId ?? booking.displayId,
|
||||
style: DmText.mono,
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
_StatusHeader(booking: booking, slot: slot, focused: focused),
|
||||
|
||||
if (assigning)
|
||||
const _AssigningHero()
|
||||
else if (!cancelled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
10,
|
||||
),
|
||||
// The map is a cell of a card, not a floating rounded rectangle
|
||||
// of its own: it gets the card's edge, and the line under it
|
||||
// carries the fact the picture cannot state precisely.
|
||||
child: DmCard(
|
||||
children: [
|
||||
DmRouteMap(
|
||||
height: 178,
|
||||
radius: 0,
|
||||
pickup: booking.pickup,
|
||||
destination: (focused ?? booking.destinations.first).hub,
|
||||
showMiler: milerEnRoute,
|
||||
progress: _routeProgress(stage),
|
||||
pickupLabel: booking.pickup.title,
|
||||
destinationLabel: booking.destination.districtName,
|
||||
badge: milerEnRoute && booking.milerDistanceKm != null
|
||||
? _Badge(
|
||||
icon: LucideIcons.bike,
|
||||
label: booking.milerDistanceKm == 0
|
||||
? 'At your door'
|
||||
: '${booking.miler?.name ?? 'Your Miler'}, '
|
||||
'${booking.milerDistanceKm} km away',
|
||||
)
|
||||
: null,
|
||||
),
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.clock,
|
||||
size: 15,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
// Short enough to sit beside Refresh without
|
||||
// ellipsizing, which is the whole job of this line.
|
||||
milerEnRoute && booking.milerEtaMinutes != null
|
||||
? 'Pickup in about ${booking.milerEtaMinutes} min'
|
||||
: 'Tracking your parcel live',
|
||||
style: DmText.small,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
DmTextAction(
|
||||
label: 'Refresh',
|
||||
onPressed: app.refreshOrders,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (assigning && booking.milersInZone > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
10,
|
||||
),
|
||||
child: DmInfoBanner(
|
||||
icon: LucideIcons.users,
|
||||
title:
|
||||
'${booking.milersInZone} verified Milers active in your zone',
|
||||
message: 'Most pickups are accepted within a few minutes.',
|
||||
),
|
||||
),
|
||||
|
||||
if (milerEnRoute)
|
||||
_MilerCard(person: booking.miler!, role: 'Your Miler'),
|
||||
if (!cancelled &&
|
||||
stage == JourneyStage.outForDelivery &&
|
||||
booking.deliveryAgent != null)
|
||||
_MilerCard(person: booking.deliveryAgent!, role: 'Delivery Miler'),
|
||||
|
||||
if (!cancelled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
10,
|
||||
),
|
||||
child: _Journey(booking: booking),
|
||||
),
|
||||
|
||||
if (!cancelled && booking.hasOrders && booking.isMultiDestination)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
10,
|
||||
),
|
||||
child: _YourPackages(booking: booking, focused: focused),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
child: _RouteOutline(
|
||||
booking: booking,
|
||||
slot: slot,
|
||||
focused: focused,
|
||||
),
|
||||
),
|
||||
|
||||
// Stands in for backend push updates. Real stage changes arrive
|
||||
// from `GET /customer/bookings/{reference}`, so this exists only
|
||||
// where there is no server to send them.
|
||||
if (AppConfig.showStageStepper) _StageStepper(booking: booking),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: _footerActions(context, app, booking),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// How far along the route the Miler is drawn, by stage.
|
||||
static double _routeProgress(JourneyStage stage) => switch (stage) {
|
||||
JourneyStage.assigned => 0.08,
|
||||
JourneyStage.onTheWay => 0.42,
|
||||
JourneyStage.arrived || JourneyStage.pickedUp => 0.92,
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
/// The footer is a hierarchy, not a single button: the helpful action is the
|
||||
/// filled one and cancelling is a text button under it. Cancel used to be a
|
||||
/// full-width outlined button in the danger colour — the most prominent
|
||||
/// control on a screen whose job is to reassure someone that their parcel is
|
||||
/// fine.
|
||||
List<Widget> _footerActions(BuildContext context, AppState app, Booking booking) {
|
||||
if (booking.isCancelled || booking.stage == JourneyStage.delivered) {
|
||||
return [
|
||||
DmButton(
|
||||
label: 'Book another pickup',
|
||||
icon: LucideIcons.arrowRight,
|
||||
onPressed: () {
|
||||
app.startBooking();
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.pickup,
|
||||
(_) => const PickupLocationScreen(),
|
||||
),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
DmButton(
|
||||
label: 'Share live tracking',
|
||||
icon: LucideIcons.share2,
|
||||
kind: DmButtonKind.outline,
|
||||
onPressed: () => DmToast.show(context, 'Sharing is coming soon'),
|
||||
),
|
||||
// Backend policy decides whether cancelling is still possible.
|
||||
if (app.isCancellable(booking.stage))
|
||||
DmButton(
|
||||
label: 'Cancel this pickup',
|
||||
kind: DmButtonKind.ghost,
|
||||
danger: true,
|
||||
onPressed: () async {
|
||||
final cancelled = await showCancelSheet(context, booking);
|
||||
if (cancelled == true && context.mounted) {
|
||||
DmToast.show(context, 'Pickup cancelled');
|
||||
}
|
||||
},
|
||||
)
|
||||
else
|
||||
DmButton(
|
||||
label: 'Need help with this order?',
|
||||
kind: DmButtonKind.ghost,
|
||||
onPressed: () =>
|
||||
DmToast.show(context, 'Support chat is not available yet'),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's one loud surface: what is happening, right now.
|
||||
///
|
||||
/// ── Why this is a filled card and not a heading ──
|
||||
///
|
||||
/// The status used to be an eyebrow, a 32pt headline and a row of chips laid
|
||||
/// straight onto the canvas. It said the right words, but it looked like every
|
||||
/// other heading in the app, so the screen opened with no focal point and the
|
||||
/// map below it — a decoration — became the loudest thing on the page.
|
||||
///
|
||||
/// It is now the one crimson card the design allows per screen. It carries the
|
||||
/// live pulse, the headline, the journey's own progress and the window, so the
|
||||
/// whole answer is in one object above the fold. A cancelled booking drops to a
|
||||
/// plain card: nothing is live, and a crimson field would keep insisting it is.
|
||||
class _StatusHeader extends StatelessWidget {
|
||||
const _StatusHeader({
|
||||
required this.booking,
|
||||
required this.slot,
|
||||
this.focused,
|
||||
});
|
||||
|
||||
final Booking booking;
|
||||
final PickupSlot? slot;
|
||||
|
||||
/// Set when following one destination's own order.
|
||||
final DestinationGroup? focused;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cancelled = booking.isCancelled;
|
||||
final stage = focused?.stage ?? booking.stage;
|
||||
|
||||
final headline = switch (true) {
|
||||
_ when cancelled => 'Booking cancelled',
|
||||
_ when stage == JourneyStage.booked => 'Finding your Miler',
|
||||
_ when stage == JourneyStage.assigned && booking.miler != null =>
|
||||
'${booking.miler!.name} is assigned',
|
||||
_ when stage == JourneyStage.onTheWay && booking.miler != null =>
|
||||
booking.milerEtaMinutes == null
|
||||
? '${booking.miler!.name} is on the way'
|
||||
: '${booking.miler!.name} is ${booking.milerEtaMinutes} minutes away',
|
||||
_ when stage == JourneyStage.arrived && booking.miler != null =>
|
||||
'${booking.miler!.name} is at your door',
|
||||
_ => stage.headline,
|
||||
};
|
||||
|
||||
final sub = cancelled
|
||||
? 'This pickup was cancelled. You were not charged.'
|
||||
: stage.sub;
|
||||
|
||||
final total = CustomerMilestone.values.length;
|
||||
final step = CustomerMilestone.of(stage).index + 1;
|
||||
|
||||
if (cancelled) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
child: DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const DmPill('Cancelled', tone: DmPillTone.off),
|
||||
const SizedBox(height: 12),
|
||||
Text(headline, style: DmText.display.copyWith(fontSize: 25)),
|
||||
const SizedBox(height: 6),
|
||||
Text(sub, style: DmText.small),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
child: DmCard(
|
||||
tone: DmCardTone.fill,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const DmLiveDot(size: 9, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'LIVE',
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 11,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (focused != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
'To ${focused!.destination.label}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.small.copyWith(color: DmColors.onBrand3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
headline,
|
||||
style: DmText.display.copyWith(fontSize: 27, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(sub, style: DmText.small.copyWith(color: DmColors.onBrand2)),
|
||||
const SizedBox(height: 16),
|
||||
DmStepBar(
|
||||
progress: step / total,
|
||||
steps: total,
|
||||
current: step,
|
||||
onFill: true,
|
||||
// The milestone, not the window: the window is already the
|
||||
// line underneath, and at this width it clipped to
|
||||
// "Window 2:00 – 4:…".
|
||||
label: CustomerMilestone.of(stage).label,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_footnote(stage),
|
||||
style: DmText.tiny.copyWith(color: DmColors.onBrand3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The one fact under the rail: whatever is most useful at this stage.
|
||||
String _footnote(JourneyStage stage) {
|
||||
if (booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0) {
|
||||
return 'Arriving in about ${booking.milerEtaMinutes} min';
|
||||
}
|
||||
if (stage == JourneyStage.arrived) return 'Waiting at your door';
|
||||
if (stage.index <= JourneyStage.assigned.index && slot != null) {
|
||||
return '${slot!.day}, ${slot!.window}';
|
||||
}
|
||||
if (booking.expectedDelivery != null) {
|
||||
return 'Expected ${booking.expectedDelivery}';
|
||||
}
|
||||
return CustomerMilestone.of(stage).label;
|
||||
}
|
||||
}
|
||||
|
||||
/// The journey, compressed to what just happened.
|
||||
class _Journey extends StatefulWidget {
|
||||
const _Journey({required this.booking});
|
||||
|
||||
final Booking booking;
|
||||
|
||||
@override
|
||||
State<_Journey> createState() => _JourneyState();
|
||||
}
|
||||
|
||||
class _JourneyState extends State<_Journey> {
|
||||
bool _all = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmMilestones(
|
||||
booking: widget.booking,
|
||||
title: widget.booking.trackingId == null ? 'Recent updates' : 'Live journey',
|
||||
maxRows: _all ? null : 3,
|
||||
onExpand: () => setState(() => _all = true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A chip floating on the map — "En route, 1.4 km away".
|
||||
class _Badge extends StatelessWidget {
|
||||
const _Badge({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(6),
|
||||
boxShadow: DmShadow.float,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: DmColors.brand),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Radar shown while the backend matches a Miler.
|
||||
class _AssigningHero extends StatelessWidget {
|
||||
const _AssigningHero();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 20),
|
||||
child: Container(
|
||||
height: 200,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: DmRadarPulse(
|
||||
size: 170,
|
||||
child: Container(
|
||||
width: 62,
|
||||
height: 62,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.bike,
|
||||
size: 28,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Who is collecting, how good they are, and how to reach them.
|
||||
///
|
||||
/// A wash rather than a plain card: this is the one row on the screen about a
|
||||
/// person rather than a parcel, and the tint is what separates "who" from the
|
||||
/// facts stacked above and below it. The plate is mono — it is read off a
|
||||
/// number plate at the door.
|
||||
class _MilerCard extends StatelessWidget {
|
||||
const _MilerCard({required this.person, required this.role});
|
||||
|
||||
final Person person;
|
||||
final String role;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
child: DmCard(
|
||||
tone: DmCardTone.wash,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
DmAvatar(initials: person.initials, size: 44),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(person.name, style: DmText.cardTitle),
|
||||
const SizedBox(height: 2),
|
||||
// Absent unless the backend sent a rating or a pickup
|
||||
// count. A default would put an invented reputation on a
|
||||
// real person's card.
|
||||
if (person.credentials != null)
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.star,
|
||||
size: 12,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
Flexible(
|
||||
child: Text(
|
||||
person.credentials!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.tiny,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(role, style: DmText.tiny),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 122),
|
||||
child: DmTag(person.vehicle, mono: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
DmMiniButton(
|
||||
label: 'Call',
|
||||
icon: LucideIcons.phone,
|
||||
filled: true,
|
||||
onPressed: () => DmToast.show(context, 'Calling ${person.name}…'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DmMiniButton(
|
||||
label: 'Message',
|
||||
icon: LucideIcons.messageSquare,
|
||||
onPressed: () =>
|
||||
DmToast.show(context, 'Chat is not available yet'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where it is going, and the few facts worth keeping on screen.
|
||||
class _RouteOutline extends StatelessWidget {
|
||||
const _RouteOutline({
|
||||
required this.booking,
|
||||
required this.slot,
|
||||
this.focused,
|
||||
});
|
||||
|
||||
final Booking booking;
|
||||
final PickupSlot? slot;
|
||||
final DestinationGroup? focused;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final group = focused;
|
||||
final recipient = (group ?? booking.destinations.first).details.recipient;
|
||||
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
child: DmRouteRail(
|
||||
pickup: booking.pickup.title,
|
||||
pickupNote: booking.pickup.sub,
|
||||
drop: group != null
|
||||
? group.destination.label
|
||||
: booking.isMultiDestination
|
||||
? '${booking.destinations.length} destinations'
|
||||
: booking.destination.label,
|
||||
dropNote: group != null
|
||||
? '${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}'
|
||||
: booking.isMultiDestination
|
||||
? booking.packageSummary
|
||||
: booking.details.exactAddress ??
|
||||
(recipient == null
|
||||
? 'Exact address collected at pickup'
|
||||
: 'Recipient · $recipient'),
|
||||
),
|
||||
),
|
||||
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
|
||||
DmSummaryRow(
|
||||
label: (group?.trackingId ?? booking.trackingId) == null
|
||||
? 'Booking reference'
|
||||
: 'Tracking number',
|
||||
value: group?.trackingId ?? booking.displayId,
|
||||
mono: true,
|
||||
secondary: group == null
|
||||
? null
|
||||
: 'Collected together · Pickup ${booking.reference}',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The individual orders a completed pickup produced.
|
||||
///
|
||||
/// Deliberately absent until the backend has actually created them: before
|
||||
/// collection the customer has one scheduled visit, not several shipments.
|
||||
class _YourPackages extends StatelessWidget {
|
||||
const _YourPackages({required this.booking, this.focused});
|
||||
|
||||
final Booking booking;
|
||||
|
||||
/// The one being followed, so the list can mark it.
|
||||
final DestinationGroup? focused;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'YOUR PACKAGES',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
focused == null
|
||||
? 'Collected in one visit'
|
||||
: 'Pickup ${booking.reference}',
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.tiny,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final group in booking.destinations)
|
||||
_PackageRow(
|
||||
group: group,
|
||||
isFocused: identical(group, focused),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PackageRow extends StatelessWidget {
|
||||
const _PackageRow({required this.group, this.isFocused = false});
|
||||
|
||||
final DestinationGroup group;
|
||||
final bool isFocused;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stage = group.stage;
|
||||
return DmCardCell(
|
||||
background: isFocused ? DmColors.brandSoft : null,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(group.destination.label, style: DmText.cardTitle),
|
||||
const SizedBox(height: 3),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.tiny,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: group.trackingId ?? '—',
|
||||
style: DmText.monoSm,
|
||||
),
|
||||
TextSpan(
|
||||
text: ' · ${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (stage != null)
|
||||
Flexible(
|
||||
child: DmPill(
|
||||
stage.milestoneLabel,
|
||||
tone: stage == JourneyStage.delivered
|
||||
? DmPillTone.done
|
||||
: DmPillTone.neutral,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend's QA stage helper, as a control.
|
||||
///
|
||||
/// It is not a simulator: each tap posts to
|
||||
/// `POST /customer/ops/bookings/{reference}/stage` and the screen then re-reads
|
||||
/// the booking, so what it shows is what the **server** recorded. Off entirely
|
||||
/// unless the build asked for it with `DM_ALLOW_STAGE_OVERRIDE`, and never on
|
||||
/// production or in a release.
|
||||
class _StageStepper extends StatelessWidget {
|
||||
const _StageStepper({required this.booking});
|
||||
|
||||
final Booking booking;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.read(context);
|
||||
final index = booking.stage.index;
|
||||
final locked = booking.isCancelled;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 10, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Move stage (QA)',
|
||||
style: DmText.label.copyWith(color: DmColors.ink3),
|
||||
),
|
||||
),
|
||||
_StepButton(
|
||||
icon: LucideIcons.minus,
|
||||
onTap: locked || index == 0
|
||||
? null
|
||||
: () => app.advanceStage(booking, JourneyStage.at(index - 1)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StepButton(
|
||||
icon: LucideIcons.plus,
|
||||
onTap: locked || index == JourneyStage.values.length - 1
|
||||
? null
|
||||
: () => app.advanceStage(booking, JourneyStage.at(index + 1)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepButton extends StatelessWidget {
|
||||
const _StepButton({required this.icon, this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onTap != null;
|
||||
return Opacity(
|
||||
opacity: enabled ? 1 : 0.35,
|
||||
child: Material(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
child: InkWell(
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
onTap: onTap,
|
||||
child: Ink(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
border: Border.all(color: DmColors.border),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: DmColors.ink2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
395
lib/ui/tokens.dart
Normal file
395
lib/ui/tokens.dart
Normal file
@@ -0,0 +1,395 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Design tokens. Every colour, radius, shadow and text style in the app comes
|
||||
/// from here — screens never hardcode a value.
|
||||
///
|
||||
/// Direction (2026-09-15 rebuild, from the 14-screen design):
|
||||
///
|
||||
/// **Edges, not elevation.** Surfaces are separated by a 1px warm hairline and
|
||||
/// a 10pt radius, not by a shadow. The only shadows left in the system are the
|
||||
/// crimson glow under the one committing action on a screen, the float under a
|
||||
/// control that genuinely hovers over a map, and the sheet lift. A screen that
|
||||
/// drops shadows everywhere has no way left to say "this one matters".
|
||||
///
|
||||
/// **One accent, three intensities.** Crimson fills the header and the primary
|
||||
/// action, washes (`brandSoft` over `brandLine`) carry supporting information,
|
||||
/// and everything else is warm neutral. There is no second hue competing for
|
||||
/// meaning — `ok` survives only for the delivered state, `danger` only for
|
||||
/// destructive text.
|
||||
///
|
||||
/// **Manrope for voice, Geist Mono for data.** Headings run at w800 with tight
|
||||
/// negative tracking; anything a customer might read back aloud or check
|
||||
/// digit-by-digit — a booking reference, a phone number, a fare, a plate, a
|
||||
/// countdown — is set in mono so the glyphs line up and a transposed character
|
||||
/// is visible.
|
||||
class DmColors {
|
||||
DmColors._();
|
||||
|
||||
// ── Brand ────────────────────────────────────────────────────────────────
|
||||
/// The mark's single colour.
|
||||
static const brand = Color(0xFF8F0F06);
|
||||
|
||||
/// Pressed state, and crimson text sitting on a [brandSoft] wash where the
|
||||
/// full brand would vibrate against the tint.
|
||||
static const brandPress = Color(0xFF730C05);
|
||||
static const brandDeep = brandPress;
|
||||
|
||||
/// Tint behind selected options, wash cards and brand chips.
|
||||
static const brandSoft = Color(0xFFFBF3F2);
|
||||
|
||||
/// The wash's own edge, and the outline on quiet brand buttons.
|
||||
static const brandLine = Color(0xFFF0DEDC);
|
||||
|
||||
// ── Ink — warm neutrals, never pure black ────────────────────────────────
|
||||
static const ink = Color(0xFF191716);
|
||||
static const ink2 = Color(0xFF5C5654);
|
||||
static const ink3 = Color(0xFF6E6864);
|
||||
static const ink4 = Color(0xFFB4AEAA);
|
||||
|
||||
// ── Surfaces ─────────────────────────────────────────────────────────────
|
||||
/// The page canvas.
|
||||
static const canvas = Color(0xFFF7F6F4);
|
||||
static const surface = Color(0xFFFFFFFF);
|
||||
|
||||
/// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken"
|
||||
/// treatment for a disabled primary action.
|
||||
static const surfaceAlt = Color(0xFFF1EFEC);
|
||||
|
||||
/// Barely-there divider between rows inside one card.
|
||||
static const hairline = Color(0xFFF1EFEC);
|
||||
|
||||
/// A card's own edge — the workhorse of this design.
|
||||
static const border = Color(0xFFE8E5E2);
|
||||
|
||||
// ── On-brand text ────────────────────────────────────────────────────────
|
||||
/// Secondary text on a crimson field. Not white-at-opacity: a tinted rose
|
||||
/// keeps its warmth where translucent white goes grey and dirty.
|
||||
static const onBrand2 = Color(0xFFFFD9D4);
|
||||
|
||||
/// Tertiary text on a crimson field.
|
||||
static const onBrand3 = Color(0xFFFFD1CB);
|
||||
|
||||
/// Recessed track inside the header (the segmented control's bed).
|
||||
static const onBrandSunken = Color(0x24000000);
|
||||
|
||||
/// A control resting on the crimson field.
|
||||
static const onBrandRaise = Color(0x2EFFFFFF);
|
||||
|
||||
// ── Semantic ─────────────────────────────────────────────────────────────
|
||||
static const ok = Color(0xFF0A6B56);
|
||||
static const okSoft = Color(0xFFE2F4EF);
|
||||
static const danger = Color(0xFFB3231F);
|
||||
|
||||
/// "You are here" on a map. Deliberately not the brand crimson — on a map
|
||||
/// crimson means the pickup pin, and blue is the convention every customer
|
||||
/// already reads as their own position.
|
||||
static const locationDot = Color(0xFF1B6EF3);
|
||||
static const locationHalo = Color(0x241B6EF3);
|
||||
|
||||
static const scrim = Color(0x80191716);
|
||||
|
||||
// Backwards-compatible aliases used across the widget layer.
|
||||
static const bg = canvas;
|
||||
static const surface2 = surfaceAlt;
|
||||
static const line = hairline;
|
||||
static const lineStrong = border;
|
||||
}
|
||||
|
||||
class DmRadius {
|
||||
DmRadius._();
|
||||
|
||||
/// Tiles, chips, the small square that holds a glyph.
|
||||
static const xs = 8.0;
|
||||
|
||||
/// Buttons, fields, icon buttons. The design's most-used corner.
|
||||
static const sm = 9.0;
|
||||
|
||||
/// Cards and every panel that holds rows.
|
||||
static const md = 10.0;
|
||||
|
||||
/// Sheets' grabber area, the segmented control, the header's bottom sweep.
|
||||
static const lg = 12.0;
|
||||
|
||||
/// Bottom sheets and the auth sheet that overlaps the hero.
|
||||
static const xl = 16.0;
|
||||
|
||||
/// The brand header's bottom corners.
|
||||
static const header = 18.0;
|
||||
|
||||
static const pill = 999.0;
|
||||
|
||||
/// Kept for call sites that mean "a primary bar's corner".
|
||||
static const book = sm;
|
||||
|
||||
static BorderRadius all(double r) => BorderRadius.circular(r);
|
||||
}
|
||||
|
||||
class DmSpace {
|
||||
DmSpace._();
|
||||
|
||||
/// Horizontal page padding. 22 on the brand header's body, 24 on canvas
|
||||
/// content — the header's crimson field reads as tighter than it measures,
|
||||
/// so it takes the smaller inset.
|
||||
static const pad = 24.0;
|
||||
static const headerPad = 22.0;
|
||||
|
||||
/// Minimum touch target.
|
||||
static const tap = 48.0;
|
||||
|
||||
/// Primary button height.
|
||||
static const button = 54.0;
|
||||
|
||||
/// Field height — matched to [button] so a form and its action share a rhythm.
|
||||
static const field = 54.0;
|
||||
}
|
||||
|
||||
class DmShadow {
|
||||
DmShadow._();
|
||||
|
||||
/// Under the one committing action on a screen, and under a selected slot.
|
||||
static const lift = [
|
||||
BoxShadow(
|
||||
color: Color(0x808F0F06),
|
||||
blurRadius: 24,
|
||||
spreadRadius: -12,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
];
|
||||
|
||||
/// A smaller version of [lift] for chips and half-width controls.
|
||||
static const liftSm = [
|
||||
BoxShadow(
|
||||
color: Color(0xA68F0F06),
|
||||
blurRadius: 16,
|
||||
spreadRadius: -8,
|
||||
offset: Offset(0, 7),
|
||||
),
|
||||
];
|
||||
|
||||
/// Controls that genuinely float over content — map buttons, map chips.
|
||||
static const float = [
|
||||
BoxShadow(
|
||||
color: Color(0x0D191716),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
];
|
||||
|
||||
static const sheet = [
|
||||
BoxShadow(
|
||||
color: Color(0x2E191716),
|
||||
blurRadius: 40,
|
||||
spreadRadius: -20,
|
||||
offset: Offset(0, -8),
|
||||
),
|
||||
];
|
||||
|
||||
// Aliases kept for existing call sites.
|
||||
static const two = float;
|
||||
static const three = sheet;
|
||||
}
|
||||
|
||||
class DmMotion {
|
||||
DmMotion._();
|
||||
static const fast = Duration(milliseconds: 140);
|
||||
static const base = Duration(milliseconds: 250);
|
||||
static const slow = Duration(milliseconds: 460);
|
||||
static const ease = Curves.easeOutCubic;
|
||||
|
||||
/// The bottom nav's travelling pill — a touch of overshoot, which reads as
|
||||
/// the indicator catching up rather than teleporting.
|
||||
static const glide = Cubic(0.34, 1.06, 0.36, 1);
|
||||
}
|
||||
|
||||
/// Manrope for voice, Geist Mono for data.
|
||||
///
|
||||
/// Both ship as single variable-font files, which is why every style below sets
|
||||
/// `fontVariations` as well as `fontWeight` — see the note inside the class.
|
||||
class DmText {
|
||||
DmText._();
|
||||
|
||||
static const _f = 'Manrope';
|
||||
static const _mono = 'GeistMono';
|
||||
|
||||
// The weight axis. Manrope and Geist Mono both ship as variable fonts, so a
|
||||
// style has to move `wght` to change weight — `fontWeight` alone picks the
|
||||
// nearest named instance and, on a single-file VF, that is always the
|
||||
// default. Both are set on every style: the axis does the work, the enum
|
||||
// keeps the semantics (and the system-font fallback) correct.
|
||||
static const _w500 = [FontVariation('wght', 500)];
|
||||
static const _w600 = [FontVariation('wght', 600)];
|
||||
static const _w700 = [FontVariation('wght', 700)];
|
||||
static const _w800 = [FontVariation('wght', 800)];
|
||||
|
||||
// Letter spacing is quoted in em by the design and converted here at each
|
||||
// size, because -0.035em is a different number of pixels on a 31pt display
|
||||
// head than on a 15pt row title, and splitting the difference would soften
|
||||
// exactly the heads that are supposed to feel tight.
|
||||
|
||||
// ── Display ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away".
|
||||
static const display = TextStyle(
|
||||
fontFamily: _f, fontSize: 31, height: 1.12,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
letterSpacing: -1.09, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// The brand header's own title, sitting on crimson.
|
||||
static const headerTitle = TextStyle(
|
||||
fontFamily: _f, fontSize: 34, height: 1.0,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
letterSpacing: -1.36, color: Colors.white,
|
||||
);
|
||||
|
||||
/// Screen headings on a canvas.
|
||||
static const title = TextStyle(
|
||||
fontFamily: _f, fontSize: 25, height: 1.18,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
letterSpacing: -0.75, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// Tab-root page titles where no brand header is used.
|
||||
static const pageTitle = title;
|
||||
|
||||
/// The heading above a group of cards, and a sheet's own title.
|
||||
static const heading = TextStyle(
|
||||
fontFamily: _f, fontSize: 18, height: 1.25,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: -0.45, color: DmColors.ink,
|
||||
);
|
||||
|
||||
static const sheetTitle = TextStyle(
|
||||
fontFamily: _f, fontSize: 19, height: 1.2,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
letterSpacing: -0.57, color: DmColors.ink,
|
||||
);
|
||||
|
||||
// ── Rows ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A row's own line — `t1` in the design, and the workhorse of the app.
|
||||
static const cardTitle = TextStyle(
|
||||
fontFamily: _f, fontSize: 15, height: 1.4,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: -0.18, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// Alias: the same weight of statement, used mid-form.
|
||||
static const bodyStrong = cardTitle;
|
||||
|
||||
static const body = TextStyle(
|
||||
fontFamily: _f, fontSize: 15, height: 1.5,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
letterSpacing: -0.12, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// Supporting line under a row title — `t2`.
|
||||
static const small = TextStyle(
|
||||
fontFamily: _f, fontSize: 13.5, height: 1.45,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
/// The quietest line — `t3`. Timestamps, counts, "collected on".
|
||||
static const tiny = TextStyle(
|
||||
fontFamily: _f, fontSize: 13, height: 1.4,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
color: DmColors.ink3,
|
||||
);
|
||||
|
||||
/// Paragraph under a screen heading.
|
||||
static const lede = TextStyle(
|
||||
fontFamily: _f, fontSize: 14.5, height: 1.55,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
/// The right-hand value in a settings row.
|
||||
static const value = TextStyle(
|
||||
fontFamily: _f, fontSize: 13.5, height: 1.4,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
/// Field labels, tags, chips, inline actions.
|
||||
static const label = TextStyle(
|
||||
fontFamily: _f, fontSize: 12.5, height: 1.3,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
static const button = TextStyle(
|
||||
fontFamily: _f, fontSize: 15.5, height: 1.2,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: -0.23,
|
||||
);
|
||||
|
||||
/// The text-only action under a primary button ("Back to home").
|
||||
static const textButton = TextStyle(
|
||||
fontFamily: _f, fontSize: 14.5, height: 1.2,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
color: DmColors.ink2,
|
||||
);
|
||||
|
||||
/// Uppercase micro-label above a group — the design's `micro`. Crimson when
|
||||
/// it heads brand content, [DmColors.ink3] when it heads a neutral group.
|
||||
static const eyebrow = TextStyle(
|
||||
fontFamily: _f, fontSize: 10.5, height: 1.3,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: 1.16, color: DmColors.ink3,
|
||||
);
|
||||
|
||||
/// The eyebrow as it appears on the crimson header.
|
||||
static const headerEyebrow = TextStyle(
|
||||
fontFamily: _f, fontSize: 11.5, height: 1.3,
|
||||
fontWeight: FontWeight.w800, fontVariations: _w800,
|
||||
letterSpacing: 1.84, color: DmColors.onBrand3,
|
||||
);
|
||||
|
||||
/// Sentence-case heading above a group of rows.
|
||||
static const sectionHead = TextStyle(
|
||||
fontFamily: _f, fontSize: 15, height: 1.3,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
letterSpacing: -0.3, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// Alias kept for existing call sites.
|
||||
static const groupHead = sectionHead;
|
||||
|
||||
/// Field input text.
|
||||
static const field = TextStyle(
|
||||
fontFamily: _f, fontSize: 16, height: 1.3,
|
||||
fontWeight: FontWeight.w600, fontVariations: _w600,
|
||||
color: DmColors.ink,
|
||||
);
|
||||
|
||||
// ── Mono: anything checkable digit by digit ──────────────────────────────
|
||||
|
||||
/// References, plates and phone numbers sitting inline in a row.
|
||||
static const mono = TextStyle(
|
||||
fontFamily: _mono, fontSize: 13, height: 1.4,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
letterSpacing: -0.26, color: DmColors.ink3,
|
||||
);
|
||||
|
||||
/// Step counters, timers, the smallest data.
|
||||
static const monoSm = TextStyle(
|
||||
fontFamily: _mono, fontSize: 11.5, height: 1.3,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
letterSpacing: -0.23, color: DmColors.ink3,
|
||||
);
|
||||
|
||||
/// A booking reference given its own line, or a fare.
|
||||
static const monoLg = TextStyle(
|
||||
fontFamily: _mono, fontSize: 19, height: 1.2,
|
||||
fontWeight: FontWeight.w500, fontVariations: _w500,
|
||||
letterSpacing: -0.38, color: DmColors.ink,
|
||||
);
|
||||
|
||||
/// The OTP digits.
|
||||
static const otp = TextStyle(
|
||||
fontFamily: _f, fontSize: 24, height: 1.1,
|
||||
fontWeight: FontWeight.w700, fontVariations: _w700,
|
||||
color: DmColors.ink,
|
||||
);
|
||||
}
|
||||
375
lib/ui/widgets/buttons.dart
Normal file
375
lib/ui/widgets/buttons.dart
Normal file
@@ -0,0 +1,375 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
enum DmButtonKind {
|
||||
/// Crimson, with the glow. The one committing action on a screen.
|
||||
primary,
|
||||
|
||||
/// White with a hairline edge. A real action that is not *the* action —
|
||||
/// "Share live tracking".
|
||||
outline,
|
||||
|
||||
/// White with a crimson edge and crimson label. Brand-weighted, but not a
|
||||
/// commitment — used where an outline would read as too neutral.
|
||||
quiet,
|
||||
|
||||
/// A crimson wash. Rare; secondary actions inside a wash card.
|
||||
soft,
|
||||
|
||||
/// No surface at all. The line under a primary action.
|
||||
ghost,
|
||||
}
|
||||
|
||||
/// The app's only button.
|
||||
///
|
||||
/// `block: true` fills the width (the default in footers). The disabled primary
|
||||
/// does not fade the crimson — it goes to the sunken neutral, because a
|
||||
/// half-opacity red still reads as "press me" and this design uses exactly one
|
||||
/// red for exactly one meaning.
|
||||
class DmButton extends StatefulWidget {
|
||||
const DmButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.kind = DmButtonKind.primary,
|
||||
this.block = true,
|
||||
this.danger = false,
|
||||
this.busy = false,
|
||||
this.busyLabel,
|
||||
this.icon,
|
||||
this.iconLeading = false,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final DmButtonKind kind;
|
||||
final bool block;
|
||||
|
||||
/// Renders the label in the danger colour (Cancel pickup).
|
||||
final bool danger;
|
||||
final bool busy;
|
||||
final String? busyLabel;
|
||||
|
||||
/// Optional icon, e.g. an arrow on a forward action.
|
||||
final IconData? icon;
|
||||
|
||||
/// Puts [icon] before the label. True for a pair of short actions — "Call",
|
||||
/// "Message" — where the glyph is what the eye lands on first.
|
||||
final bool iconLeading;
|
||||
|
||||
/// Overrides [DmSpace.button] — for the half-width pair inside a card.
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<DmButton> createState() => _DmButtonState();
|
||||
}
|
||||
|
||||
class _DmButtonState extends State<DmButton> {
|
||||
bool _down = false;
|
||||
|
||||
bool get _enabled => widget.onPressed != null && !widget.busy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
BoxBorder? border;
|
||||
List<BoxShadow>? shadow;
|
||||
double height = widget.height ?? DmSpace.button;
|
||||
|
||||
switch (widget.kind) {
|
||||
case DmButtonKind.primary:
|
||||
background = !_enabled
|
||||
? DmColors.surfaceAlt
|
||||
: (_down ? DmColors.brandPress : DmColors.brand);
|
||||
foreground = _enabled ? Colors.white : DmColors.ink3;
|
||||
shadow = _enabled && !_down ? DmShadow.lift : null;
|
||||
case DmButtonKind.outline:
|
||||
background = _down ? DmColors.surfaceAlt : DmColors.surface;
|
||||
foreground = widget.danger ? DmColors.danger : DmColors.ink;
|
||||
border = Border.all(color: DmColors.border);
|
||||
case DmButtonKind.quiet:
|
||||
background = _down ? DmColors.brandSoft : DmColors.surface;
|
||||
foreground = DmColors.brand;
|
||||
border = Border.all(color: DmColors.brandLine);
|
||||
case DmButtonKind.soft:
|
||||
background = _down ? DmColors.brandLine : DmColors.brandSoft;
|
||||
foreground = DmColors.brand;
|
||||
case DmButtonKind.ghost:
|
||||
background = Colors.transparent;
|
||||
foreground = widget.danger ? DmColors.brand : DmColors.ink2;
|
||||
height = widget.height ?? DmSpace.tap;
|
||||
}
|
||||
|
||||
final content = Row(
|
||||
mainAxisSize: widget.block ? MainAxisSize.max : MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.icon != null && widget.iconLeading && !widget.busy) ...[
|
||||
Icon(widget.icon, size: 18, color: foreground),
|
||||
const SizedBox(width: 9),
|
||||
],
|
||||
if (widget.busy) ...[
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
strokeCap: StrokeCap.round,
|
||||
valueColor: AlwaysStoppedAnimation(foreground),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.busy ? (widget.busyLabel ?? widget.label) : widget.label,
|
||||
style: (widget.kind == DmButtonKind.ghost
|
||||
? DmText.textButton
|
||||
: DmText.button)
|
||||
.copyWith(color: foreground),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
if (widget.icon != null && !widget.iconLeading && !widget.busy) ...[
|
||||
const SizedBox(width: 9),
|
||||
Icon(widget.icon, size: 18, color: foreground),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Presses translate rather than scale: the design's buttons sit on a
|
||||
// hairline grid, and scaling a 1px border makes it shimmer.
|
||||
final button = AnimatedSlide(
|
||||
offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero,
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.fast,
|
||||
constraints: BoxConstraints(minHeight: height),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
border: border,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
boxShadow: shadow,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: _enabled,
|
||||
label: widget.label,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: _enabled ? (_) => setState(() => _down = true) : null,
|
||||
onTapUp: _enabled ? (_) => setState(() => _down = false) : null,
|
||||
onTapCancel: _enabled ? () => setState(() => _down = false) : null,
|
||||
onTap: _enabled
|
||||
? () {
|
||||
HapticFeedback.selectionClick();
|
||||
widget.onPressed!();
|
||||
}
|
||||
: null,
|
||||
child: widget.block ? SizedBox(width: double.infinity, child: button) : button,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Half-width action inside a card — Call / Message on the rider row.
|
||||
class DmMiniButton extends StatelessWidget {
|
||||
const DmMiniButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.onPressed,
|
||||
this.filled = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// The one of the pair that is the expected action.
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: DmButton(
|
||||
label: label,
|
||||
icon: icon,
|
||||
iconLeading: true,
|
||||
height: 42,
|
||||
kind: filled ? DmButtonKind.primary : DmButtonKind.outline,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small quiet pill inside a card ("Change", when it needs a surface).
|
||||
class DmChipButton extends StatelessWidget {
|
||||
const DmChipButton({super.key, required this.label, this.onPressed, this.icon});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 34),
|
||||
padding: EdgeInsets.only(
|
||||
left: icon == null ? 12 : 9,
|
||||
right: 12,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 14, color: DmColors.ink2),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Text(label, style: DmText.label),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-only action — the "Change" / "Edit" / "Copy" at the end of a card row.
|
||||
///
|
||||
/// Its padding is negative-margined back out, so the label sits flush with the
|
||||
/// card's right edge while the tap target still clears 40pt.
|
||||
class DmTextAction extends StatelessWidget {
|
||||
const DmTextAction({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.color = DmColors.brand,
|
||||
this.minHeight = 40,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final Color color;
|
||||
final double minHeight;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onPressed!();
|
||||
},
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
// Flexible, because this sits at the end of rows whose other half
|
||||
// is an address or a window that has first claim on the width.
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(fontSize: 13, color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The chrome affordance: a rounded square, not a disc.
|
||||
class DmIconButton extends StatelessWidget {
|
||||
const DmIconButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.onPressed,
|
||||
this.background = DmColors.surface,
|
||||
this.foreground = DmColors.ink,
|
||||
this.border = DmColors.border,
|
||||
this.size = 40,
|
||||
this.radius = DmRadius.sm,
|
||||
this.tooltip,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
|
||||
/// Transparent on the crimson header, where the fill carries the shape.
|
||||
final Color border;
|
||||
final double size;
|
||||
final double radius;
|
||||
final String? tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
child: SizedBox(
|
||||
width: DmSpace.tap,
|
||||
height: DmSpace.tap,
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: background,
|
||||
borderRadius: DmRadius.all(radius),
|
||||
child: InkWell(
|
||||
borderRadius: DmRadius.all(radius),
|
||||
onTap: onPressed,
|
||||
child: Ink(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(radius),
|
||||
border: Border.all(color: border),
|
||||
),
|
||||
child: Icon(icon, size: 19, color: foreground),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
470
lib/ui/widgets/cards.dart
Normal file
470
lib/ui/widgets/cards.dart
Normal file
@@ -0,0 +1,470 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
/// How loudly a card speaks.
|
||||
enum DmCardTone {
|
||||
/// White on the canvas, hairline edge. Almost everything.
|
||||
plain,
|
||||
|
||||
/// Filled crimson. **One per screen, at most** — it is the screen's answer
|
||||
/// to "what is happening right now": the live ETA, the booked confirmation,
|
||||
/// the delivered summary. A second one on the same screen and neither wins.
|
||||
fill,
|
||||
|
||||
/// A crimson wash. Supporting brand information that must not shout: the
|
||||
/// address being collected from, the rider on the way, a reassurance.
|
||||
wash,
|
||||
|
||||
/// The canvas itself, edged. For content that is deliberately inert — the
|
||||
/// build panel on Account.
|
||||
sunken,
|
||||
}
|
||||
|
||||
/// A bordered panel that stacks rows and rules them apart.
|
||||
///
|
||||
/// This replaces the shadowed card the app used to draw everywhere. The rule
|
||||
/// between rows belongs to the card, not to the rows: a row does not know
|
||||
/// whether it is first, and a card that is handed one child draws no rule at
|
||||
/// all, so the same [DmCardCell] works alone or in a stack of five.
|
||||
class DmCard extends StatelessWidget {
|
||||
const DmCard({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.tone = DmCardTone.plain,
|
||||
this.rail = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final DmCardTone tone;
|
||||
|
||||
/// A 3pt crimson spine down the leading edge — marks the one row in a list
|
||||
/// that needs the customer's attention now.
|
||||
final bool rail;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
Color get _background => switch (tone) {
|
||||
DmCardTone.plain => DmColors.surface,
|
||||
DmCardTone.fill => DmColors.brand,
|
||||
DmCardTone.wash => DmColors.brandSoft,
|
||||
DmCardTone.sunken => DmColors.canvas,
|
||||
};
|
||||
|
||||
Color get _edge => switch (tone) {
|
||||
DmCardTone.plain => DmColors.border,
|
||||
DmCardTone.fill => DmColors.brand,
|
||||
DmCardTone.wash => DmColors.brandLine,
|
||||
DmCardTone.sunken => DmColors.border,
|
||||
};
|
||||
|
||||
/// The rule between two rows of the same card. On crimson it is a lightened
|
||||
/// white rather than a darker line: a dark rule on a saturated field reads as
|
||||
/// a crack, a light one as a seam.
|
||||
Color get _rule => switch (tone) {
|
||||
DmCardTone.plain => DmColors.hairline,
|
||||
DmCardTone.fill => const Color(0x2EFFFFFF),
|
||||
DmCardTone.wash => DmColors.brandLine,
|
||||
DmCardTone.sunken => DmColors.border,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The edge is drawn in the FOREGROUND, over the children.
|
||||
//
|
||||
// As a background border it was painted first and then covered: a cell with
|
||||
// its own tint — the washed status strip at the head of an order card, the
|
||||
// crimson rows on the booked screen — filled the whole box, border included,
|
||||
// and the card lost its outline exactly where it needed one most. A
|
||||
// foreground decoration paints last, so every cell is clipped inside an
|
||||
// edge that is always visible.
|
||||
final card = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: _background,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: _edge),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) Container(height: 1, color: _rule),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final railed = rail
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 3),
|
||||
child: card,
|
||||
)
|
||||
: card;
|
||||
|
||||
if (onTap == null) return railed;
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: railed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One padded cell inside a [DmCard] — the design's `.p`.
|
||||
class DmCardCell extends StatelessWidget {
|
||||
const DmCardCell({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
||||
this.background,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// Tints this cell alone — a wash strip at the head of a plain card.
|
||||
final Color? background;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = Padding(padding: padding, child: child);
|
||||
final painted = background == null
|
||||
? body
|
||||
: ColoredBox(color: background!, child: body);
|
||||
|
||||
if (onTap == null) return painted;
|
||||
return Material(
|
||||
color: background ?? Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: DmColors.brandSoft,
|
||||
highlightColor: DmColors.brandSoft.withValues(alpha: 0.6),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The common cell: a glyph or tile, a title with an optional second line, and
|
||||
/// something on the right — a value, a chevron, an inline action.
|
||||
class DmCardRow extends StatelessWidget {
|
||||
const DmCardRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.eyebrow,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.tone = DmCardTone.plain,
|
||||
this.onTap,
|
||||
this.titleStyle,
|
||||
this.subtitleStyle,
|
||||
this.background,
|
||||
this.crossAlign = CrossAxisAlignment.center,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
/// A micro-label above the title — "COLLECTING FROM".
|
||||
final String? eyebrow;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
|
||||
/// The tone of the *card this row sits in*, so the row can pick text colours
|
||||
/// that survive a crimson field.
|
||||
final DmCardTone tone;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
final TextStyle? titleStyle;
|
||||
final TextStyle? subtitleStyle;
|
||||
final Color? background;
|
||||
final CrossAxisAlignment crossAlign;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onFill = tone == DmCardTone.fill;
|
||||
final titleColor = onFill ? Colors.white : DmColors.ink;
|
||||
final subColor = switch (tone) {
|
||||
DmCardTone.fill => DmColors.onBrand2,
|
||||
DmCardTone.wash => DmColors.brandPress,
|
||||
_ => DmColors.ink2,
|
||||
};
|
||||
final eyebrowColor = switch (tone) {
|
||||
DmCardTone.fill => DmColors.onBrand3,
|
||||
DmCardTone.wash => DmColors.brand,
|
||||
_ => DmColors.ink3,
|
||||
};
|
||||
|
||||
return DmCardCell(
|
||||
background: background,
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
crossAxisAlignment: crossAlign,
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 12)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (eyebrow != null) ...[
|
||||
Text(
|
||||
eyebrow!.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(color: eyebrowColor),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: (titleStyle ?? DmText.cardTitle).copyWith(
|
||||
color: titleStyle?.color ?? titleColor,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: (subtitleStyle ?? DmText.small).copyWith(
|
||||
color: subtitleStyle?.color ?? subColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 12), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a [DmTile] is filled.
|
||||
enum DmTileTone { sunken, wash, brand, onFill }
|
||||
|
||||
/// The 38pt rounded square that holds a glyph or a step number.
|
||||
///
|
||||
/// Square, not a circle: the cards, fields and buttons around it are all cut
|
||||
/// with the same small radius, and a disc in the middle of that is the one
|
||||
/// shape that has to be explained.
|
||||
class DmTile extends StatelessWidget {
|
||||
const DmTile({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.label,
|
||||
this.tone = DmTileTone.sunken,
|
||||
this.size = 38,
|
||||
}) : assert(icon != null || label != null, 'a tile shows a glyph or a number');
|
||||
|
||||
final IconData? icon;
|
||||
|
||||
/// A step number, for the "how it works" sequence.
|
||||
final String? label;
|
||||
final DmTileTone tone;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (bg, fg) = switch (tone) {
|
||||
DmTileTone.sunken => (DmColors.surfaceAlt, DmColors.ink),
|
||||
DmTileTone.wash => (DmColors.brandSoft, DmColors.brand),
|
||||
DmTileTone.brand => (DmColors.brand, Colors.white),
|
||||
DmTileTone.onFill => (DmColors.onBrandRaise, Colors.white),
|
||||
};
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
child: icon != null
|
||||
? Icon(icon, size: size * 0.47, color: fg)
|
||||
: Text(
|
||||
label!,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontVariations: const [FontVariation('wght', 800)],
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A figure and its caption, sat in its own bordered box. Used in pairs.
|
||||
class DmMetric extends StatelessWidget {
|
||||
const DmMetric({
|
||||
super.key,
|
||||
required this.figure,
|
||||
required this.caption,
|
||||
this.tone = DmCardTone.plain,
|
||||
});
|
||||
|
||||
final String figure;
|
||||
final String caption;
|
||||
final DmCardTone tone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (bg, edge, figureColor, captionColor) = switch (tone) {
|
||||
DmCardTone.fill => (
|
||||
DmColors.brand,
|
||||
DmColors.brand,
|
||||
Colors.white,
|
||||
DmColors.onBrand3,
|
||||
),
|
||||
DmCardTone.wash => (
|
||||
DmColors.brandSoft,
|
||||
DmColors.brandLine,
|
||||
DmColors.brand,
|
||||
DmColors.brandPress,
|
||||
),
|
||||
_ => (DmColors.surface, DmColors.border, DmColors.ink, DmColors.ink3),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(15, 14, 15, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border.all(color: edge),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
figure,
|
||||
style: DmText.display.copyWith(fontSize: 25, height: 1, color: figureColor),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
caption,
|
||||
style: DmText.value.copyWith(fontSize: 12.5, color: captionColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A label and its figure on one baseline — the fare breakdown, the build
|
||||
/// panel. The label may wrap; the figure never does.
|
||||
class DmFareLine extends StatelessWidget {
|
||||
const DmFareLine({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.strong = false,
|
||||
this.mono = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
/// The total, rather than one of the lines that make it up.
|
||||
final bool strong;
|
||||
|
||||
/// Figures are mono by default — they are meant to be compared down a column.
|
||||
final bool mono;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: DmText.small.copyWith(
|
||||
fontWeight: strong ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
style: mono
|
||||
? DmText.mono.copyWith(
|
||||
fontSize: strong ? 15 : 13.5,
|
||||
color: strong ? DmColors.brand : DmColors.ink,
|
||||
)
|
||||
: DmText.value.copyWith(
|
||||
color: strong ? DmColors.brand : DmColors.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The uppercase micro-label that heads a group of cards.
|
||||
class DmMicroHead extends StatelessWidget {
|
||||
const DmMicroHead(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.brand = true,
|
||||
this.top = 20,
|
||||
this.bottom = 10,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
/// Crimson heads brand content; neutral heads everything else.
|
||||
final bool brand;
|
||||
final double top;
|
||||
final double bottom;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: top, bottom: bottom),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
text.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(
|
||||
color: brand ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
655
lib/ui/widgets/chrome.dart
Normal file
655
lib/ui/widgets/chrome.dart
Normal file
@@ -0,0 +1,655 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/app_config.dart';
|
||||
import '../tokens.dart';
|
||||
import 'buttons.dart';
|
||||
|
||||
/// Top bar for pushed screens: a square back affordance, a quiet title, an
|
||||
/// optional trailing slot (usually the booking reference in mono), and the
|
||||
/// booking step indicator.
|
||||
class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const DmTopBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.trailing,
|
||||
this.progress,
|
||||
this.steps,
|
||||
this.stepLabel,
|
||||
this.onBack,
|
||||
this.background = DmColors.canvas,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final Widget? trailing;
|
||||
|
||||
/// 0..1 — omit to hide the step indicator.
|
||||
final double? progress;
|
||||
|
||||
/// When given, the indicator is drawn as [steps] separate segments rather
|
||||
/// than one continuous rail. Discrete steps are countable; a filled bar is
|
||||
/// only a proportion, and "how many screens until I am done" is the question
|
||||
/// a customer is actually asking.
|
||||
final int? steps;
|
||||
|
||||
/// The line above the segments — "Step 2 of 4 · Destination".
|
||||
final String? stepLabel;
|
||||
|
||||
final VoidCallback? onBack;
|
||||
final Color background;
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(56 + (progress == null ? 0 : 42));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = progress == null || steps == null
|
||||
? 0
|
||||
: (progress! * steps!).round().clamp(0, steps!);
|
||||
|
||||
return Material(
|
||||
color: background,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: DmSpace.pad - 12),
|
||||
DmIconButton(
|
||||
icon: LucideIcons.arrowLeft,
|
||||
tooltip: 'Back',
|
||||
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: title == null
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
title!,
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 15.5,
|
||||
letterSpacing: -0.31,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
const SizedBox(width: DmSpace.pad),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (progress != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
|
||||
child: DmStepBar(
|
||||
progress: progress!,
|
||||
steps: steps,
|
||||
current: current,
|
||||
label: stepLabel,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Booking progress.
|
||||
///
|
||||
/// With [steps] it draws that many segments, captioned "Step n of m" in mono;
|
||||
/// without, it falls back to a single filled rail for callers that only know a
|
||||
/// fraction.
|
||||
class DmStepBar extends StatelessWidget {
|
||||
const DmStepBar({
|
||||
super.key,
|
||||
required this.progress,
|
||||
this.steps,
|
||||
this.current = 0,
|
||||
this.label,
|
||||
this.onFill = false,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final int? steps;
|
||||
final int current;
|
||||
final String? label;
|
||||
|
||||
/// Drawn on a crimson field (the live-tracking card), where the track is a
|
||||
/// lightened white and the fill is solid white.
|
||||
final bool onFill;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final track = onFill ? const Color(0x52FFFFFF) : DmColors.border;
|
||||
final fill = onFill ? Colors.white : DmColors.brand;
|
||||
|
||||
final bar = steps == null
|
||||
? ClipRRect(
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
child: SizedBox(
|
||||
height: 3,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: ColoredBox(color: track)),
|
||||
FractionallySizedBox(
|
||||
widthFactor: progress.clamp(0, 1),
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.ease,
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
for (var i = 0; i < steps!; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: i < current ? fill : track,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (label == null && steps == null) return bar;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label != null || steps != null) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (steps != null)
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.monoSm.copyWith(
|
||||
color: onFill ? DmColors.onBrand3 : DmColors.ink3,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'Step '),
|
||||
TextSpan(
|
||||
text: '$current',
|
||||
style: TextStyle(color: onFill ? Colors.white : DmColors.brand),
|
||||
),
|
||||
TextSpan(text: ' of ${steps!}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (label != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
label!,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: onFill ? DmColors.onBrand3 : DmColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
],
|
||||
bar,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sticky bottom action area.
|
||||
///
|
||||
/// `edge: true` lifts it onto white with a hairline above — used when the
|
||||
/// content behind it scrolls, so the action never looks like the end of the
|
||||
/// list. On a short screen the canvas variant keeps the page quiet.
|
||||
class DmFooter extends StatelessWidget {
|
||||
const DmFooter({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.edge = false,
|
||||
this.background,
|
||||
this.divider = false,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
/// White bed, hairline above.
|
||||
final bool edge;
|
||||
|
||||
final Color? background;
|
||||
|
||||
/// Retained for call-site compatibility; superseded by [edge].
|
||||
final bool divider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lifted = edge || divider;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? (lifted ? DmColors.surface : DmColors.canvas),
|
||||
border: lifted
|
||||
? const Border(top: BorderSide(color: DmColors.border))
|
||||
: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 4),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The crimson field at the top of a tab root.
|
||||
///
|
||||
/// Home, Orders and Account all open with one. It does three jobs at once —
|
||||
/// it states where you are (eyebrow + title), it holds that destination's one
|
||||
/// control (a segmented filter, an address switcher, a phone number), and it
|
||||
/// gives the app a recognisable brand surface that the white canvas below can
|
||||
/// then stay completely quiet.
|
||||
///
|
||||
/// The arcs are drawn, not an asset: they are struck from the same geometry as
|
||||
/// the mark, they scale to any header height, and they cost no bytes.
|
||||
class DmBrandHeader extends StatelessWidget {
|
||||
const DmBrandHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.eyebrow,
|
||||
this.leading,
|
||||
this.action,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? eyebrow;
|
||||
|
||||
/// Sits at the top-left of the header — the avatar on Account.
|
||||
final Widget? leading;
|
||||
|
||||
/// Top-right — refresh, help.
|
||||
final Widget? action;
|
||||
|
||||
/// The control that belongs to this destination, under the title.
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(DmRadius.header),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: DmColors.brand,
|
||||
child: CustomPaint(
|
||||
painter: const DmArcField(),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.headerPad,
|
||||
2,
|
||||
DmSpace.headerPad,
|
||||
18,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
?leading,
|
||||
const Spacer(),
|
||||
// ── A build on dev data says so, on every screen ──
|
||||
//
|
||||
// Twice now a booking made offline has been hunted for
|
||||
// in the admin console. The Account screen named the
|
||||
// mode, but nobody goes to Account before booking. It
|
||||
// belongs where the booking is made.
|
||||
if (AppConfig.useDevData) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0x80FFFFFF)),
|
||||
borderRadius: DmRadius.all(5),
|
||||
),
|
||||
child: Text(
|
||||
'OFFLINE DATA',
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
?action,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (eyebrow != null) ...[
|
||||
Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(title, style: DmText.headerTitle),
|
||||
if (child != null) ...[const SizedBox(height: 18), child!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin arc strokes struck from the mark's geometry, at the opacity the design
|
||||
/// uses. Decorative only — it never carries meaning, so it is not announced.
|
||||
class DmArcField extends CustomPainter {
|
||||
const DmArcField({this.opacity = 0.15});
|
||||
|
||||
final double opacity;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// The arcs are authored on a 340-square field anchored to the top-right,
|
||||
// then scaled — so the same geometry reads identically on a 210pt Orders
|
||||
// header and a 260pt Account one.
|
||||
const field = 340.0;
|
||||
final scale = size.width / 390 * 1.0;
|
||||
canvas.save();
|
||||
canvas.translate(size.width - field * scale + 62 * scale, -72 * scale);
|
||||
canvas.scale(scale);
|
||||
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5 / scale
|
||||
..color = Colors.white.withValues(alpha: opacity);
|
||||
|
||||
canvas.drawCircle(const Offset(250, 90), 120, paint);
|
||||
canvas.drawCircle(const Offset(250, 90), 170, paint);
|
||||
|
||||
for (final (y1, y2, cx, cy) in const [
|
||||
(300.0, 40.0, 60.0, 300.0),
|
||||
(330.0, 60.0, 70.0, 330.0),
|
||||
]) {
|
||||
final path = Path()
|
||||
..moveTo(-20, y1)
|
||||
..cubicTo(cx, cy, field - 40, y2 + 140, field, y2);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant DmArcField oldDelegate) =>
|
||||
oldDelegate.opacity != opacity;
|
||||
}
|
||||
|
||||
/// The segmented filter recessed into the brand header — Orders' Active /
|
||||
/// Completed / Cancelled, with each tab's count.
|
||||
///
|
||||
/// The selected tab lifts out as a white pill and keeps its figure; the others
|
||||
/// stay in the recess. One control, three states, no separate count row.
|
||||
class DmSegmented extends StatelessWidget {
|
||||
const DmSegmented({
|
||||
super.key,
|
||||
required this.labels,
|
||||
required this.counts,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<String> labels;
|
||||
|
||||
/// Null where a tab has no meaningful figure yet (still loading).
|
||||
final List<int?> counts;
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.onBrandSunken,
|
||||
borderRadius: DmRadius.all(DmRadius.lg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < labels.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
label: labels[i],
|
||||
count: i < counts.length ? counts[i] : null,
|
||||
selected: i == index,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment extends StatelessWidget {
|
||||
const _Segment({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int? count;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.white : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13,
|
||||
color: selected ? DmColors.brand : DmColors.onBrand3,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.brandSoft : DmColors.onBrandRaise,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 11,
|
||||
color: selected ? DmColors.brand : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recessed strip under a header title that carries one live fact — the
|
||||
/// address a pickup is coming from, or the customer's phone number.
|
||||
class DmHeaderTrack extends StatelessWidget {
|
||||
const DmHeaderTrack({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: onTap != null,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.onBrandSunken,
|
||||
borderRadius: DmRadius.all(DmRadius.lg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 10)],
|
||||
Expanded(child: child),
|
||||
if (trailing != null) ...[const SizedBox(width: 10), trailing!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Large title for a screen that has no brand header.
|
||||
class DmPageTitle extends StatelessWidget {
|
||||
const DmPageTitle(this.text, {super.key, this.subtitle});
|
||||
|
||||
final String text;
|
||||
final String? subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 24, DmSpace.pad, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(text, style: DmText.pageTitle),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(subtitle!, style: DmText.lede),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentence-case heading above a group of fields or rows.
|
||||
class DmGroupHead extends StatelessWidget {
|
||||
const DmGroupHead(this.text, {super.key, this.top = 24});
|
||||
|
||||
final String text;
|
||||
final double top;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: top, bottom: 10),
|
||||
child: Text(text, style: DmText.sectionHead),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen heading and its supporting line — shared by every booking step.
|
||||
class DmScreenHeader extends StatelessWidget {
|
||||
const DmScreenHeader({super.key, required this.title, this.lede, this.leding});
|
||||
|
||||
final String title;
|
||||
final String? lede;
|
||||
|
||||
/// Rich alternative to [lede].
|
||||
final Widget? leding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(title, style: DmText.title),
|
||||
if (lede != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(lede!, style: DmText.lede),
|
||||
],
|
||||
if (leding != null) ...[const SizedBox(height: 10), leding!],
|
||||
const SizedBox(height: 22),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
187
lib/ui/widgets/feedback.dart
Normal file
187
lib/ui/widgets/feedback.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
/// Dark pill toast, matching the prototype's transient confirmations.
|
||||
class DmToast {
|
||||
DmToast._();
|
||||
|
||||
static OverlayEntry? _entry;
|
||||
|
||||
static void show(BuildContext context, String message) {
|
||||
final overlay = Overlay.maybeOf(context);
|
||||
if (overlay == null) return;
|
||||
|
||||
_entry?.remove();
|
||||
final entry = OverlayEntry(
|
||||
builder: (context) => _ToastBody(message: message),
|
||||
);
|
||||
_entry = entry;
|
||||
overlay.insert(entry);
|
||||
|
||||
Future<void>.delayed(const Duration(milliseconds: 2400), () {
|
||||
if (_entry == entry) {
|
||||
entry.remove();
|
||||
_entry = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when the app is torn down in tests.
|
||||
static void dismiss() {
|
||||
_entry?.remove();
|
||||
_entry = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _ToastBody extends StatefulWidget {
|
||||
const _ToastBody({required this.message});
|
||||
final String message;
|
||||
|
||||
@override
|
||||
State<_ToastBody> createState() => _ToastBodyState();
|
||||
}
|
||||
|
||||
class _ToastBodyState extends State<_ToastBody>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 240),
|
||||
)..forward();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 96 + bottom,
|
||||
child: FadeTransition(
|
||||
opacity: _c,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 0.3),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)),
|
||||
child: Center(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 11,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xF0191716),
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
),
|
||||
child: Text(
|
||||
widget.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom sheet with the prototype's grip, radius and padding.
|
||||
Future<T?> showDmSheet<T>({
|
||||
required BuildContext context,
|
||||
required WidgetBuilder builder,
|
||||
bool isScrollControlled = true,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
backgroundColor: DmColors.canvas,
|
||||
barrierColor: DmColors.scrim,
|
||||
elevation: 0,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(DmRadius.xl)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.86,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
10,
|
||||
DmSpace.pad,
|
||||
20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _SheetGrip(),
|
||||
Flexible(child: builder(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SheetGrip extends StatelessWidget {
|
||||
const _SheetGrip();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 2, bottom: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.border,
|
||||
borderRadius: DmRadius.all(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sheet heading + supporting line.
|
||||
class DmSheetHeader extends StatelessWidget {
|
||||
const DmSheetHeader({super.key, required this.title, this.subtitle});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: DmText.sheetTitle),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle!, style: DmText.tiny),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
323
lib/ui/widgets/inputs.dart
Normal file
323
lib/ui/widgets/inputs.dart
Normal file
@@ -0,0 +1,323 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
import 'cards.dart';
|
||||
|
||||
/// Labelled field.
|
||||
///
|
||||
/// White with a hairline at rest, and on focus the edge goes crimson with a
|
||||
/// 3pt wash ring around it. The ring is the whole reason this is a container
|
||||
/// rather than an `InputDecoration`: it is a spread shadow in the brand's
|
||||
/// lightest tint, which no `InputBorder` can draw, and it is what makes the
|
||||
/// focused field read as lit rather than merely outlined.
|
||||
///
|
||||
/// [optional] appends the grey marker that keeps skippable destination and
|
||||
/// recipient fields honestly signposted.
|
||||
class DmTextField extends StatefulWidget {
|
||||
const DmTextField({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.controller,
|
||||
this.hint,
|
||||
this.optional = false,
|
||||
this.keyboardType,
|
||||
this.maxLength,
|
||||
this.maxLines = 1,
|
||||
this.prefix,
|
||||
this.textInputAction,
|
||||
this.onSubmitted,
|
||||
this.autofocus = false,
|
||||
this.digitsOnly = false,
|
||||
this.mono = false,
|
||||
this.onClear,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final TextEditingController controller;
|
||||
final String? hint;
|
||||
final bool optional;
|
||||
final TextInputType? keyboardType;
|
||||
final int? maxLength;
|
||||
final int maxLines;
|
||||
final String? prefix;
|
||||
final TextInputAction? textInputAction;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final bool autofocus;
|
||||
final bool digitsOnly;
|
||||
|
||||
/// Sets the value in mono — a pincode or a phone number, where the customer
|
||||
/// checks the digits back.
|
||||
final bool mono;
|
||||
|
||||
/// Shows a clear button on the right when non-null.
|
||||
final VoidCallback? onClear;
|
||||
|
||||
@override
|
||||
State<DmTextField> createState() => _DmTextFieldState();
|
||||
}
|
||||
|
||||
class _DmTextFieldState extends State<DmTextField> {
|
||||
final FocusNode _node = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_node.addListener(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_node.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final focused = _node.hasFocus;
|
||||
final textStyle = widget.mono
|
||||
? DmText.monoLg.copyWith(fontSize: 15.5, color: DmColors.ink)
|
||||
: DmText.field;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.label,
|
||||
children: [
|
||||
TextSpan(text: widget.label),
|
||||
if (widget.optional)
|
||||
const TextSpan(
|
||||
text: ' Optional',
|
||||
style: TextStyle(
|
||||
color: DmColors.ink4,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
AnimatedContainer(
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: widget.maxLines > 1 ? 0 : DmSpace.field,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
border: Border.all(
|
||||
color: focused ? DmColors.brand : DmColors.border,
|
||||
),
|
||||
boxShadow: focused
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: DmColors.brandSoft,
|
||||
spreadRadius: 3,
|
||||
blurRadius: 0,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: widget.maxLines > 1
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.prefix != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(15, 0, 12, 0),
|
||||
child: Text(
|
||||
widget.prefix!,
|
||||
style: DmText.field.copyWith(
|
||||
color: DmColors.ink2,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
// A hairline between the country code and the number: they
|
||||
// are two different things and reading them as one string is
|
||||
// how a digit gets lost.
|
||||
Container(width: 1, height: 22, color: DmColors.border),
|
||||
],
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.controller,
|
||||
focusNode: _node,
|
||||
keyboardType: widget.keyboardType,
|
||||
maxLines: widget.maxLines,
|
||||
autofocus: widget.autofocus,
|
||||
textInputAction: widget.textInputAction,
|
||||
onSubmitted: widget.onSubmitted,
|
||||
cursorColor: DmColors.brand,
|
||||
cursorRadius: const Radius.circular(2),
|
||||
style: textStyle,
|
||||
inputFormatters: [
|
||||
if (widget.maxLength != null)
|
||||
LengthLimitingTextInputFormatter(widget.maxLength),
|
||||
if (widget.digitsOnly)
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: widget.hint,
|
||||
hintStyle: DmText.field.copyWith(
|
||||
color: DmColors.ink3,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
filled: false,
|
||||
counterText: '',
|
||||
contentPadding: EdgeInsets.fromLTRB(
|
||||
widget.prefix == null ? 15 : 12,
|
||||
widget.maxLines > 1 ? 15 : 14,
|
||||
8,
|
||||
widget.maxLines > 1 ? 15 : 14,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.onClear != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: InkWell(
|
||||
onTap: widget.onClear,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
child: const SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: Icon(LucideIcons.x, size: 17, color: DmColors.ink3),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings row: label left, value or control right.
|
||||
class DmRow extends StatelessWidget {
|
||||
const DmRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.value,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.showChevron = false,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final bool showChevron;
|
||||
|
||||
/// The glyph in the row's leading tile. Account's rows all carry one, so the
|
||||
/// list can be scanned by shape before it is read.
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: DmColors.brandSoft,
|
||||
highlightColor: DmColors.brandSoft.withValues(alpha: 0.5),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 56),
|
||||
padding: const EdgeInsets.fromLTRB(16, 11, 16, 11),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
DmTile(icon: icon, tone: DmTileTone.wash),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
// The label is what the row IS, so it never wraps and never
|
||||
// gives up width; the value is context and ellipsizes. A saved
|
||||
// address as a value would otherwise push "Saved addresses" onto
|
||||
// two lines.
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Text(
|
||||
label,
|
||||
style: DmText.cardTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (value != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
value!,
|
||||
style: DmText.value,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
?trailing,
|
||||
if (showChevron) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 18,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Card that rules a group of [DmRow]s together.
|
||||
class DmRowGroup extends StatelessWidget {
|
||||
const DmRowGroup({super.key, required this.children});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DmCard(children: children);
|
||||
}
|
||||
|
||||
/// Switch in brand colours.
|
||||
class DmSwitch extends StatelessWidget {
|
||||
const DmSwitch({super.key, required this.value, required this.onChanged});
|
||||
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Switch.adaptive(
|
||||
value: value,
|
||||
onChanged: (v) {
|
||||
HapticFeedback.selectionClick();
|
||||
onChanged(v);
|
||||
},
|
||||
activeThumbColor: Colors.white,
|
||||
activeTrackColor: DmColors.brand,
|
||||
inactiveThumbColor: Colors.white,
|
||||
inactiveTrackColor: DmColors.border,
|
||||
trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
|
||||
);
|
||||
}
|
||||
}
|
||||
454
lib/ui/widgets/map_panel.dart
Normal file
454
lib/ui/widgets/map_panel.dart
Normal file
@@ -0,0 +1,454 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
import 'map_tiles.dart';
|
||||
|
||||
/// The pickup map: a real, pannable map with a pin fixed to the centre.
|
||||
///
|
||||
/// The pin never moves — the map moves under it, the way every ride-hailing
|
||||
/// app does it. When the map settles, [onPinMoved] fires with the coordinates
|
||||
/// under the pin so the caller can reverse-geocode them.
|
||||
class DmMapPanel extends StatefulWidget {
|
||||
const DmMapPanel({
|
||||
super.key,
|
||||
required this.lat,
|
||||
required this.lng,
|
||||
this.height,
|
||||
this.loading = false,
|
||||
this.interactive = true,
|
||||
this.zoom = 16.5,
|
||||
this.deviceLat,
|
||||
this.deviceLng,
|
||||
this.deviceAccuracy,
|
||||
this.onLocate,
|
||||
this.onPinMoveStart,
|
||||
this.onPinMoved,
|
||||
this.borderRadius = const BorderRadius.all(Radius.circular(DmRadius.lg)),
|
||||
});
|
||||
|
||||
/// Where the pin sits. Changing these recentres the map, unless the customer
|
||||
/// is mid-gesture — their drag always wins.
|
||||
final double lat;
|
||||
final double lng;
|
||||
|
||||
/// Fixed height, or null to fill whatever the parent gives it — which is how
|
||||
/// it sits inside the pickup card.
|
||||
final double? height;
|
||||
|
||||
/// True while we are still resolving a position; dims the pin.
|
||||
final bool loading;
|
||||
|
||||
/// False makes the map a picture: no dragging, no pin lift, no callbacks.
|
||||
final bool interactive;
|
||||
|
||||
final double zoom;
|
||||
|
||||
/// Where the customer is, drawn as the blue dot. Null hides it — a refused
|
||||
/// permission should show no dot rather than a dot in the wrong place.
|
||||
///
|
||||
/// Distinct from [lat]/[lng], which is where the *pickup pin* is: after a
|
||||
/// drag the two separate, which is exactly the information the dot carries.
|
||||
final double? deviceLat;
|
||||
final double? deviceLng;
|
||||
|
||||
/// Horizontal accuracy in metres. Drawn as a halo at true map scale, so it
|
||||
/// shrinks as the customer zooms in. Null draws the dot alone.
|
||||
final double? deviceAccuracy;
|
||||
|
||||
/// Shows the "my location" control when set.
|
||||
final VoidCallback? onLocate;
|
||||
|
||||
/// Fires the moment a drag begins, so the address can go stale-quiet.
|
||||
final VoidCallback? onPinMoveStart;
|
||||
|
||||
/// Fires once the map has come to rest under the pin.
|
||||
final void Function(double lat, double lng)? onPinMoved;
|
||||
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
@override
|
||||
State<DmMapPanel> createState() => _DmMapPanelState();
|
||||
}
|
||||
|
||||
class _DmMapPanelState extends State<DmMapPanel> with TickerProviderStateMixin {
|
||||
final MapController _map = MapController();
|
||||
|
||||
/// Raises the pin off the map while the customer drags.
|
||||
late final AnimationController _lift = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
);
|
||||
|
||||
late final AnimationController _glide = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
);
|
||||
|
||||
Timer? _settle;
|
||||
bool _ready = false;
|
||||
bool _dragging = false;
|
||||
|
||||
/// The last position the parent knows about — either what it handed us or
|
||||
/// what we last reported. Used to tell an external recentre from an echo of
|
||||
/// our own callback.
|
||||
late LatLng _known = LatLng(widget.lat, widget.lng);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settle?.cancel();
|
||||
_lift.dispose();
|
||||
_glide.dispose();
|
||||
_map.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DmMapPanel old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.lat == old.lat && widget.lng == old.lng) return;
|
||||
|
||||
final target = LatLng(widget.lat, widget.lng);
|
||||
// Our own reverse-geocode coming back, or a genuinely new place?
|
||||
if (_metres(target, _known) < 20) {
|
||||
_known = target;
|
||||
return;
|
||||
}
|
||||
_known = target;
|
||||
if (!_dragging) _glideTo(target);
|
||||
}
|
||||
|
||||
/// flutter_map moves instantly; a short eased glide keeps "locate me" and a
|
||||
/// searched address from teleporting.
|
||||
void _glideTo(LatLng target) {
|
||||
if (!_ready) return;
|
||||
final from = _map.camera.center;
|
||||
final fromZoom = _map.camera.zoom;
|
||||
_glide
|
||||
..stop()
|
||||
..reset();
|
||||
|
||||
void tick() {
|
||||
final t = Curves.easeInOutCubic.transform(_glide.value);
|
||||
_map.move(
|
||||
LatLng(
|
||||
from.latitude + (target.latitude - from.latitude) * t,
|
||||
from.longitude + (target.longitude - from.longitude) * t,
|
||||
),
|
||||
fromZoom + (widget.zoom - fromZoom) * t,
|
||||
);
|
||||
}
|
||||
|
||||
_glide.addListener(tick);
|
||||
_glide.forward().whenComplete(() => _glide.removeListener(tick));
|
||||
}
|
||||
|
||||
void _onEvent(MapEvent event) {
|
||||
if (!widget.interactive) return;
|
||||
|
||||
if (event is MapEventMoveStart ||
|
||||
event is MapEventFlingAnimationStart ||
|
||||
event is MapEventDoubleTapZoomStart) {
|
||||
_settle?.cancel();
|
||||
if (!_dragging) {
|
||||
_dragging = true;
|
||||
_lift.forward();
|
||||
widget.onPinMoveStart?.call();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is MapEventMoveEnd ||
|
||||
event is MapEventFlingAnimationEnd ||
|
||||
event is MapEventDoubleTapZoomEnd) {
|
||||
_dragging = false;
|
||||
_lift.reverse();
|
||||
final centre = event.camera.center;
|
||||
// A flick that ends and restarts should resolve once, not twice.
|
||||
_settle?.cancel();
|
||||
_settle = Timer(const Duration(milliseconds: 320), () {
|
||||
if (!mounted) return;
|
||||
_known = centre;
|
||||
widget.onPinMoved?.call(centre.latitude, centre.longitude);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static double _metres(LatLng a, LatLng b) {
|
||||
const earth = 6371000.0;
|
||||
double rad(double d) => d * math.pi / 180;
|
||||
final dLat = rad(b.latitude - a.latitude);
|
||||
final dLng = rad(b.longitude - a.longitude);
|
||||
final h = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(rad(a.latitude)) *
|
||||
math.cos(rad(b.latitude)) *
|
||||
math.sin(dLng / 2) *
|
||||
math.sin(dLng / 2);
|
||||
return earth * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
|
||||
}
|
||||
|
||||
/// The customer's own position: an accuracy halo at map scale with the dot
|
||||
/// on top. Returns null when there is no fix to draw.
|
||||
Widget? get _deviceLayers {
|
||||
final lat = widget.deviceLat;
|
||||
final lng = widget.deviceLng;
|
||||
if (lat == null || lng == null) return null;
|
||||
final at = LatLng(lat, lng);
|
||||
final accuracy = widget.deviceAccuracy;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (accuracy != null && accuracy > 0)
|
||||
CircleLayer(
|
||||
circles: [
|
||||
CircleMarker(
|
||||
point: at,
|
||||
// True metres, so the halo tells the truth at every zoom.
|
||||
radius: accuracy,
|
||||
useRadiusInMeter: true,
|
||||
color: DmColors.locationHalo,
|
||||
borderColor: DmColors.locationDot.withValues(alpha: 0.28),
|
||||
borderStrokeWidth: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
key: const ValueKey('deviceLocationDot'),
|
||||
point: at,
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: const _DeviceDot(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final map = ClipRRect(
|
||||
borderRadius: widget.borderRadius,
|
||||
child: ColoredBox(
|
||||
color: DmMapTiles.ground,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: FlutterMap(
|
||||
mapController: _map,
|
||||
options: MapOptions(
|
||||
initialCenter: LatLng(widget.lat, widget.lng),
|
||||
initialZoom: widget.zoom,
|
||||
minZoom: 4,
|
||||
maxZoom: 19,
|
||||
backgroundColor: DmMapTiles.ground,
|
||||
onMapReady: () => _ready = true,
|
||||
onMapEvent: _onEvent,
|
||||
interactionOptions: InteractionOptions(
|
||||
// North stays up: rotation buys nothing here and makes
|
||||
// the fixed pin read as broken.
|
||||
flags: widget.interactive
|
||||
? InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchMove |
|
||||
InteractiveFlag.pinchZoom |
|
||||
InteractiveFlag.doubleTapZoom
|
||||
: InteractiveFlag.none,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
DmMapTiles.layer(context),
|
||||
?_deviceLayers,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// The pin is an overlay, not a marker: it belongs to the screen,
|
||||
// not to a coordinate.
|
||||
Center(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: widget.loading ? 0.45 : 1,
|
||||
duration: DmMotion.base,
|
||||
child: AnimatedBuilder(
|
||||
animation: _lift,
|
||||
builder: (context, child) => _CentrePin(lift: _lift.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.onLocate != null)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
child: _LocateButton(
|
||||
busy: widget.loading,
|
||||
onTap: widget.onLocate!,
|
||||
),
|
||||
),
|
||||
|
||||
const Positioned(left: 10, bottom: 10, child: DmMapAttribution()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return widget.height == null
|
||||
? map
|
||||
: SizedBox(height: widget.height, child: map);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed centre pin. [lift] 0..1 raises it and shrinks its shadow, so a
|
||||
/// drag feels like picking the pin up rather than dragging the ground.
|
||||
class _CentrePin extends StatelessWidget {
|
||||
const _CentrePin({required this.lift});
|
||||
|
||||
final double lift;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final eased = Curves.easeOutCubic.transform(lift);
|
||||
return SizedBox(
|
||||
width: 60,
|
||||
height: 74,
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
// Ground shadow — the anchor point the pin actually refers to.
|
||||
Positioned(
|
||||
bottom: 8,
|
||||
child: Transform.scale(
|
||||
scale: 1 - eased * 0.35,
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.ink.withValues(alpha: 0.22 - eased * 0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 10 + eased * 12,
|
||||
child: const _PinHead(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "You are here". White ring so it stays legible over any cartography, and a
|
||||
/// soft shadow so it sits on the map rather than in it.
|
||||
class _DeviceDot extends StatelessWidget {
|
||||
const _DeviceDot();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.locationDot,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x3D14100F),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinHead extends StatelessWidget {
|
||||
const _PinHead();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 46,
|
||||
color: DmColors.brand,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color(0x408F0F06),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
child: CircleAvatar(radius: 5.5, backgroundColor: Colors.white),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocateButton extends StatelessWidget {
|
||||
const _LocateButton({required this.onTap, this.busy = false});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final bool busy;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
boxShadow: DmShadow.float,
|
||||
),
|
||||
child: Material(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
child: InkWell(
|
||||
onTap: busy ? null : onTap,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
child: Container(
|
||||
width: DmSpace.tap,
|
||||
height: DmSpace.tap,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: DmColors.line),
|
||||
),
|
||||
child: busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
LucideIcons.locateFixed,
|
||||
size: 22,
|
||||
color: DmColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/ui/widgets/map_tiles.dart
Normal file
76
lib/ui/widgets/map_tiles.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
import '../../data/map_config.dart';
|
||||
import '../tokens.dart';
|
||||
|
||||
/// Turns the configured tile provider ([DmMapConfig]) into a flutter_map layer.
|
||||
///
|
||||
/// The one bridge between map configuration and map rendering: no widget in
|
||||
/// this app knows a tile URL, a subdomain list or an API key, and changing
|
||||
/// provider never reaches a screen.
|
||||
class DmMapTiles {
|
||||
DmMapTiles._();
|
||||
|
||||
/// What shows through before a tile paints, or when one never arrives.
|
||||
static const ground = Color(0xFFF0EDE9);
|
||||
|
||||
static DmMapConfig get config => DmMapConfig.instance;
|
||||
|
||||
static TileLayer layer(
|
||||
BuildContext context, {
|
||||
DmMapStyle style = DmMapStyle.streets,
|
||||
}) {
|
||||
final map = config;
|
||||
return TileLayer(
|
||||
urlTemplate: map.urlFor(style),
|
||||
subdomains: map.subdomains,
|
||||
// Identifies the app to the provider so our traffic is attributable and
|
||||
// can be rate-limited or allow-listed properly.
|
||||
userAgentPackageName: DmMapConfig.packageName,
|
||||
tileProvider: NetworkTileProvider(
|
||||
headers: {'User-Agent': map.userAgent},
|
||||
// A failed tile is a cosmetic event — the ground colour covers it.
|
||||
silenceExceptions: true,
|
||||
),
|
||||
retinaMode: map.retina && RetinaMode.isHighDensity(context),
|
||||
maxNativeZoom: map.maxNativeZoom,
|
||||
// Defaults, stated so they are not "tuned" upward later: the app fetches
|
||||
// only the tiles it is about to draw plus the standard render margin.
|
||||
// Bulk prefetching or scraping a provider's tiles is out of bounds.
|
||||
keepBuffer: 2,
|
||||
panBuffer: 1,
|
||||
tileDisplay: const TileDisplay.fadeIn(
|
||||
duration: Duration(milliseconds: 160),
|
||||
),
|
||||
// Offline, or under `flutter test`, tiles simply never arrive.
|
||||
errorTileCallback: (_, _, _) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The credit line the tile licence requires. Small, quiet, and on every map —
|
||||
/// its text comes from the provider, so it stays correct after a swap.
|
||||
class DmMapAttribution extends StatelessWidget {
|
||||
const DmMapAttribution({super.key, this.dark = false});
|
||||
|
||||
/// Slightly stronger contrast for maps with busy cartography behind it.
|
||||
final bool dark;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface.withValues(alpha: dark ? 0.92 : 0.82),
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
DmMapTiles.config.attribution,
|
||||
style: DmText.small.copyWith(fontSize: 9.5, color: DmColors.ink3),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
243
lib/ui/widgets/milestones.dart
Normal file
243
lib/ui/widgets/milestones.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import 'buttons.dart';
|
||||
import 'cards.dart';
|
||||
|
||||
/// The customer's journey: seven milestones, not nine backend states.
|
||||
///
|
||||
/// Operational detail — "Miler on the way", "Order created" — is shown as
|
||||
/// context beneath the milestone that is currently happening, so a transition
|
||||
/// informs without becoming another permanent row.
|
||||
///
|
||||
/// ── Newest first, and only as many as matter ──
|
||||
///
|
||||
/// The list used to run oldest-to-newest, full height, every time: seven rows
|
||||
/// of which four were grey placeholders for things that have not happened. On
|
||||
/// a screen whose job is "where is my parcel right now", the answer was at the
|
||||
/// bottom, below the scroll. It now leads with what just happened and, when
|
||||
/// [maxRows] is set, keeps the rest behind one line — the same compression the
|
||||
/// design applies to the live tracking card.
|
||||
class DmMilestones extends StatelessWidget {
|
||||
const DmMilestones({
|
||||
super.key,
|
||||
required this.booking,
|
||||
this.stage,
|
||||
this.title = 'Recent updates',
|
||||
this.maxRows,
|
||||
this.onExpand,
|
||||
});
|
||||
|
||||
final Booking booking;
|
||||
|
||||
/// Journey to show. Defaults to the pickup's own stage; a destination order
|
||||
/// passes its own.
|
||||
final JourneyStage? stage;
|
||||
|
||||
final String title;
|
||||
|
||||
/// Show at most this many rows, newest first. Null shows the whole journey.
|
||||
final int? maxRows;
|
||||
|
||||
/// Tapped from the "All n stages" action. Null hides the action.
|
||||
final VoidCallback? onExpand;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = stage ?? booking.stage;
|
||||
final currentMilestone = CustomerMilestone.of(current);
|
||||
final milestones = CustomerMilestone.values;
|
||||
final currentIndex = currentMilestone.index;
|
||||
|
||||
// Newest first: the current milestone, then everything already done, then
|
||||
// — only if there is room — what is still to come.
|
||||
final ordered = [
|
||||
for (var i = currentIndex; i >= 0; i--) i,
|
||||
for (var i = currentIndex + 1; i < milestones.length; i++) i,
|
||||
];
|
||||
final shown = maxRows == null ? ordered : ordered.take(maxRows!).toList();
|
||||
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
),
|
||||
),
|
||||
if (onExpand != null && shown.length < ordered.length)
|
||||
DmTextAction(
|
||||
label: 'All ${milestones.length} stages',
|
||||
onPressed: onExpand,
|
||||
minHeight: 24,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Step ${currentIndex + 1} of ${milestones.length}',
|
||||
style: DmText.monoSm,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (var n = 0; n < shown.length; n++)
|
||||
_MilestoneRow(
|
||||
milestone: milestones[shown[n]],
|
||||
done: shown[n] < currentIndex,
|
||||
isCurrent: shown[n] == currentIndex,
|
||||
last: n == shown.length - 1,
|
||||
time: shown[n] <= currentIndex
|
||||
? relativeTime(booking.timeOf(milestones[shown[n]].firstStage))
|
||||
: '',
|
||||
detail: shown[n] == currentIndex ? _detailFor(current) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// One line of operational context for the stage actually in progress.
|
||||
String? _detailFor(JourneyStage current) => switch (current) {
|
||||
JourneyStage.booked => 'Matching the nearest available Miler',
|
||||
JourneyStage.assigned =>
|
||||
booking.miler == null
|
||||
? null
|
||||
: '${booking.miler!.name} accepted this pickup',
|
||||
JourneyStage.onTheWay =>
|
||||
booking.milerDistanceKm == null
|
||||
? 'Miler on the way'
|
||||
: 'Miler on the way · ${booking.milerDistanceKm} km away, '
|
||||
'about ${booking.milerEtaMinutes} min',
|
||||
JourneyStage.arrived => 'Miler arrived at your door · verification OTP ready',
|
||||
JourneyStage.pickedUp => 'Weighing and sealing your packages',
|
||||
JourneyStage.orderCreated =>
|
||||
booking.isMultiDestination
|
||||
? 'Order created for each destination · tracking issued'
|
||||
: 'Order created · tracking number issued',
|
||||
JourneyStage.inTransit => 'Moving through our network',
|
||||
JourneyStage.outForDelivery => 'Out with the delivery Miler',
|
||||
JourneyStage.delivered => null,
|
||||
};
|
||||
}
|
||||
|
||||
class _MilestoneRow extends StatelessWidget {
|
||||
const _MilestoneRow({
|
||||
required this.milestone,
|
||||
required this.done,
|
||||
required this.isCurrent,
|
||||
required this.last,
|
||||
required this.time,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
final CustomerMilestone milestone;
|
||||
final bool done;
|
||||
final bool isCurrent;
|
||||
final bool last;
|
||||
final String time;
|
||||
final String? detail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final upcoming = !done && !isCurrent;
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 3),
|
||||
_Marker(done: done, isCurrent: isCurrent),
|
||||
if (!last)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Container(width: 1.5, color: DmColors.border),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: last ? 0 : 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
milestone.label,
|
||||
style: upcoming
|
||||
? DmText.small.copyWith(color: DmColors.ink3)
|
||||
: DmText.cardTitle.copyWith(fontSize: 14.5),
|
||||
),
|
||||
),
|
||||
if (time.isNotEmpty) ...[
|
||||
const SizedBox(width: 10),
|
||||
Text(time, style: DmText.monoSm),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (detail != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(detail!, style: DmText.small),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Marker extends StatelessWidget {
|
||||
const _Marker({required this.done, required this.isCurrent});
|
||||
|
||||
final bool done;
|
||||
final bool isCurrent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isCurrent) {
|
||||
return Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(color: DmColors.brandSoft, spreadRadius: 4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: done ? DmColors.brand : DmColors.border,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
260
lib/ui/widgets/misc.dart
Normal file
260
lib/ui/widgets/misc.dart
Normal file
@@ -0,0 +1,260 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
/// One accent, three intensities — the design's `.st`.
|
||||
enum DmPillTone {
|
||||
/// Something is happening right now and nobody has to do anything: searching
|
||||
/// for a Miler, out for delivery. Crimson field, white label, solid dot.
|
||||
live,
|
||||
|
||||
/// Moving, but between beats — assigned, in transit. The crimson is held in
|
||||
/// an outline so a list of six orders is not six red blocks.
|
||||
neutral,
|
||||
|
||||
/// Finished. Grey, with a filled dot: the journey happened.
|
||||
done,
|
||||
|
||||
/// Never happened. Grey, hollow dot.
|
||||
off,
|
||||
}
|
||||
|
||||
/// Status pill used on order rows and at the head of a tracking card.
|
||||
class DmPill extends StatelessWidget {
|
||||
const DmPill(this.label, {super.key, this.tone = DmPillTone.neutral});
|
||||
|
||||
final String label;
|
||||
final DmPillTone tone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (Color bg, Color fg, BoxBorder? edge) = switch (tone) {
|
||||
DmPillTone.live => (DmColors.brand, Colors.white, null),
|
||||
DmPillTone.neutral => (
|
||||
DmColors.surface,
|
||||
DmColors.brand,
|
||||
Border.all(color: DmColors.brandLine),
|
||||
),
|
||||
DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null),
|
||||
DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null),
|
||||
};
|
||||
|
||||
// The dot is the tone's tell at a glance, before the word is read: filled
|
||||
// for anything that happened or is happening, hollow for what never did.
|
||||
final hollow = tone == DmPillTone.off;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 5, 11, 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: edge,
|
||||
borderRadius: DmRadius.all(5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.only(right: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: hollow ? Colors.transparent : fg,
|
||||
shape: BoxShape.circle,
|
||||
border: hollow ? Border.all(color: fg, width: 1.5) : null,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(fontSize: 12.5, color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pulsing dot that marks a live booking.
|
||||
class DmLiveDot extends StatefulWidget {
|
||||
const DmLiveDot({super.key, this.size = 9, this.color = DmColors.brand});
|
||||
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<DmLiveDot> createState() => _DmLiveDotState();
|
||||
}
|
||||
|
||||
class _DmLiveDotState extends State<DmLiveDot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1900),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// A ring that never stops is a battery cost and, for anyone who asked the
|
||||
// system not to animate, a distraction. Honour the setting: the dot still
|
||||
// reads as live because it is a filled brand dot on a quiet row.
|
||||
if (!WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.disableAnimations) {
|
||||
_c.repeat();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final span = widget.size + 10;
|
||||
return SizedBox(
|
||||
width: span,
|
||||
height: span,
|
||||
child: AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (_c.isAnimating)
|
||||
Transform.scale(
|
||||
scale: 1 + _c.value * 1.6,
|
||||
child: Opacity(
|
||||
opacity: (1 - _c.value) * 0.5,
|
||||
child: Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child!,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Initials avatar. Crimson-filled on a light surface, lightened white when it
|
||||
/// sits on the brand header.
|
||||
class DmAvatar extends StatelessWidget {
|
||||
const DmAvatar({
|
||||
super.key,
|
||||
required this.initials,
|
||||
this.size = 42,
|
||||
this.onBrand = false,
|
||||
});
|
||||
|
||||
final String initials;
|
||||
final double size;
|
||||
final bool onBrand;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: onBrand ? DmColors.onBrandRaise : DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
initials,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: size < 40 ? 12 : 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontVariations: const [FontVariation('wght', 800)],
|
||||
letterSpacing: 0.13,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Doormile logomark: a door inside a house silhouette.
|
||||
class DoormileMark extends StatelessWidget {
|
||||
const DoormileMark({super.key, this.size = 52, this.inverted = false});
|
||||
|
||||
final double size;
|
||||
|
||||
/// White strokes on nothing, for use on the crimson field.
|
||||
final bool inverted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: inverted ? Colors.transparent : DmColors.brand,
|
||||
borderRadius: DmRadius.all(size * 0.24),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: const _MarkPainter(),
|
||||
size: Size.square(size),
|
||||
child: SizedBox.square(dimension: size),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MarkPainter extends CustomPainter {
|
||||
const _MarkPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final s = size.width;
|
||||
final paint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = s * 0.072
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
// Roof + walls, drawn on a 24x24 grid scaled to the mark.
|
||||
double u(double v) => v / 24 * s * 0.62 + s * 0.19;
|
||||
|
||||
final house = Path()
|
||||
..moveTo(u(3.6), u(9.2))
|
||||
..lineTo(u(12), u(4))
|
||||
..lineTo(u(20.4), u(9.2))
|
||||
..lineTo(u(20.4), u(20))
|
||||
..lineTo(u(3.6), u(20))
|
||||
..close();
|
||||
|
||||
final door = Path()
|
||||
..moveTo(u(9.4), u(20))
|
||||
..lineTo(u(9.4), u(13))
|
||||
..lineTo(u(14.6), u(13))
|
||||
..lineTo(u(14.6), u(20));
|
||||
|
||||
canvas.drawPath(house, paint);
|
||||
canvas.drawPath(door, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _MarkPainter oldDelegate) => false;
|
||||
}
|
||||
285
lib/ui/widgets/option_tile.dart
Normal file
285
lib/ui/widgets/option_tile.dart
Normal file
@@ -0,0 +1,285 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
/// The selectable row used for serviceable states, districts and pickup slots.
|
||||
///
|
||||
/// Selection **fills**: the chosen slot becomes a crimson card with a glow
|
||||
/// under it, and every other option stays a white card with a hairline. The
|
||||
/// earlier design tinted the selected tile instead, which on a list of five
|
||||
/// slots left the customer hunting for which one they had picked. A filled row
|
||||
/// is unmistakable from across the room, and the radio on the right keeps the
|
||||
/// control legible for anyone who does not read the colour.
|
||||
///
|
||||
/// Unavailable options stay visible but recede, with the backend's reason
|
||||
/// attached — coverage should be legible, not hidden.
|
||||
class DmOptionTile extends StatefulWidget {
|
||||
const DmOptionTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.selected = false,
|
||||
this.enabled = true,
|
||||
this.onTap,
|
||||
this.leading,
|
||||
this.tags,
|
||||
this.trailing,
|
||||
this.titleStyle,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Optional leading glyph.
|
||||
final IconData? leading;
|
||||
|
||||
/// Chips above the title — "Fastest", "4 Milers nearby".
|
||||
final List<Widget>? tags;
|
||||
|
||||
/// Replaces the radio — "Notify me" on a full slot.
|
||||
final Widget? trailing;
|
||||
|
||||
/// Slots set their time in the display face; states and districts do not.
|
||||
final TextStyle? titleStyle;
|
||||
|
||||
@override
|
||||
State<DmOptionTile> createState() => _DmOptionTileState();
|
||||
}
|
||||
|
||||
class _DmOptionTileState extends State<DmOptionTile> {
|
||||
bool _down = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = widget.selected;
|
||||
final enabled = widget.enabled;
|
||||
|
||||
final Color background = selected
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.surface : DmColors.surfaceAlt);
|
||||
|
||||
final Color edge = selected
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.border : DmColors.border);
|
||||
|
||||
final titleColor = selected
|
||||
? Colors.white
|
||||
: (enabled ? DmColors.ink : DmColors.ink3);
|
||||
|
||||
final subColor = selected
|
||||
? DmColors.onBrand2
|
||||
: (enabled ? DmColors.ink2 : DmColors.ink3);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
enabled: enabled,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: enabled ? (_) => setState(() => _down = true) : null,
|
||||
onTapUp: enabled ? (_) => setState(() => _down = false) : null,
|
||||
onTapCancel: enabled ? () => setState(() => _down = false) : null,
|
||||
onTap: enabled
|
||||
? () {
|
||||
HapticFeedback.selectionClick();
|
||||
widget.onTap?.call();
|
||||
}
|
||||
: null,
|
||||
child: AnimatedSlide(
|
||||
offset: _down ? const Offset(0, 0.012) : Offset.zero,
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 62),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
widget.leading == null ? 16 : 14,
|
||||
14,
|
||||
16,
|
||||
14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: edge),
|
||||
boxShadow: selected ? DmShadow.lift : null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.leading != null) ...[
|
||||
Icon(
|
||||
widget.leading,
|
||||
size: 20,
|
||||
color: selected ? Colors.white : DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.tags != null && widget.tags!.isNotEmpty) ...[
|
||||
Wrap(
|
||||
spacing: 7,
|
||||
runSpacing: 6,
|
||||
children: widget.tags!,
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
],
|
||||
Text(
|
||||
widget.title,
|
||||
style: (widget.titleStyle ?? DmText.cardTitle)
|
||||
.copyWith(color: titleColor),
|
||||
),
|
||||
if (widget.subtitle != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
widget.subtitle!,
|
||||
style: DmText.small.copyWith(color: subColor),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
widget.trailing ??
|
||||
_Radio(selected: selected, enabled: enabled),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A radio, not a check: these lists are single-choice, and a tick reads as
|
||||
/// "done" where a radio reads as "this one, of these".
|
||||
class _Radio extends StatelessWidget {
|
||||
const _Radio({required this.selected, required this.enabled});
|
||||
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Colors.white
|
||||
: (enabled ? DmColors.ink4 : DmColors.border),
|
||||
width: selected ? 2 : 1.5,
|
||||
),
|
||||
),
|
||||
child: selected
|
||||
? Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row with a leading tile — "Drop a map pin", "Saved addresses".
|
||||
class DmActionRow extends StatelessWidget {
|
||||
const DmActionRow({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.onTap,
|
||||
this.subtitleColor,
|
||||
this.active = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback? onTap;
|
||||
final Color? subtitleColor;
|
||||
|
||||
/// Tints the tile once the action has been completed.
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? DmColors.okSoft : DmColors.brandSoft,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: active ? DmColors.ok : DmColors.brand,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title, style: DmText.cardTitle),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: DmText.small.copyWith(
|
||||
color: subtitleColor ?? DmColors.ink2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 20,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
559
lib/ui/widgets/pieces.dart
Normal file
559
lib/ui/widgets/pieces.dart
Normal file
@@ -0,0 +1,559 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
import 'buttons.dart';
|
||||
import 'chrome.dart';
|
||||
|
||||
enum DmTagTone {
|
||||
/// The default: a quiet sunken chip carrying a fact — "2 Milers nearby".
|
||||
neutral,
|
||||
|
||||
/// Outlined crimson. The one tag in a group that is a recommendation
|
||||
/// — "Fastest".
|
||||
brand,
|
||||
|
||||
/// Kept for the delivered state.
|
||||
ok,
|
||||
|
||||
warn,
|
||||
|
||||
/// On a crimson field.
|
||||
onFill,
|
||||
}
|
||||
|
||||
/// Small badge: "Fastest pickup", "Transit", "4 Milers nearby".
|
||||
class DmTag extends StatelessWidget {
|
||||
const DmTag(
|
||||
this.label, {
|
||||
super.key,
|
||||
this.tone = DmTagTone.neutral,
|
||||
this.icon,
|
||||
this.mono = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final DmTagTone tone;
|
||||
final IconData? icon;
|
||||
|
||||
/// For a vehicle plate or a reference carried as a tag.
|
||||
final bool mono;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (Color bg, Color fg, BoxBorder? edge) = switch (tone) {
|
||||
DmTagTone.neutral => (DmColors.surfaceAlt, DmColors.ink2, null),
|
||||
DmTagTone.brand => (
|
||||
DmColors.surface,
|
||||
DmColors.brand,
|
||||
Border.all(color: DmColors.brandLine),
|
||||
),
|
||||
DmTagTone.ok => (DmColors.okSoft, DmColors.ok, null),
|
||||
DmTagTone.warn => (const Color(0xFFFFF3DF), const Color(0xFF9C5303), null),
|
||||
DmTagTone.onFill => (DmColors.onBrandRaise, Colors.white, null),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 5, 10, 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: edge,
|
||||
borderRadius: DmRadius.all(5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 13, color: fg),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: mono
|
||||
? DmText.monoSm.copyWith(fontSize: 12, color: fg)
|
||||
: DmText.label.copyWith(fontSize: 12, color: fg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection chip — the design's `.filt`.
|
||||
///
|
||||
/// This is the app's one selection control: it is the sign-in method switch,
|
||||
/// the address label picker, the slot day filter and the rating tags. Selected
|
||||
/// is a filled crimson chip with a small glow; unselected is a white chip with
|
||||
/// a hairline. There is no third state to learn.
|
||||
class DmChoiceChip extends StatelessWidget {
|
||||
const DmChoiceChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
this.enabled = true,
|
||||
this.onTap,
|
||||
this.count,
|
||||
this.expand = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// A figure carried inside the chip, as on the Orders filters.
|
||||
final int? count;
|
||||
|
||||
/// Fills the available width — for a row of chips that splits evenly.
|
||||
final bool expand;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = selected
|
||||
? Colors.white
|
||||
: (enabled ? DmColors.ink2 : DmColors.ink4);
|
||||
|
||||
final chip = AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 38),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
|
||||
border: Border.all(
|
||||
color: selected ? DmColors.brand : DmColors.border,
|
||||
),
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
boxShadow: selected ? DmShadow.liftSm : null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(fontSize: 13, color: fg),
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 11,
|
||||
color: selected ? Colors.white : DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final tappable = Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
enabled: enabled,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: enabled
|
||||
? () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap?.call();
|
||||
}
|
||||
: null,
|
||||
child: chip,
|
||||
),
|
||||
);
|
||||
|
||||
return expand ? Expanded(child: tappable) : tappable;
|
||||
}
|
||||
}
|
||||
|
||||
/// A destination card in a horizontal strip — the design's `.ccard`.
|
||||
///
|
||||
/// Selection is a wash and a crimson edge rather than a fill: a strip of these
|
||||
/// is scanned, and a solid crimson block in a scrollable row pulls the eye off
|
||||
/// the neighbours the customer is still comparing.
|
||||
class DmGridTile extends StatelessWidget {
|
||||
const DmGridTile({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
this.enabled = true,
|
||||
this.note,
|
||||
this.onTap,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
final String? note;
|
||||
final VoidCallback? onTap;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final titleColor = enabled
|
||||
? (selected ? DmColors.brand : DmColors.ink)
|
||||
: DmColors.ink4;
|
||||
final noteColor = selected
|
||||
? DmColors.brandPress
|
||||
: (enabled ? DmColors.ink3 : DmColors.ink4);
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
enabled: enabled,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: enabled
|
||||
? () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap?.call();
|
||||
}
|
||||
: null,
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
padding: const EdgeInsets.fromLTRB(13, 12, 13, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? DmColors.brandSoft
|
||||
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
|
||||
border: Border.all(
|
||||
color: selected ? DmColors.brand : DmColors.border,
|
||||
),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: selected ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.cardTitle.copyWith(color: titleColor),
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
// Secondary: the promise may ellipsize, the place name
|
||||
// never does.
|
||||
note!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.tiny.copyWith(fontSize: 11.5, color: noteColor),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6),
|
||||
child: Icon(LucideIcons.check, size: 17, color: DmColors.brand),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Step 2 of 4 · Pickup" with its segmented rail.
|
||||
class DmStepHeader extends StatelessWidget {
|
||||
const DmStepHeader({
|
||||
super.key,
|
||||
required this.step,
|
||||
required this.total,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final int step;
|
||||
final int total;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
|
||||
child: DmStepBar(
|
||||
progress: step / total,
|
||||
steps: total,
|
||||
current: step,
|
||||
label: label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A washed strip carrying one piece of supporting information.
|
||||
class DmInfoBanner extends StatelessWidget {
|
||||
const DmInfoBanner({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.tone = DmTagTone.brand,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? message;
|
||||
final DmTagTone tone;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (bg, edge, fg, sub) = switch (tone) {
|
||||
DmTagTone.ok => (DmColors.okSoft, DmColors.okSoft, DmColors.ok, DmColors.ok),
|
||||
DmTagTone.warn => (
|
||||
const Color(0xFFFFF3DF),
|
||||
const Color(0xFFF6E3C2),
|
||||
const Color(0xFF9C5303),
|
||||
const Color(0xFF9C5303),
|
||||
),
|
||||
DmTagTone.neutral => (
|
||||
DmColors.surfaceAlt,
|
||||
DmColors.border,
|
||||
DmColors.ink,
|
||||
DmColors.ink2,
|
||||
),
|
||||
_ => (
|
||||
DmColors.brandSoft,
|
||||
DmColors.brandLine,
|
||||
DmColors.ink,
|
||||
DmColors.brandPress,
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(13, 13, 14, 13),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border.all(color: edge),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: tone == DmTagTone.brand ? DmColors.brandSoft : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: tone == DmTagTone.brand ? DmColors.brand : fg,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title, style: DmText.cardTitle.copyWith(fontSize: 14, color: fg)),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(message!, style: DmText.small.copyWith(color: sub)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference number with a copy affordance. The number is mono and large
|
||||
/// enough to read back over a phone.
|
||||
class DmCopyRow extends StatefulWidget {
|
||||
const DmCopyRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.trailing,
|
||||
this.onWash = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final Widget? trailing;
|
||||
|
||||
/// Sitting inside a wash card, where the label goes crimson.
|
||||
final bool onWash;
|
||||
|
||||
@override
|
||||
State<DmCopyRow> createState() => _DmCopyRowState();
|
||||
}
|
||||
|
||||
class _DmCopyRowState extends State<DmCopyRow> {
|
||||
bool _copied = false;
|
||||
|
||||
Future<void> _copy() async {
|
||||
await Clipboard.setData(ClipboardData(text: widget.value));
|
||||
await HapticFeedback.mediumImpact();
|
||||
if (!mounted) return;
|
||||
// The confirmation lands on the control that was pressed rather than in a
|
||||
// snackbar over the footer: it is the row the customer is looking at.
|
||||
setState(() => _copied = true);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1600));
|
||||
if (mounted) setState(() => _copied = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.label.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(
|
||||
color: widget.onWash ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.value,
|
||||
style: DmText.monoLg.copyWith(
|
||||
color: widget.onWash ? DmColors.brandPress : DmColors.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?widget.trailing,
|
||||
DmTextAction(
|
||||
label: _copied ? 'Copied' : 'Copy',
|
||||
icon: _copied ? LucideIcons.check : LucideIcons.copy,
|
||||
onPressed: _copy,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// − n + quantity control. Used for how many packages go to one place.
|
||||
class DmStepper extends StatelessWidget {
|
||||
const DmStepper({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.min = 1,
|
||||
this.canIncrease = true,
|
||||
});
|
||||
|
||||
final int value;
|
||||
final ValueChanged<int> onChanged;
|
||||
final int min;
|
||||
|
||||
/// False when a cap has been reached; the + greys out rather than vanishing.
|
||||
final bool canIncrease;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Step(
|
||||
icon: LucideIcons.minus,
|
||||
onTap: value > min ? () => onChanged(value - 1) : null,
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 30),
|
||||
child: Text(
|
||||
'$value',
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.monoLg.copyWith(fontSize: 17),
|
||||
),
|
||||
),
|
||||
_Step(
|
||||
icon: LucideIcons.plus,
|
||||
onTap: canIncrease ? () => onChanged(value + 1) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Step extends StatelessWidget {
|
||||
const _Step({required this.icon, this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onTap != null;
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: enabled,
|
||||
child: InkResponse(
|
||||
onTap: enabled
|
||||
? () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap!();
|
||||
}
|
||||
: null,
|
||||
radius: 20,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: enabled ? DmColors.ink : DmColors.ink4,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
460
lib/ui/widgets/route_map.dart
Normal file
460
lib/ui/widgets/route_map.dart
Normal file
@@ -0,0 +1,460 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../data/location_service.dart';
|
||||
import '../../data/map_config.dart';
|
||||
import '../../data/models.dart';
|
||||
import '../tokens.dart';
|
||||
import 'map_tiles.dart';
|
||||
|
||||
/// A real map of the journey: pickup and destination joined by the transit
|
||||
/// path, with the Miler's live position when there is one.
|
||||
///
|
||||
/// Read-only by design — it summarises a booking, and a non-interactive map
|
||||
/// never steals a scroll gesture from the list it sits in.
|
||||
class DmRouteMap extends StatefulWidget {
|
||||
const DmRouteMap({
|
||||
super.key,
|
||||
this.height = 190,
|
||||
this.pickup,
|
||||
this.destination,
|
||||
this.progress = 0,
|
||||
this.showMiler = false,
|
||||
this.pickupLabel,
|
||||
this.destinationLabel,
|
||||
this.badge,
|
||||
this.overlay,
|
||||
this.radius = DmRadius.lg,
|
||||
});
|
||||
|
||||
final double height;
|
||||
|
||||
/// Where the Miler collects. Without coordinates the map falls back to the
|
||||
/// service-area centre rather than showing an empty ocean.
|
||||
final Place? pickup;
|
||||
|
||||
/// The destination hub. Null draws pickup only — which is exactly right
|
||||
/// before a district has been chosen.
|
||||
final Place? destination;
|
||||
|
||||
/// How far along the route the Miler is, 0..1.
|
||||
final double progress;
|
||||
final bool showMiler;
|
||||
|
||||
final String? pickupLabel;
|
||||
final String? destinationLabel;
|
||||
|
||||
/// Pill shown top-left, e.g. "En route · 1.4 km away".
|
||||
final Widget? badge;
|
||||
|
||||
/// Anything centred over the map, e.g. the "searching" state.
|
||||
final Widget? overlay;
|
||||
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
State<DmRouteMap> createState() => _DmRouteMapState();
|
||||
}
|
||||
|
||||
class _DmRouteMapState extends State<DmRouteMap>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _pulse = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2000),
|
||||
)..repeat();
|
||||
|
||||
late final AnimationController _draw = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..forward();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulse.dispose();
|
||||
_draw.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
LatLng? get _from => widget.pickup != null && widget.pickup!.hasLocation
|
||||
? LatLng(widget.pickup!.lat!, widget.pickup!.lng!)
|
||||
: null;
|
||||
|
||||
LatLng? get _to =>
|
||||
widget.destination != null && widget.destination!.hasLocation
|
||||
? LatLng(widget.destination!.lat!, widget.destination!.lng!)
|
||||
: null;
|
||||
|
||||
/// A gentle arc rather than a ruler-straight line — it reads as a journey,
|
||||
/// and it separates the two ends when they are nearly aligned.
|
||||
List<LatLng> _arc(LatLng a, LatLng b) {
|
||||
final dLat = b.latitude - a.latitude;
|
||||
final dLng = b.longitude - a.longitude;
|
||||
// Perpendicular offset, proportional to the span.
|
||||
final bowLat = -dLng * 0.12;
|
||||
final bowLng = dLat * 0.12;
|
||||
final control = LatLng(
|
||||
(a.latitude + b.latitude) / 2 + bowLat,
|
||||
(a.longitude + b.longitude) / 2 + bowLng,
|
||||
);
|
||||
|
||||
return [
|
||||
for (var i = 0; i <= 48; i++)
|
||||
_quadratic(a, control, b, i / 48),
|
||||
];
|
||||
}
|
||||
|
||||
static LatLng _quadratic(LatLng a, LatLng c, LatLng b, double t) {
|
||||
final u = 1 - t;
|
||||
return LatLng(
|
||||
u * u * a.latitude + 2 * u * t * c.latitude + t * t * b.latitude,
|
||||
u * u * a.longitude + 2 * u * t * c.longitude + t * t * b.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final from = _from;
|
||||
final to = _to;
|
||||
final route = (from != null && to != null) ? _arc(from, to) : null;
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: DmRadius.all(widget.radius),
|
||||
child: SizedBox(
|
||||
height: widget.height,
|
||||
child: ColoredBox(
|
||||
color: DmMapTiles.ground,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _map(context, from: from, to: to, route: route),
|
||||
),
|
||||
if (widget.badge != null)
|
||||
Positioned(
|
||||
top: 12,
|
||||
left: 12,
|
||||
// Held to half the map's width so it cannot run under the
|
||||
// attribution chip opposite it, whatever the badge says.
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.5,
|
||||
),
|
||||
child: widget.badge!,
|
||||
),
|
||||
),
|
||||
if (widget.pickupLabel != null)
|
||||
Positioned(
|
||||
left: 12,
|
||||
bottom: 12,
|
||||
child: _MapLabel(
|
||||
icon: LucideIcons.circleDot,
|
||||
color: DmColors.ink,
|
||||
label: widget.pickupLabel!,
|
||||
),
|
||||
),
|
||||
if (widget.destinationLabel != null)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
child: _MapLabel(
|
||||
icon: LucideIcons.mapPin,
|
||||
color: DmColors.brand,
|
||||
label: widget.destinationLabel!,
|
||||
),
|
||||
),
|
||||
// Attribution is required on every map, so it sits top-right,
|
||||
// clear of the badge (top-left) and the labels (bottom).
|
||||
const Positioned(top: 10, right: 10, child: DmMapAttribution()),
|
||||
if (widget.overlay != null) Center(child: widget.overlay!),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _map(
|
||||
BuildContext context, {
|
||||
required LatLng? from,
|
||||
required LatLng? to,
|
||||
required List<LatLng>? route,
|
||||
}) {
|
||||
final centre =
|
||||
from ??
|
||||
to ??
|
||||
const LatLng(
|
||||
LocationService.fallbackLat,
|
||||
LocationService.fallbackLng,
|
||||
);
|
||||
|
||||
// The map itself is built once. Only the layers that animate sit inside an
|
||||
// AnimatedBuilder, so a pulsing Miler never re-creates the camera.
|
||||
return FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: centre,
|
||||
initialZoom: to == null ? 13.5 : 11,
|
||||
initialCameraFit: route == null
|
||||
? null
|
||||
: CameraFit.coordinates(
|
||||
coordinates: route,
|
||||
padding: const EdgeInsets.fromLTRB(46, 34, 46, 52),
|
||||
maxZoom: 14,
|
||||
),
|
||||
backgroundColor: DmMapTiles.ground,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.none,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
// The quieter basemap: this map is a summary, the route is the subject.
|
||||
DmMapTiles.layer(context, style: DmMapStyle.muted),
|
||||
|
||||
if (route != null)
|
||||
AnimatedBuilder(
|
||||
animation: _draw,
|
||||
builder: (context, _) {
|
||||
final drawn = Curves.easeOutCubic.transform(_draw.value);
|
||||
final upto = (route.length * drawn).ceil().clamp(2, route.length);
|
||||
return PolylineLayer(
|
||||
polylines: [
|
||||
Polyline(
|
||||
points: route.sublist(0, upto),
|
||||
color: DmColors.brand,
|
||||
strokeWidth: 4,
|
||||
borderColor: Colors.white,
|
||||
borderStrokeWidth: 3,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
AnimatedBuilder(
|
||||
animation: widget.showMiler
|
||||
? Listenable.merge([_pulse, _draw])
|
||||
: _draw,
|
||||
builder: (context, _) {
|
||||
final drawn = Curves.easeOutCubic.transform(_draw.value);
|
||||
return MarkerLayer(
|
||||
markers: [
|
||||
if (from != null)
|
||||
Marker(
|
||||
point: from,
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: const _OriginDot(),
|
||||
),
|
||||
if (to != null)
|
||||
Marker(
|
||||
point: to,
|
||||
width: 34,
|
||||
height: 40,
|
||||
alignment: Alignment.topCenter,
|
||||
child: const _DestinationPin(),
|
||||
),
|
||||
if (widget.showMiler && route != null)
|
||||
Marker(
|
||||
point: route[(
|
||||
(route.length - 1) *
|
||||
widget.progress.clamp(0.02, 0.98) *
|
||||
drawn
|
||||
).round()],
|
||||
width: 58,
|
||||
height: 58,
|
||||
child: _MilerDot(pulse: _pulse.value),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OriginDot extends StatelessWidget {
|
||||
const _OriginDot();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: DmColors.ink, width: 3.5),
|
||||
boxShadow: DmShadow.float,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DestinationPin extends StatelessWidget {
|
||||
const _DestinationPin();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 34,
|
||||
color: DmColors.brand,
|
||||
shadows: [
|
||||
Shadow(color: Color(0x338F0F06), blurRadius: 8, offset: Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 9,
|
||||
child: CircleAvatar(radius: 4, backgroundColor: Colors.white),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The Miler's live position. The expanding ring is what makes it read as live.
|
||||
class _MilerDot extends StatelessWidget {
|
||||
const _MilerDot({required this.pulse});
|
||||
|
||||
final double pulse;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 24 + pulse * 30,
|
||||
height: 24 + pulse * 30,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: DmColors.brand.withValues(alpha: (1 - pulse) * 0.22),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: DmShadow.float,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.bike,
|
||||
size: 15,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapLabel extends StatelessWidget {
|
||||
const _MapLabel({required this.icon, required this.color, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 168),
|
||||
padding: const EdgeInsets.fromLTRB(9, 6, 12, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(6),
|
||||
boxShadow: DmShadow.float,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Radar sweep shown while the backend is matching a Miler.
|
||||
class DmRadarPulse extends StatefulWidget {
|
||||
const DmRadarPulse({super.key, this.size = 150, this.child});
|
||||
|
||||
final double size;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
State<DmRadarPulse> createState() => _DmRadarPulseState();
|
||||
}
|
||||
|
||||
class _DmRadarPulseState extends State<DmRadarPulse>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2600),
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < 3; i++) _ring(i),
|
||||
?child,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ring(int index) {
|
||||
final t = (_c.value + index / 3) % 1;
|
||||
return Opacity(
|
||||
opacity: (1 - t) * 0.5,
|
||||
child: Container(
|
||||
width: widget.size * (0.35 + t * 0.65),
|
||||
height: widget.size * (0.35 + t * 0.65),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: DmColors.brand.withValues(alpha: 0.45),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
328
lib/ui/widgets/route_rail.dart
Normal file
328
lib/ui/widgets/route_rail.dart
Normal file
@@ -0,0 +1,328 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
/// One stop on a rail: where it goes, plus anything worth saying about it.
|
||||
class DmRailStop {
|
||||
const DmRailStop({required this.label, this.note, this.trailing});
|
||||
|
||||
final String label;
|
||||
|
||||
/// Second line under the label.
|
||||
final String? note;
|
||||
|
||||
/// Right-aligned text, e.g. "2 packages".
|
||||
final String? trailing;
|
||||
}
|
||||
|
||||
/// What a node on the thread stands for.
|
||||
enum DmNodeKind {
|
||||
/// Where the parcel starts. A hollow circle: a door, not a decision.
|
||||
origin,
|
||||
|
||||
/// A sorting hub in between. An outlined diamond — the parcel passes
|
||||
/// through, it does not stop here.
|
||||
hub,
|
||||
|
||||
/// The destination. A filled crimson diamond, the only solid node on the
|
||||
/// thread, so the eye lands on where the parcel is going.
|
||||
drop,
|
||||
|
||||
/// A leg that has not been arranged yet — dashed outline.
|
||||
pending,
|
||||
}
|
||||
|
||||
/// Pickup → drop, drawn as a vertical thread.
|
||||
///
|
||||
/// One component for a journey wherever it appears: an order card, the review
|
||||
/// summary, tracking. It takes a single [drop] or, when a pickup serves several
|
||||
/// places, a list of [drops] hanging off the same origin.
|
||||
class DmRouteRail extends StatelessWidget {
|
||||
const DmRouteRail({
|
||||
super.key,
|
||||
required this.pickup,
|
||||
this.pickupNote,
|
||||
this.pickupEyebrow,
|
||||
this.pickupTrailing,
|
||||
this.drop,
|
||||
this.dropNote,
|
||||
this.dropsEyebrow,
|
||||
this.drops,
|
||||
this.compact = false,
|
||||
this.hub,
|
||||
this.hubNote,
|
||||
this.hubTrailing,
|
||||
}) : assert(drop != null || drops != null, 'give the rail somewhere to go');
|
||||
|
||||
final String pickup;
|
||||
final String? pickupNote;
|
||||
|
||||
/// Uppercase micro-label above the origin, e.g. "PICKUP".
|
||||
final String? pickupEyebrow;
|
||||
|
||||
/// Action rendered at the right of the origin row.
|
||||
final Widget? pickupTrailing;
|
||||
|
||||
/// The single-destination case.
|
||||
final String? drop;
|
||||
final String? dropNote;
|
||||
|
||||
/// Uppercase micro-label above the first destination.
|
||||
final String? dropsEyebrow;
|
||||
|
||||
/// Several destinations hanging off one pickup.
|
||||
final List<DmRailStop>? drops;
|
||||
|
||||
final bool compact;
|
||||
|
||||
/// The sorting hub between pickup and drop, when the customer has been told
|
||||
/// about it. Omitted on a hyperlocal run, which never sees a hub.
|
||||
final String? hub;
|
||||
final String? hubNote;
|
||||
final Widget? hubTrailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stops = drops ?? [DmRailStop(label: drop!, note: dropNote)];
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Leg(
|
||||
eyebrow: pickupEyebrow,
|
||||
label: pickup,
|
||||
note: compact ? null : pickupNote,
|
||||
trailing: pickupTrailing,
|
||||
kind: DmNodeKind.origin,
|
||||
connector: true,
|
||||
dashedConnector: hub != null,
|
||||
compact: compact,
|
||||
),
|
||||
if (hub != null)
|
||||
_Leg(
|
||||
label: hub!,
|
||||
note: compact ? null : hubNote,
|
||||
trailing: hubTrailing,
|
||||
kind: DmNodeKind.hub,
|
||||
labelColor: DmColors.brand,
|
||||
connector: true,
|
||||
dashedConnector: true,
|
||||
compact: compact,
|
||||
),
|
||||
for (var i = 0; i < stops.length; i++)
|
||||
_Leg(
|
||||
eyebrow: i == 0 ? dropsEyebrow : null,
|
||||
label: stops[i].label,
|
||||
note: compact ? null : stops[i].note,
|
||||
trailingText: stops[i].trailing,
|
||||
kind: DmNodeKind.drop,
|
||||
connector: i != stops.length - 1,
|
||||
compact: compact,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Leg extends StatelessWidget {
|
||||
const _Leg({
|
||||
required this.label,
|
||||
required this.note,
|
||||
required this.kind,
|
||||
required this.connector,
|
||||
required this.compact,
|
||||
this.dashedConnector = false,
|
||||
this.eyebrow,
|
||||
this.trailing,
|
||||
this.trailingText,
|
||||
this.labelColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? note;
|
||||
final DmNodeKind kind;
|
||||
final bool connector;
|
||||
final bool dashedConnector;
|
||||
final bool compact;
|
||||
final String? eyebrow;
|
||||
final Widget? trailing;
|
||||
final String? trailingText;
|
||||
final Color? labelColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 15,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: eyebrow == null ? 3 : 19),
|
||||
_Node(kind: kind),
|
||||
if (connector)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: CustomPaint(
|
||||
painter: _Thread(dashed: dashedConnector),
|
||||
size: const Size(1.5, double.infinity),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: connector ? (compact ? 13 : 16) : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (eyebrow != null) ...[
|
||||
Text(eyebrow!.toUpperCase(), style: DmText.eyebrow),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: DmText.cardTitle.copyWith(color: labelColor),
|
||||
),
|
||||
),
|
||||
if (trailingText != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(
|
||||
trailingText!,
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.small,
|
||||
),
|
||||
),
|
||||
],
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(note!, style: DmText.small),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Node extends StatelessWidget {
|
||||
const _Node({required this.kind});
|
||||
|
||||
final DmNodeKind kind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 15,
|
||||
height: 15,
|
||||
child: Center(
|
||||
child: switch (kind) {
|
||||
DmNodeKind.origin => Container(
|
||||
width: 11,
|
||||
height: 11,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: DmColors.ink4, width: 2),
|
||||
),
|
||||
),
|
||||
// Rotated squares rather than a second circle: on a thread of three
|
||||
// identical dots nothing tells you which end matters, and at 11pt a
|
||||
// diamond is distinguishable from a circle at a glance where two
|
||||
// shades of the same dot are not.
|
||||
DmNodeKind.hub => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: DmColors.brand, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
DmNodeKind.drop => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
DmNodeKind.pending => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: DmColors.ink4, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The line between two nodes. Solid where the parcel is handled, dashed
|
||||
/// across a leg it merely travels — the distinction the design draws between
|
||||
/// a doorstep and a trunk route.
|
||||
class _Thread extends CustomPainter {
|
||||
const _Thread({required this.dashed});
|
||||
|
||||
final bool dashed;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = DmColors.border
|
||||
..strokeWidth = 1.5
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final x = size.width / 2;
|
||||
if (!dashed) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
|
||||
return;
|
||||
}
|
||||
|
||||
const dash = 2.0;
|
||||
const gap = 4.0;
|
||||
var y = 0.0;
|
||||
while (y < size.height) {
|
||||
canvas.drawLine(
|
||||
Offset(x, y),
|
||||
Offset(x, (y + dash).clamp(0, size.height)),
|
||||
paint,
|
||||
);
|
||||
y += dash + gap;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _Thread oldDelegate) =>
|
||||
oldDelegate.dashed != dashed;
|
||||
}
|
||||
230
lib/ui/widgets/states.dart
Normal file
230
lib/ui/widgets/states.dart
Normal file
@@ -0,0 +1,230 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
import 'buttons.dart';
|
||||
|
||||
/// Shimmering placeholder rows shown while a remote list loads.
|
||||
class DmSkeleton extends StatefulWidget {
|
||||
const DmSkeleton({super.key, this.rows = 4, this.height = 68});
|
||||
|
||||
final int rows;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
State<DmSkeleton> createState() => _DmSkeletonState();
|
||||
}
|
||||
|
||||
class _DmSkeletonState extends State<DmSkeleton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1300),
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
label: 'Loading',
|
||||
child: AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (context, _) {
|
||||
return Column(
|
||||
children: [
|
||||
for (var i = 0; i < widget.rows; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: i == widget.rows - 1 ? 0 : 10,
|
||||
),
|
||||
child: Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment(-1 - 2 * (1 - _c.value), 0),
|
||||
end: Alignment(1 + 2 * (1 - _c.value), 0),
|
||||
colors: const [
|
||||
DmColors.surfaceAlt,
|
||||
Color(0xFFFBFAF8),
|
||||
DmColors.surfaceAlt,
|
||||
],
|
||||
stops: const [0.25, 0.4, 0.6],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty / error state with an optional retry action.
|
||||
class DmEmptyState extends StatelessWidget {
|
||||
const DmEmptyState({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.icon = LucideIcons.ban,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String message;
|
||||
final IconData icon;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: DmColors.border),
|
||||
),
|
||||
child: Icon(icon, size: 24, color: DmColors.ink3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: DmText.heading,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
message,
|
||||
style: DmText.small,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (actionLabel != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
DmButton(
|
||||
label: actionLabel!,
|
||||
kind: DmButtonKind.outline,
|
||||
block: false,
|
||||
onPressed: onAction,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps every backend-driven list so loading, empty, unavailable and error +
|
||||
/// retry are handled identically wherever data comes from.
|
||||
///
|
||||
/// Adding an endpoint needs no extra UI work: return a
|
||||
/// different [Future] and these states come along.
|
||||
class DmAsyncList<T> extends StatefulWidget {
|
||||
const DmAsyncList({
|
||||
super.key,
|
||||
required this.load,
|
||||
required this.builder,
|
||||
required this.emptyTitle,
|
||||
required this.emptyMessage,
|
||||
this.emptyIcon = LucideIcons.ban,
|
||||
this.emptyActionLabel = 'Retry',
|
||||
this.onEmptyAction,
|
||||
this.skeletonRows = 4,
|
||||
this.reloadToken,
|
||||
});
|
||||
|
||||
/// Called on first build and on every retry.
|
||||
final Future<List<T>> Function({bool refresh}) load;
|
||||
final Widget Function(BuildContext context, List<T> items) builder;
|
||||
|
||||
final String emptyTitle;
|
||||
final String emptyMessage;
|
||||
final IconData emptyIcon;
|
||||
final String emptyActionLabel;
|
||||
|
||||
/// Defaults to reloading; override to send the customer somewhere else.
|
||||
final VoidCallback? onEmptyAction;
|
||||
|
||||
final int skeletonRows;
|
||||
|
||||
/// Changes when the caller has invalidated what this list is showing.
|
||||
///
|
||||
/// The future is held in state, so a screen the customer returns to would
|
||||
/// otherwise keep rendering the answer it got the first time — which is how
|
||||
/// a pickup slot from yesterday stays on screen after the server has
|
||||
/// rejected it.
|
||||
final Object? reloadToken;
|
||||
|
||||
@override
|
||||
State<DmAsyncList<T>> createState() => DmAsyncListState<T>();
|
||||
}
|
||||
|
||||
class DmAsyncListState<T> extends State<DmAsyncList<T>> {
|
||||
late Future<List<T>> _future = widget.load();
|
||||
|
||||
void reload() {
|
||||
// Note the block body: setState rejects a callback that returns a value,
|
||||
// and an expression-bodied closure would hand it the Future.
|
||||
setState(() {
|
||||
_future = widget.load(refresh: true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DmAsyncList<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.reloadToken != oldWidget.reloadToken) reload();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<T>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return DmSkeleton(rows: widget.skeletonRows);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return DmEmptyState(
|
||||
icon: LucideIcons.wifiOff,
|
||||
title: "Couldn't load this",
|
||||
message: _messageFor(snap.error),
|
||||
actionLabel: 'Retry',
|
||||
onAction: reload,
|
||||
);
|
||||
}
|
||||
final items = snap.data ?? const [];
|
||||
if (items.isEmpty) {
|
||||
return DmEmptyState(
|
||||
icon: widget.emptyIcon,
|
||||
title: widget.emptyTitle,
|
||||
message: widget.emptyMessage,
|
||||
actionLabel: widget.emptyActionLabel,
|
||||
onAction: widget.onEmptyAction ?? reload,
|
||||
);
|
||||
}
|
||||
return widget.builder(context, items);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _messageFor(Object? error) {
|
||||
final text = error?.toString() ?? '';
|
||||
final match = RegExp(r'\): (.+)$').firstMatch(text);
|
||||
return match?.group(1) ?? 'Check your connection and try again.';
|
||||
}
|
||||
}
|
||||
173
lib/ui/widgets/summary.dart
Normal file
173
lib/ui/widgets/summary.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
import 'buttons.dart';
|
||||
import 'cards.dart';
|
||||
|
||||
/// One labelled fact, optionally with an inline edit action.
|
||||
class DmSummaryRow extends StatelessWidget {
|
||||
const DmSummaryRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.secondary,
|
||||
this.muted = false,
|
||||
this.mono = false,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
/// Smaller grey line under the value (street, postcode…).
|
||||
final String? secondary;
|
||||
|
||||
/// Renders the value as placeholder text ("Not added — …").
|
||||
final bool muted;
|
||||
|
||||
/// Sets the value in mono — references, phone numbers, pincodes.
|
||||
final bool mono;
|
||||
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCardCell(
|
||||
padding: EdgeInsets.fromLTRB(16, 14, actionLabel == null ? 16 : 10, 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label.toUpperCase(), style: DmText.eyebrow),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
value,
|
||||
style: muted
|
||||
? DmText.small.copyWith(color: DmColors.ink3)
|
||||
: mono
|
||||
? DmText.mono.copyWith(fontSize: 15, color: DmColors.ink)
|
||||
: DmText.cardTitle,
|
||||
),
|
||||
if (secondary != null) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(secondary!, style: DmText.small),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (actionLabel != null)
|
||||
DmTextAction(label: actionLabel!, onPressed: onAction),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// White card that rules its children apart — Review, the confirmation summary,
|
||||
/// and the tracking facts card.
|
||||
///
|
||||
/// A thin wrapper over [DmCard] so the many call sites that say "summary card"
|
||||
/// keep saying it; new code can use [DmCard] directly and pick a tone.
|
||||
class DmSummaryCard extends StatelessWidget {
|
||||
const DmSummaryCard({super.key, required this.children, this.flat = false});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
/// Sits on the canvas rather than lifting off it.
|
||||
final bool flat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCard(
|
||||
tone: flat ? DmCardTone.sunken : DmCardTone.plain,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quiet explanatory note — the "this creates a pickup request" block.
|
||||
class DmNote extends StatelessWidget {
|
||||
const DmNote({super.key, required this.child, this.icon = LucideIcons.info});
|
||||
|
||||
final Widget child;
|
||||
final IconData icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 15, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 17, color: DmColors.ink3),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmation strip — "Delivering to Coimbatore, Tamil Nadu".
|
||||
///
|
||||
/// Crimson, not green. The app has one accent, and a confirmation inside the
|
||||
/// booking flow is a brand moment, not a system status: green here would be
|
||||
/// the only green in the product and would read as a validation message.
|
||||
class DmConfirmedStrip extends StatelessWidget {
|
||||
const DmConfirmedStrip({super.key, required this.prefix, required this.value});
|
||||
|
||||
final String prefix;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(13, 12, 15, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
border: Border.all(color: DmColors.brandLine),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(LucideIcons.check, size: 13, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.small.copyWith(color: DmColors.brandPress),
|
||||
children: [
|
||||
TextSpan(text: prefix),
|
||||
TextSpan(
|
||||
text: value,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user