Initial commit

This commit is contained in:
2026-06-23 11:29:07 +05:30
commit 401cf600e8
224 changed files with 12165 additions and 0 deletions

View 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),
);
}
}

View 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)),
],
),
);
}
}

View 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),
),
),
),
);
}
}

View 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,
),
),
),
);
}
}

View 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),
);
}
}

View 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,
),
),
),
),
),
],
),
);
}
}

View 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,
),
),
],
),
),
);
}
}

View 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(),
),
],
],
),
);
}
}

View 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,
),
],
),
),
),
);
}
}

View 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. Well 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)),
),
],
);
}

View 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 ['01 kg', '15 kg', '510 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,
),
],
),
),
);
}
}

View 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,
),
),
),
],
),
);
}
}

View 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)),
],
),
],
),
),
);
}
}

View 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.'),
);
}
}

View 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);
}

View 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)),
],
),
);
}
}

View 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)),
],
),
),
],
),
);
}
}

View 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),
],
),
);
}
}

View 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),
),
),
],
),
),
],
),
);
}
}

View 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),
),
),
],
),
);
}
}

View 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;
}

View 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),
],
),
),
),
],
),
);
}
}

View 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);
}