Initial commit
This commit is contained in:
30
lib/core/constants/api_constants.dart
Normal file
30
lib/core/constants/api_constants.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
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';
|
||||
}
|
||||
56
lib/core/theme/app_colors.dart
Normal file
56
lib/core/theme/app_colors.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
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
|
||||
}
|
||||
97
lib/core/theme/app_theme.dart
Normal file
97
lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
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)),
|
||||
];
|
||||
}
|
||||
434
lib/core/widgets/common.dart
Normal file
434
lib/core/widgets/common.dart
Normal file
@@ -0,0 +1,434 @@
|
||||
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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/core/widgets/logo.dart
Normal file
69
lib/core/widgets/logo.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
349
lib/features/auth/screens/login_screen.dart
Normal file
349
lib/features/auth/screens/login_screen.dart
Normal file
@@ -0,0 +1,349 @@
|
||||
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/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);
|
||||
|
||||
try {
|
||||
await context.read<AppState>().initiateLogin(_phone.text);
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
Navigator.pushNamed(context, Routes.enterPin);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
|
||||
}
|
||||
}
|
||||
|
||||
@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),
|
||||
);
|
||||
}
|
||||
}
|
||||
261
lib/features/auth/screens/onboarding_screen.dart
Normal file
261
lib/features/auth/screens/onboarding_screen.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
247
lib/features/auth/screens/otp_screen.dart
Normal file
247
lib/features/auth/screens/otp_screen.dart
Normal file
@@ -0,0 +1,247 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/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> {
|
||||
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();
|
||||
// Pre-fill demo OTP for the no-backend prototype.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _nodes[0].requestFocus());
|
||||
}
|
||||
|
||||
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);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(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();
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('OTP Resent')));
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(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();
|
||||
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: 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.isEmpty ? '98111 22334' : phone}',
|
||||
style: AppTheme.labelMd,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(6, (i) => _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,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Tip: enter any 6 digits to continue',
|
||||
style: AppTheme.caption.copyWith(color: AppColors.outline)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
width: 46,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
316
lib/features/auth/screens/pin_screen.dart
Normal file
316
lib/features/auth/screens/pin_screen.dart
Normal file
@@ -0,0 +1,316 @@
|
||||
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/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);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
|
||||
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);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
274
lib/features/auth/screens/signup_screen.dart
Normal file
274
lib/features/auth/screens/signup_screen.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
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/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);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(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),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/features/auth/screens/splash_screen.dart
Normal file
90
lib/features/auth/screens/splash_screen.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
843
lib/features/booking/screens/booking_pickup_screen.dart
Normal file
843
lib/features/booking/screens/booking_pickup_screen.dart
Normal file
@@ -0,0 +1,843 @@
|
||||
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/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;
|
||||
|
||||
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);
|
||||
} else {
|
||||
throw Exception('Could not determine address from coordinates.');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
},
|
||||
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',
|
||||
onSelect: (addr) => setState(() {
|
||||
_pickup.text = addr;
|
||||
final state = context.read<AppState>();
|
||||
_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',
|
||||
onSelect: (addr) => setState(() {
|
||||
_pickup.text = addr;
|
||||
final state = context.read<AppState>();
|
||||
_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',
|
||||
onSelect: (addr) => setState(() {
|
||||
_pickup.text = addr;
|
||||
final state = context.read<AppState>();
|
||||
_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: () => 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) {
|
||||
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: () {
|
||||
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 Function(String) onSelect;
|
||||
|
||||
const _SavedAddressChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.address,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => onSelect(address),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
252
lib/features/booking/screens/booking_preview_screen.dart
Normal file
252
lib/features/booking/screens/booking_preview_screen.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
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';
|
||||
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);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
|
||||
}
|
||||
}
|
||||
|
||||
@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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/features/booking/screens/booking_scaffold.dart
Normal file
147
lib/features/booking/screens/booking_scaffold.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
113
lib/features/booking/screens/booking_success_screen.dart
Normal file
113
lib/features/booking/screens/booking_success_screen.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
349
lib/features/booking/screens/parcel_details_screen.dart
Normal file
349
lib/features/booking/screens/parcel_details_screen.dart
Normal file
@@ -0,0 +1,349 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
165
lib/features/booking/screens/payment_sheet.dart
Normal file
165
lib/features/booking/screens/payment_sheet.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
state.placeOrder();
|
||||
Navigator.pop(context); // close sheet
|
||||
Navigator.pushNamedAndRemoveUntil(
|
||||
context,
|
||||
Routes.bookingSuccess,
|
||||
(r) => r.settings.name == Routes.home,
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
lib/features/booking/screens/service_price_screen.dart
Normal file
253
lib/features/booking/screens/service_price_screen.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
475
lib/features/dashboard/screens/home_screen.dart
Normal file
475
lib/features/dashboard/screens/home_screen.dart
Normal file
@@ -0,0 +1,475 @@
|
||||
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.'),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
189
lib/features/dashboard/screens/main_shell.dart
Normal file
189
lib/features/dashboard/screens/main_shell.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.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 {
|
||||
final status = await Permission.locationWhenInUse.status;
|
||||
if (!status.isGranted) {
|
||||
await Permission.locationWhenInUse.request();
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
264
lib/features/dashboard/screens/profile_screen.dart
Normal file
264
lib/features/dashboard/screens/profile_screen.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
217
lib/features/dashboard/screens/saved_addresses_screen.dart
Normal file
217
lib/features/dashboard/screens/saved_addresses_screen.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
168
lib/features/dashboard/screens/summary_screen.dart
Normal file
168
lib/features/dashboard/screens/summary_screen.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/features/support/screens/chat_screen.dart
Normal file
124
lib/features/support/screens/chat_screen.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
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 ChatScreen extends StatefulWidget {
|
||||
final String riderName;
|
||||
const ChatScreen({super.key, required this.riderName});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final TextEditingController _messageController = TextEditingController();
|
||||
final List<String> _messages = [
|
||||
'Hi! I am picking up your parcel now.',
|
||||
'Please let me know if there are any specific instructions.',
|
||||
];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
if (_messageController.text.trim().isEmpty) return;
|
||||
setState(() {
|
||||
_messages.add(_messageController.text.trim());
|
||||
_messageController.clear();
|
||||
});
|
||||
}
|
||||
|
||||
@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)),
|
||||
Text('Doormile EV Rider', style: AppTheme.caption.copyWith(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isRider = index < 2; // Mocking first 2 messages from rider
|
||||
return Align(
|
||||
alignment: isRider ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isRider ? AppColors.surfaceContainerHigh : AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(16).copyWith(
|
||||
bottomLeft: isRider ? Radius.zero : const Radius.circular(16),
|
||||
bottomRight: !isRider ? Radius.zero : const Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_messages[index],
|
||||
style: AppTheme.bodyMd.copyWith(color: isRider ? AppColors.onSurface : Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16).copyWith(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,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
258
lib/features/support/screens/help_support_screen.dart
Normal file
258
lib/features/support/screens/help_support_screen.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
362
lib/features/tracking/screens/live_tracking_screen.dart
Normal file
362
lib/features/tracking/screens/live_tracking_screen.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.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 LiveTrackingScreen extends StatelessWidget {
|
||||
final Order? order;
|
||||
const LiveTrackingScreen({super.key, this.order});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final id = order?.id ?? 'DM-CHN-BLR-7741';
|
||||
final eta = order?.eta ?? '2:30 PM';
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Stack(
|
||||
children: [
|
||||
// Faux map
|
||||
Positioned.fill(
|
||||
child: CustomPaint(painter: _MapPainter()),
|
||||
),
|
||||
// Top bar
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom detail sheet
|
||||
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: 'In transit',
|
||||
color: AppColors.tertiary,
|
||||
bg: AppColors.tertiaryFixed.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('ETA $eta',
|
||||
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('Rahul Sharma',
|
||||
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: 'Rahul Sharma'),
|
||||
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) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: AppColors.inverseSurface,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight stylised street map with a dashed delivery route.
|
||||
class _MapPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final bg = Paint()..color = const Color(0xFFEDEAE9);
|
||||
canvas.drawRect(Offset.zero & size, bg);
|
||||
|
||||
final road = Paint()
|
||||
..color = Colors.white
|
||||
..strokeWidth = 10;
|
||||
final thin = Paint()
|
||||
..color = const Color(0xFFE0DCDB)
|
||||
..strokeWidth = 4;
|
||||
|
||||
for (double x = 30; x < size.width; x += 70) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), thin);
|
||||
}
|
||||
for (double y = 80; y < size.height; y += 70) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), thin);
|
||||
}
|
||||
canvas.drawLine(Offset(0, size.height * 0.35),
|
||||
Offset(size.width, size.height * 0.42), road);
|
||||
canvas.drawLine(Offset(size.width * 0.4, 0),
|
||||
Offset(size.width * 0.5, size.height * 0.7), road);
|
||||
|
||||
// Dashed route
|
||||
final routePaint = Paint()
|
||||
..color = AppColors.primary
|
||||
..strokeWidth = 4
|
||||
..style = PaintingStyle.stroke;
|
||||
final path = Path()
|
||||
..moveTo(size.width * 0.2, size.height * 0.6)
|
||||
..quadraticBezierTo(size.width * 0.35, size.height * 0.3,
|
||||
size.width * 0.55, size.height * 0.35)
|
||||
..quadraticBezierTo(size.width * 0.75, size.height * 0.4,
|
||||
size.width * 0.7, size.height * 0.18);
|
||||
_drawDashed(canvas, path, routePaint);
|
||||
|
||||
// Origin & destination markers
|
||||
final dot = Paint()..color = AppColors.primary;
|
||||
canvas.drawCircle(Offset(size.width * 0.2, size.height * 0.6), 7, dot);
|
||||
canvas.drawCircle(Offset(size.width * 0.2, size.height * 0.6), 3,
|
||||
Paint()..color = Colors.white);
|
||||
canvas.drawCircle(Offset(size.width * 0.7, size.height * 0.18), 9,
|
||||
Paint()..color = AppColors.primary);
|
||||
// Vehicle marker (mid route)
|
||||
final mid = Offset(size.width * 0.55, size.height * 0.35);
|
||||
canvas.drawCircle(mid, 16, Paint()..color = AppColors.primary.withValues(alpha: 0.2));
|
||||
canvas.drawCircle(mid, 9, Paint()..color = AppColors.tertiary);
|
||||
}
|
||||
|
||||
void _drawDashed(Canvas canvas, Path path, Paint paint) {
|
||||
const dash = 8.0;
|
||||
const gap = 6.0;
|
||||
for (final metric in path.computeMetrics()) {
|
||||
double dist = 0;
|
||||
while (dist < metric.length) {
|
||||
canvas.drawPath(
|
||||
metric.extractPath(dist, dist + dash), paint);
|
||||
dist += dash + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
278
lib/features/tracking/screens/my_orders_screen.dart
Normal file
278
lib/features/tracking/screens/my_orders_screen.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
319
lib/features/tracking/screens/shipment_journey_screen.dart
Normal file
319
lib/features/tracking/screens/shipment_journey_screen.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
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);
|
||||
}
|
||||
176
lib/main.dart
Normal file
176
lib/main.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
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';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
try {
|
||||
await Firebase.initializeApp();
|
||||
} catch (e) {
|
||||
debugPrint('Firebase initialization error: $e');
|
||||
}
|
||||
|
||||
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
|
||||
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:
|
||||
page = ChatScreen(riderName: (settings.arguments as String?) ?? 'Rahul Sharma');
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selection colour helper used by text fields.
|
||||
final selectionTheme = const TextSelectionThemeData(
|
||||
selectionColor: AppColors.primaryFixed,
|
||||
cursorColor: AppColors.primary,
|
||||
);
|
||||
122
lib/shared/models/models.dart
Normal file
122
lib/shared/models/models.dart
Normal file
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
|
||||
const AddressEntry({
|
||||
required this.label,
|
||||
required this.line,
|
||||
required this.icon,
|
||||
this.iconBg = AppColors.surfaceContainer,
|
||||
this.iconColor = AppColors.onSurfaceVariant,
|
||||
});
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
563
lib/shared/state/app_state.dart
Normal file
563
lib/shared/state/app_state.dart
Normal file
@@ -0,0 +1,563 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:firebase_auth/firebase_auth.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 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 = '';
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
void setPhone(String value) {
|
||||
phone = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> sendOtp(String phoneNumber, {String? firstName, String? email, bool resetPin = false, required Function onSuccess, required Function(String) onError}) async {
|
||||
final fullPhone = '+91${phoneNumber.replaceAll(' ', '')}';
|
||||
setPhone(phoneNumber);
|
||||
pendingFirstName = firstName;
|
||||
pendingEmail = email;
|
||||
isResettingPin = resetPin;
|
||||
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
debugPrint('⚠️ RUNNING ON WEB: Bypassing Firebase OTP (Mocking success)');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
verificationId = 'mock_verification_id_$fullPhone';
|
||||
onSuccess();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('====================================');
|
||||
debugPrint('🚀 INITIATING LIVE FIREBASE OTP FOR: $fullPhone');
|
||||
await FirebaseAuth.instance.verifyPhoneNumber(
|
||||
phoneNumber: fullPhone,
|
||||
verificationCompleted: (PhoneAuthCredential credential) async {
|
||||
debugPrint('✅ VERIFICATION COMPLETED AUTOMATICALLY');
|
||||
try {
|
||||
await FirebaseAuth.instance.signInWithCredential(credential);
|
||||
} catch (e) {
|
||||
debugPrint('❌ ERROR SIGNING IN: $e');
|
||||
onError(e.toString());
|
||||
}
|
||||
},
|
||||
verificationFailed: (FirebaseAuthException e) {
|
||||
debugPrint('❌ VERIFICATION FAILED: ${e.code} - ${e.message}');
|
||||
onError(e.message ?? 'Verification failed');
|
||||
},
|
||||
codeSent: (String vId, int? resendToken) {
|
||||
debugPrint('✅ CODE SUCCESSFULLY SENT. Verification ID: $vId');
|
||||
verificationId = vId;
|
||||
onSuccess();
|
||||
},
|
||||
codeAutoRetrievalTimeout: (String vId) {
|
||||
debugPrint('⚠️ AUTO RETRIEVAL TIMEOUT. Verification ID: $vId');
|
||||
verificationId = vId;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('💥 FIREBASE OTP CRASHED COMPLETELY: $e');
|
||||
onError('Firebase OTP Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifyOtp(String smsCode, {required Function onSuccess, required Function(String) onError}) async {
|
||||
if (verificationId == null) {
|
||||
onError('Verification ID is missing. Please resend OTP.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
debugPrint('⚠️ RUNNING ON WEB: Bypassing Firebase OTP verification (Mocking success)');
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
if (smsCode.length == 6) {
|
||||
onSuccess();
|
||||
} else {
|
||||
onError('Invalid mock OTP code (must be 6 digits).');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
PhoneAuthCredential credential = PhoneAuthProvider.credential(
|
||||
verificationId: verificationId!,
|
||||
smsCode: smsCode,
|
||||
);
|
||||
await FirebaseAuth.instance.signInWithCredential(credential);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
onError('Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 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 (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 verifyRes = await http.post(
|
||||
Uri.parse(ApiConstants.verifyPin),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({"phone": rawPhone, "pin": pin, "configid": 1001}),
|
||||
);
|
||||
|
||||
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;
|
||||
// Wipe hardcoded mock data so real users only see their own actual data!
|
||||
orders.clear();
|
||||
savedAddresses.clear();
|
||||
_saveSession();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
isLoggedIn = false;
|
||||
authToken = null;
|
||||
// Reset in-memory user details so the previous account does not linger.
|
||||
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();
|
||||
|
||||
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,
|
||||
"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');
|
||||
|
||||
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": "560001", // Default pincode until we parse from GPS
|
||||
"deliverypincode": draft.dropPincode,
|
||||
"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');
|
||||
|
||||
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,
|
||||
),
|
||||
AddressEntry(
|
||||
label: 'Work',
|
||||
line: 'Cyber City Building 10, DLF Phase 2',
|
||||
icon: Icons.work_rounded,
|
||||
iconBg: AppColors.tertiaryFixed,
|
||||
iconColor: AppColors.tertiary,
|
||||
),
|
||||
AddressEntry(
|
||||
label: 'Select City Walk Mall',
|
||||
line: 'Saket District Centre, New Delhi',
|
||||
icon: Icons.history_rounded,
|
||||
iconBg: AppColors.surfaceContainerHigh,
|
||||
iconColor: AppColors.onSurfaceVariant,
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user