Initial commit
This commit is contained in:
349
lib/features/auth/screens/login_screen.dart
Normal file
349
lib/features/auth/screens/login_screen.dart
Normal file
@@ -0,0 +1,349 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _phone = TextEditingController();
|
||||
bool _loading = false;
|
||||
|
||||
bool get _valid => _phone.text.replaceAll(' ', '').length == 10;
|
||||
|
||||
void _login() async {
|
||||
if (!_valid) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
await context.read<AppState>().initiateLogin(_phone.text);
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
Navigator.pushNamed(context, Routes.enterPin);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final width = size.width;
|
||||
final height = size.height;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.primary.withValues(alpha: 0.05),
|
||||
Colors.white,
|
||||
AppColors.tertiary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: width * 0.05,
|
||||
vertical: height * 0.02,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: height * 0.08),
|
||||
const DoormileMark(size: 140),
|
||||
SizedBox(height: height * 0.04),
|
||||
Text(
|
||||
"Sign In",
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primary,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
),
|
||||
),
|
||||
SizedBox(height: height * 0.02),
|
||||
Text(
|
||||
"Enter your mobile number to get started.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.black54,
|
||||
fontSize: 18,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: height * 0.05),
|
||||
|
||||
// Phone number field
|
||||
SizedBox(
|
||||
height: 60,
|
||||
width: width * 0.9,
|
||||
child: _PhoneField(
|
||||
controller: _phone,
|
||||
onChanged: () {
|
||||
setState(() {});
|
||||
if (_valid) {
|
||||
FocusScope.of(context).unfocus();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (!_valid && _phone.text.isNotEmpty)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: width * 0.02,
|
||||
top: height * 0.005,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'Enter a valid 10-digit mobile number',
|
||||
style: TextStyle(
|
||||
color: Colors.red.shade700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: height * 0.04),
|
||||
RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'By continuing, you agree to '),
|
||||
TextSpan(
|
||||
text: 'T&C',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
// Launch T&C
|
||||
},
|
||||
),
|
||||
const TextSpan(text: ' and '),
|
||||
TextSpan(
|
||||
text: 'Privacy Policy',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
decoration: TextDecoration.none,
|
||||
),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
// Launch Privacy Policy
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: height * 0.04),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
children: [
|
||||
const TextSpan(text: 'New to Doormile? '),
|
||||
TextSpan(
|
||||
text: 'Create account',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.primary),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
Navigator.pushNamed(context, Routes.signup);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(width * 0.05),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _valid && !_loading ? _login : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _valid ? AppColors.primary : Colors.grey.shade300,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Next',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onChanged;
|
||||
const _PhoneField({required this.controller, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 65,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: Colors.grey.shade200, width: 1.5),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Simulated Flag/Prefix
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text('🇮🇳', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'+91',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 1.5,
|
||||
height: 32,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: Colors.grey.shade200,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: (_) => onChanged(),
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
inputFormatters: [_PhoneFormatter()],
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
hintText: 'Enter mobile number',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey.shade400,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.normal,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats input as "XXXXX XXXXX".
|
||||
class _PhoneFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
final trimmed = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||
String out = trimmed;
|
||||
if (trimmed.length > 5) {
|
||||
out = '${trimmed.substring(0, 5)} ${trimmed.substring(5)}';
|
||||
}
|
||||
return TextEditingValue(
|
||||
text: out,
|
||||
selection: TextSelection.collapsed(offset: out.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
261
lib/features/auth/screens/onboarding_screen.dart
Normal file
261
lib/features/auth/screens/onboarding_screen.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class _Slide {
|
||||
final IconData icon;
|
||||
final Color tint;
|
||||
final String title;
|
||||
final String body;
|
||||
final bool ev;
|
||||
const _Slide(this.icon, this.tint, this.title, this.body, {this.ev = false});
|
||||
}
|
||||
|
||||
class OnboardingScreen extends StatefulWidget {
|
||||
const OnboardingScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingScreen> createState() => _OnboardingScreenState();
|
||||
}
|
||||
|
||||
class _OnboardingScreenState extends State<OnboardingScreen> {
|
||||
final _controller = PageController();
|
||||
int _index = 0;
|
||||
|
||||
static const _slides = [
|
||||
_Slide(Icons.touch_app_rounded, AppColors.primary, 'Send a parcel in one touch',
|
||||
'No forms, no waiting. Tap, confirm, done.'),
|
||||
_Slide(Icons.map_rounded, AppColors.secondary, 'Watch every mile, live',
|
||||
'Origin to hub to your doorstep — real-time, always.'),
|
||||
_Slide(Icons.electric_moped_rounded, AppColors.tertiary, 'EV-first, on time, every time',
|
||||
'99% on-time, powered by MileTruth AI.',
|
||||
ev: true),
|
||||
];
|
||||
|
||||
void _next() {
|
||||
if (_index < _slides.length - 1) {
|
||||
_controller.nextPage(
|
||||
duration: const Duration(milliseconds: 350), curve: Curves.easeOut);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, Routes.login);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLast = _index == _slides.length - 1;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: AppBackground(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const DoormileMark(size: 36),
|
||||
AnimatedOpacity(
|
||||
opacity: isLast ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: TextButton(
|
||||
onPressed: isLast
|
||||
? null
|
||||
: () => Navigator.pushReplacementNamed(context, Routes.login),
|
||||
child: Text('Skip',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemCount: _slides.length,
|
||||
itemBuilder: (context, i) => _SlideView(slide: _slides[i]),
|
||||
),
|
||||
),
|
||||
// Dots
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(_slides.length, (i) {
|
||||
final active = i == _index;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
height: 8,
|
||||
width: active ? 24 : 8,
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primary : AppColors.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 28, 16, 24),
|
||||
child: PrimaryButton(
|
||||
label: isLast ? 'Get Started' : 'Next',
|
||||
onPressed: _next,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlideView extends StatelessWidget {
|
||||
final _Slide slide;
|
||||
const _SlideView({required this.slide});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 340, maxHeight: 340),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
slide.tint.withValues(alpha: 0.15),
|
||||
slide.tint.withValues(alpha: 0.02),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(36),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: slide.tint.withValues(alpha: 0.1),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 20),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.8), width: 2),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Decorative abstract shapes
|
||||
Positioned(
|
||||
top: 20,
|
||||
left: -10,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: slide.tint.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: -30,
|
||||
bottom: -20,
|
||||
child: Icon(
|
||||
Icons.blur_on,
|
||||
size: 160,
|
||||
color: slide.tint.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
// Main Icon Glassmorphism Container
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
width: 160,
|
||||
height: 160,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.6)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(slide.icon, size: 80, color: slide.tint),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// EV badge
|
||||
if (slide.ev)
|
||||
Positioned(
|
||||
top: 24,
|
||||
right: 24,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.eco, color: AppColors.tertiary, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'EV-Powered',
|
||||
style: TextStyle(
|
||||
color: AppColors.tertiary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Text(slide.title, textAlign: TextAlign.center, style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 8),
|
||||
Text(slide.body,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
247
lib/features/auth/screens/otp_screen.dart
Normal file
247
lib/features/auth/screens/otp_screen.dart
Normal file
@@ -0,0 +1,247 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
class OtpScreen extends StatefulWidget {
|
||||
const OtpScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OtpScreen> createState() => _OtpScreenState();
|
||||
}
|
||||
|
||||
class _OtpScreenState extends State<OtpScreen> {
|
||||
final List<TextEditingController> _controllers =
|
||||
List.generate(6, (_) => TextEditingController());
|
||||
final List<FocusNode> _nodes = List.generate(6, (_) => FocusNode());
|
||||
Timer? _timer;
|
||||
int _seconds = 28;
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTimer();
|
||||
// Pre-fill demo OTP for the no-backend prototype.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _nodes[0].requestFocus());
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_seconds = 28;
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (_seconds == 0) {
|
||||
t.cancel();
|
||||
} else {
|
||||
setState(() => _seconds--);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String get _code => _controllers.map((c) => c.text).join();
|
||||
|
||||
void _verify() async {
|
||||
if (_loading) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _loading = true);
|
||||
|
||||
await context.read<AppState>().verifyOtp(
|
||||
_code,
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
final state = context.read<AppState>();
|
||||
if (state.isResettingPin) {
|
||||
Navigator.pushReplacementNamed(context, Routes.resetPin);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, Routes.setPin);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
|
||||
for (var c in _controllers) {
|
||||
c.clear();
|
||||
}
|
||||
_nodes[0].requestFocus();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _resendOtp() async {
|
||||
setState(() => _loading = true);
|
||||
await context.read<AppState>().sendOtp(
|
||||
context.read<AppState>().phone,
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
_startTimer();
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('OTP Resent')));
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onChanged(int i, String v) {
|
||||
if (v.length > 1) {
|
||||
final chars = v.replaceAll(RegExp(r'[^0-9]'), '').split('');
|
||||
for (int j = 0; j < 6; j++) {
|
||||
_controllers[j].text = j < chars.length ? chars[j] : '';
|
||||
}
|
||||
_nodes[5].requestFocus();
|
||||
setState(() {});
|
||||
if (_code.length == 6) _verify();
|
||||
return;
|
||||
}
|
||||
if (v.isNotEmpty && i < 5) _nodes[i + 1].requestFocus();
|
||||
if (v.isEmpty && i > 0) _nodes[i - 1].requestFocus();
|
||||
setState(() {});
|
||||
if (_code.length == 6) _verify();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final n in _nodes) {
|
||||
n.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final phone = context.watch<AppState>().phone;
|
||||
final filled = _code.length == 6;
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
appBar: AppBar(
|
||||
leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: AppCard(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryFixed, shape: BoxShape.circle),
|
||||
child: const Icon(Icons.sms_rounded, size: 40, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Verify your number',
|
||||
textAlign: TextAlign.center, style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 8),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
children: [
|
||||
const TextSpan(text: 'We sent a 6-digit code to '),
|
||||
TextSpan(
|
||||
text: '+91 ${phone.isEmpty ? '98111 22334' : phone}',
|
||||
style: AppTheme.labelMd,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(6, (i) => _OtpBox(
|
||||
controller: _controllers[i],
|
||||
node: _nodes[i],
|
||||
onChanged: (v) => _onChanged(i, v),
|
||||
)),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
if (_seconds > 0)
|
||||
Text('Resend code in 0:${_seconds.toString().padLeft(2, '0')}',
|
||||
style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant))
|
||||
else
|
||||
TextButton(
|
||||
onPressed: _resendOtp,
|
||||
child: Text('Resend code now',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.primary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
PrimaryButton(
|
||||
label: 'Verify & Continue',
|
||||
trailingIcon: Icons.arrow_forward,
|
||||
loading: _loading,
|
||||
onPressed: filled ? _verify : null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('Tip: enter any 6 digits to continue',
|
||||
style: AppTheme.caption.copyWith(color: AppColors.outline)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OtpBox extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode node;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _OtpBox({required this.controller, required this.node, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filled = controller.text.isNotEmpty;
|
||||
return SizedBox(
|
||||
width: 46,
|
||||
height: 56,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: node,
|
||||
onChanged: onChanged,
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
autofillHints: const [AutofillHints.oneTimeCode],
|
||||
cursorColor: AppColors.primary,
|
||||
style: AppTheme.headlineMd,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
fillColor: filled ? AppColors.primaryFixed.withValues(alpha: 0.3) : AppColors.surface,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
316
lib/features/auth/screens/pin_screen.dart
Normal file
316
lib/features/auth/screens/pin_screen.dart
Normal file
@@ -0,0 +1,316 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
|
||||
enum PinMode { enter, set, reset }
|
||||
|
||||
class PinScreen extends StatefulWidget {
|
||||
final PinMode mode;
|
||||
const PinScreen({super.key, required this.mode});
|
||||
|
||||
@override
|
||||
State<PinScreen> createState() => _PinScreenState();
|
||||
}
|
||||
|
||||
class _PinScreenState extends State<PinScreen> {
|
||||
final _controllers = List.generate(4, (_) => TextEditingController());
|
||||
final _nodes = List.generate(4, (_) => FocusNode());
|
||||
String _code = '';
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _nodes[0].requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void _onBackspaceOnEmpty(int i) {
|
||||
if (i > 0) {
|
||||
_controllers[i - 1].clear();
|
||||
_nodes[i - 1].requestFocus();
|
||||
_updateCode();
|
||||
}
|
||||
}
|
||||
|
||||
void _onChanged(int i, String v) {
|
||||
if (v.length > 1) {
|
||||
final chars = v.replaceAll(RegExp(r'[^0-9]'), '').split('');
|
||||
for (int j = 0; j < 4; j++) {
|
||||
_controllers[j].text = j < chars.length ? chars[j] : '';
|
||||
}
|
||||
_nodes[3].requestFocus();
|
||||
_updateCode();
|
||||
if (_code.length == 4) _submit();
|
||||
return;
|
||||
}
|
||||
if (v.isNotEmpty && i < 3) _nodes[i + 1].requestFocus();
|
||||
_updateCode();
|
||||
if (_code.length == 4) _submit();
|
||||
}
|
||||
|
||||
void _updateCode() {
|
||||
setState(() {
|
||||
_code = _controllers.map((c) => c.text).join();
|
||||
});
|
||||
}
|
||||
|
||||
void _submit() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _loading = true);
|
||||
final state = context.read<AppState>();
|
||||
|
||||
try {
|
||||
if (widget.mode == PinMode.set) {
|
||||
await state.registerWithPin(_code);
|
||||
} else if (widget.mode == PinMode.reset) {
|
||||
await state.resetPinWithBackend(_code);
|
||||
} else {
|
||||
await state.verifyPinWithBackend(_code);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.pushNamedAndRemoveUntil(context, Routes.home, (r) => false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
|
||||
for (var c in _controllers) c.clear();
|
||||
_nodes[0].requestFocus();
|
||||
_updateCode();
|
||||
}
|
||||
}
|
||||
|
||||
void _forgotPin() async {
|
||||
setState(() => _loading = true);
|
||||
await context.read<AppState>().sendOtp(
|
||||
context.read<AppState>().phone,
|
||||
resetPin: true,
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
Navigator.pushNamed(context, Routes.otp);
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String title = 'Enter PIN';
|
||||
String subtitle = 'Enter your 4-digit security PIN to log in.';
|
||||
if (widget.mode == PinMode.set) {
|
||||
title = 'Set PIN';
|
||||
subtitle = 'Create a 4-digit security PIN for your account.';
|
||||
} else if (widget.mode == PinMode.reset) {
|
||||
title = 'Set New PIN';
|
||||
subtitle = 'Create a new 4-digit security PIN.';
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topRight,
|
||||
end: Alignment.bottomLeft,
|
||||
colors: [
|
||||
AppColors.tertiary.withValues(alpha: 0.05),
|
||||
Colors.white,
|
||||
AppColors.primary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.of(context).size.height * 0.12),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.lock_outline, size: 48, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primary,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.black54,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
4,
|
||||
(i) => _PinBox(
|
||||
controller: _controllers[i],
|
||||
node: _nodes[i],
|
||||
onChanged: (v) => _onChanged(i, v),
|
||||
onBackspaceOnEmpty: () => _onBackspaceOnEmpty(i),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.mode == PinMode.enter)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const SizedBox(), // Empty space for left side
|
||||
InkWell(
|
||||
onTap: _forgotPin,
|
||||
child: const Text(
|
||||
"Forget Pin?",
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.primary,
|
||||
fontFamily: AppTheme.fontFamily,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_loading) ...[
|
||||
const SizedBox(height: 32),
|
||||
const CircularProgressIndicator(color: AppColors.primary),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Back Button
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 16,
|
||||
left: 16,
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.pop(context),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black12,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.black,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinBox extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode node;
|
||||
final ValueChanged<String> onChanged;
|
||||
final VoidCallback onBackspaceOnEmpty;
|
||||
|
||||
const _PinBox({
|
||||
required this.controller,
|
||||
required this.node,
|
||||
required this.onChanged,
|
||||
required this.onBackspaceOnEmpty,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filled = controller.text.isNotEmpty;
|
||||
|
||||
return Container(
|
||||
width: 58,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: filled ? AppColors.primary : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: filled ? AppColors.primary.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: filled ? 12 : 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: filled ? AppColors.primary : Colors.grey.shade200,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey: (event) {
|
||||
if (event is RawKeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.backspace &&
|
||||
controller.text.isEmpty) {
|
||||
onBackspaceOnEmpty();
|
||||
}
|
||||
},
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: node,
|
||||
onChanged: onChanged,
|
||||
textAlign: TextAlign.center,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
obscureText: true,
|
||||
obscuringCharacter: '●',
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: filled ? Colors.white : AppColors.primary,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
counterText: "",
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
filled: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
274
lib/features/auth/screens/signup_screen.dart
Normal file
274
lib/features/auth/screens/signup_screen.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/shared/state/app_state.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/common.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class SignupScreen extends StatefulWidget {
|
||||
const SignupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends State<SignupScreen> {
|
||||
final _name = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
bool _loading = false;
|
||||
|
||||
bool get _valid =>
|
||||
_name.text.trim().isNotEmpty &&
|
||||
_email.text.trim().isNotEmpty &&
|
||||
_phone.text.replaceAll(' ', '').length == 10;
|
||||
|
||||
void _sendOtp() async {
|
||||
if (!_valid) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _loading = true);
|
||||
|
||||
await context.read<AppState>().sendOtp(
|
||||
_phone.text,
|
||||
firstName: _name.text.trim(),
|
||||
email: _email.text.trim(),
|
||||
onSuccess: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
Navigator.pushNamed(context, Routes.otp);
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_email.dispose();
|
||||
_phone.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
appBar: AppBar(
|
||||
leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const DoormileMark(size: 64),
|
||||
const SizedBox(height: 32),
|
||||
Text('Create Account', style: AppTheme.headlineLg),
|
||||
const SizedBox(height: 6),
|
||||
Text('Join Doormile for eco-friendly deliveries',
|
||||
style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Name Field
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text('Full Name',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
_CustomTextField(
|
||||
controller: _name,
|
||||
hint: 'e.g. Rahul Sharma',
|
||||
icon: Icons.person_outline,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Email Field
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text('Email Address',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
_CustomTextField(
|
||||
controller: _email,
|
||||
hint: 'e.g. rahul@example.com',
|
||||
icon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
onChanged: () => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Phone Field
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 6),
|
||||
child: Text('Mobile Number',
|
||||
style: AppTheme.labelMd
|
||||
.copyWith(color: AppColors.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
_PhoneField(controller: _phone, onChanged: () => setState(() {})),
|
||||
const SizedBox(height: 36),
|
||||
|
||||
PrimaryButton(
|
||||
label: 'Send OTP',
|
||||
loading: _loading,
|
||||
onPressed: _valid ? _sendOtp : null,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final IconData icon;
|
||||
final TextInputType? keyboardType;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
const _CustomTextField({
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
required this.icon,
|
||||
this.keyboardType,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: AppColors.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: (_) => onChanged(),
|
||||
keyboardType: keyboardType,
|
||||
style: AppTheme.bodyLg,
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
hintText: hint,
|
||||
hintStyle: AppTheme.bodyLg.copyWith(color: AppColors.outlineVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PhoneField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final VoidCallback onChanged;
|
||||
const _PhoneField({required this.controller, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('+91',
|
||||
style: AppTheme.bodyMd
|
||||
.copyWith(color: AppColors.primary, fontWeight: FontWeight.w600)),
|
||||
const Icon(Icons.expand_more, size: 20, color: AppColors.onSurfaceVariant),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 28,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
color: AppColors.outlineVariant,
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: (_) => onChanged(),
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
inputFormatters: [_PhoneFormatter()],
|
||||
style: AppTheme.bodyLg.copyWith(letterSpacing: 1),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
filled: false,
|
||||
counterText: '',
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
hintText: '00000 00000',
|
||||
hintStyle:
|
||||
AppTheme.bodyLg.copyWith(color: AppColors.outlineVariant, letterSpacing: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats input as "XXXXX XXXXX".
|
||||
class _PhoneFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||
final trimmed = digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||
String out = trimmed;
|
||||
if (trimmed.length > 5) {
|
||||
out = '${trimmed.substring(0, 5)} ${trimmed.substring(5)}';
|
||||
}
|
||||
return TextEditingValue(
|
||||
text: out,
|
||||
selection: TextSelection.collapsed(offset: out.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
lib/features/auth/screens/splash_screen.dart
Normal file
90
lib/features/auth/screens/splash_screen.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:doormile/main.dart';
|
||||
import 'package:doormile/core/theme/app_colors.dart';
|
||||
import 'package:doormile/core/theme/app_theme.dart';
|
||||
import 'package:doormile/core/widgets/logo.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen> with TickerProviderStateMixin {
|
||||
late final AnimationController _entrance =
|
||||
AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..forward();
|
||||
late final AnimationController _bar =
|
||||
AnimationController(vsync: this, duration: const Duration(seconds: 3))..repeat();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkSession();
|
||||
}
|
||||
|
||||
void _checkSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('auth_token');
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 2600));
|
||||
|
||||
if (mounted) {
|
||||
if (token != null && token.isNotEmpty) {
|
||||
Navigator.pushReplacementNamed(context, Routes.home);
|
||||
} else {
|
||||
Navigator.pushReplacementNamed(context, Routes.onboarding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_entrance.dispose();
|
||||
_bar.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fade = CurvedAnimation(parent: _entrance, curve: Curves.easeOutCubic);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background Image
|
||||
FadeTransition(
|
||||
opacity: fade,
|
||||
child: Image.asset(
|
||||
'assets/images/Introsplashscreen.png',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
|
||||
// Loading Indicator
|
||||
Positioned(
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: FadeTransition(
|
||||
opacity: fade,
|
||||
child: const SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user