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