diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b47be3a..fbc7652 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -47,7 +47,11 @@ android { buildTypes { release { - signingConfig = signingConfigs.getByName("release") + signingConfig = if (keystorePropertiesFile.exists()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/android/gradle.properties b/android/gradle.properties index e449a3e..3622f12 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx2560m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 org.gradle.parallel=false org.gradle.workers.max=1 org.gradle.daemon=false diff --git a/integration_test/otp_send_test.dart b/integration_test/otp_send_test.dart new file mode 100644 index 0000000..de202d5 --- /dev/null +++ b/integration_test/otp_send_test.dart @@ -0,0 +1,67 @@ +// Confirms Firebase Phone Auth actually dispatches an OTP SMS. +// +// This must run on a real device or emulator with Google Play services — +// `flutter_test`'s headless VM has no platform channels, so FirebaseAuth +// would throw MissingPluginException there. Each run sends one real, billed +// SMS (Blaze plan) to the hardcoded number below. +// +// Run with a device attached: +// flutter test integration_test/otp_send_test.dart -d +// (list device ids with `flutter devices`) +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Firebase dispatches OTP SMS to a real phone number', + (tester) async { + await Firebase.initializeApp(); + + const phoneNumber = '+916374946729'; + final completer = Completer(); + + await FirebaseAuth.instance.verifyPhoneNumber( + phoneNumber: phoneNumber, + timeout: const Duration(seconds: 60), + verificationCompleted: (PhoneAuthCredential credential) { + if (!completer.isCompleted) { + completer.complete('verificationCompleted (device auto-verified)'); + } + }, + verificationFailed: (FirebaseAuthException e) { + if (!completer.isCompleted) { + completer.completeError('verificationFailed: ${e.code} — ${e.message}'); + } + }, + codeSent: (String verificationId, int? resendToken) { + if (!completer.isCompleted) { + completer.complete('codeSent (verificationId: $verificationId)'); + } + }, + codeAutoRetrievalTimeout: (String verificationId) { + if (!completer.isCompleted) { + completer.complete( + 'codeAutoRetrievalTimeout (verificationId: $verificationId) — request was accepted, auto-retrieval just timed out'); + } + }, + ); + + final result = await completer.future.timeout( + const Duration(seconds: 45), + onTimeout: () => throw TimeoutException( + 'No callback fired within 45s — check phone provider is enabled, ' + 'Blaze billing is active, and SHA-1/SHA-256 fingerprints are ' + 'registered in Firebase console.'), + ); + + debugPrint('OTP SEND RESULT: $result'); + expect(result, isNotEmpty); + expect(result, isNot(contains('verificationFailed'))); + }); +} diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 2b5292d..30d2080 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -7,10 +7,6 @@ class ApiConstants { static const String verifyPin = '$baseUrl/customer/verify-pin'; static const String resetPin = '$baseUrl/customer/reset-pin'; static const String logout = '$baseUrl/customer/logout'; - - // ---- Email OTP (Replace Firebase) ---- - static const String sendEmailOtp = '$baseUrl/customer/send-email-otp'; - static const String verifyEmailOtp = '$baseUrl/customer/verify-email-otp'; // ---- Profile ---- static const String profile = '$baseUrl/customer/profile'; diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index 8a7205c..ccbad2a 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -34,32 +34,12 @@ class _LoginScreenState extends State { await state.initiateLogin(_phone.text); if (!mounted) return; - state.isLoginFlow = true; - - // Bypass OTP for Easter Egg - if (_phone.text.replaceAll(' ', '') == '9876543210') { - setState(() => _loading = false); - Navigator.pushNamed(context, Routes.enterPin); - return; - } - - await state.sendOtp( - _phone.text, - onSuccess: () { - if (!mounted) return; - setState(() => _loading = false); - Navigator.pushNamed(context, Routes.otp); - }, - onError: (err) { - if (!mounted) return; - state.isLoginFlow = false; - setState(() => _loading = false); - SnackbarUtils.showError(context, err); - }, - ); + // Backend already gates login behind the PIN check, so no separate + // OTP possession-of-phone step is needed for returning users. + setState(() => _loading = false); + Navigator.pushNamed(context, Routes.enterPin); } catch (e) { if (!mounted) return; - state.isLoginFlow = false; setState(() => _loading = false); SnackbarUtils.showError(context, e.toString()); } diff --git a/lib/features/auth/screens/otp_screen.dart b/lib/features/auth/screens/otp_screen.dart index 2d64db5..abbfa97 100644 --- a/lib/features/auth/screens/otp_screen.dart +++ b/lib/features/auth/screens/otp_screen.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sms_autofill/sms_autofill.dart'; import 'package:doormile/main.dart'; import 'package:doormile/shared/state/app_state.dart'; @@ -18,7 +21,7 @@ class OtpScreen extends StatefulWidget { State createState() => _OtpScreenState(); } -class _OtpScreenState extends State { +class _OtpScreenState extends State with CodeAutoFill { final List _controllers = List.generate(6, (_) => TextEditingController()); final List _nodes = List.generate(6, (_) => FocusNode()); @@ -30,9 +33,40 @@ class _OtpScreenState extends State { void initState() { super.initState(); _startTimer(); + listenForCode(); + _prefillDebugBypass(); WidgetsBinding.instance.addPostFrameCallback((_) => _nodes[0].requestFocus()); } + /// Debug-only tester shortcut, see [AppState.verifyOtp]. Compiled out of + /// release builds via kDebugMode. + Future _prefillDebugBypass() async { + if (!kDebugMode) return; + final prefs = await SharedPreferences.getInstance(); + final provider = prefs.getInt('smsDefaultProvider') ?? 0; + final passKey = prefs.getInt('smsPassKey'); + if (provider != 1 || passKey == null) return; + if (!mounted || _controllers.any((c) => c.text.isNotEmpty)) return; + + final passKeyStr = passKey.toString().padLeft(6, '0'); + for (int i = 0; i < 6; i++) { + _controllers[i].text = passKeyStr[i]; + } + setState(() {}); + } + + @override + void codeUpdated() { + if (!mounted) return; + final digits = (code ?? '').replaceAll(RegExp(r'[^0-9]'), ''); + if (digits.length < 6) return; + for (int i = 0; i < 6; i++) { + _controllers[i].text = digits[i]; + } + setState(() {}); + _verify(); + } + void _startTimer() { _seconds = 28; _timer?.cancel(); @@ -59,8 +93,6 @@ class _OtpScreenState extends State { final state = context.read(); if (state.isResettingPin) { Navigator.pushReplacementNamed(context, Routes.resetPin); - } else if (state.isLoginFlow) { - Navigator.pushReplacementNamed(context, Routes.enterPin); } else { Navigator.pushReplacementNamed(context, Routes.setPin); } @@ -115,6 +147,7 @@ class _OtpScreenState extends State { @override void dispose() { _timer?.cancel(); + cancel(); for (final c in _controllers) { c.dispose(); } @@ -137,8 +170,10 @@ class _OtpScreenState extends State { child: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 20), - child: AppCard( - padding: const EdgeInsets.all(28), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: AppCard( + padding: const EdgeInsets.all(28), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -147,10 +182,10 @@ class _OtpScreenState extends State { height: 88, decoration: const BoxDecoration( color: AppColors.primaryFixed, shape: BoxShape.circle), - child: const Icon(Icons.mark_email_read_rounded, size: 40, color: AppColors.primary), + child: const Icon(Icons.sms_rounded, size: 40, color: AppColors.primary), ), const SizedBox(height: 24), - Text('Verify your email', + Text('Verify your number', textAlign: TextAlign.center, style: AppTheme.headlineLg), const SizedBox(height: 8), Text.rich( @@ -159,7 +194,7 @@ class _OtpScreenState extends State { children: [ const TextSpan(text: 'We sent a 6-digit code to '), TextSpan( - text: context.read().pendingEmail ?? 'your email', + text: '+91 $phone', style: AppTheme.labelMd, ), ], @@ -168,12 +203,19 @@ class _OtpScreenState extends State { ), 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), - )), + children: List.generate( + 6, + (i) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: _OtpBox( + controller: _controllers[i], + node: _nodes[i], + onChanged: (v) => _onChanged(i, v), + ), + ), + ), + ), ), const SizedBox(height: 28), if (_seconds > 0) @@ -198,6 +240,7 @@ class _OtpScreenState extends State { ), ), ), + ), ), ); } @@ -213,7 +256,6 @@ class _OtpBox extends StatelessWidget { Widget build(BuildContext context) { final filled = controller.text.isNotEmpty; return SizedBox( - width: 46, height: 56, child: TextField( controller: controller, diff --git a/lib/features/booking/screens/booking_pickup_screen.dart b/lib/features/booking/screens/booking_pickup_screen.dart index 9a11c69..4499af0 100644 --- a/lib/features/booking/screens/booking_pickup_screen.dart +++ b/lib/features/booking/screens/booking_pickup_screen.dart @@ -130,6 +130,12 @@ class _BookingPickupScreenState extends State { if (match != null) draft.pickupPincode = match.group(0)!; } + if (draft.pickupPincode.isEmpty) { + _showValidationError('Missing Pickup Pincode', + 'We couldn\'t detect a pincode for your pickup address. Please use "Use GPS current location", pick a saved address, or include the 6-digit pincode in the address.'); + return; + } + setState(() => _loading = true); await appState.fetchPriceEstimate(); setState(() => _loading = false); diff --git a/lib/features/booking/screens/payment_sheet.dart b/lib/features/booking/screens/payment_sheet.dart index 03fb6dd..20e333d 100644 --- a/lib/features/booking/screens/payment_sheet.dart +++ b/lib/features/booking/screens/payment_sheet.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:doormile/main.dart'; import 'package:doormile/shared/state/app_state.dart'; +import 'package:doormile/core/utils/snackbar_utils.dart'; import 'package:doormile/core/theme/app_colors.dart'; import 'package:doormile/core/theme/app_theme.dart'; import 'package:doormile/core/widgets/common.dart'; @@ -51,13 +52,20 @@ class _PaymentSheetState extends State<_PaymentSheet> { 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, - ); + try { + await state.placeOrder(); + if (!mounted) return; + Navigator.pop(context); // close sheet + Navigator.pushNamedAndRemoveUntil( + context, + Routes.bookingSuccess, + (r) => r.settings.name == Routes.home, + ); + } catch (e) { + if (!mounted) return; + setState(() => _processing = false); + SnackbarUtils.showError(context, e.toString()); + } } @override diff --git a/lib/shared/state/app_state.dart b/lib/shared/state/app_state.dart index 09767aa..69b1bcd 100644 --- a/lib/shared/state/app_state.dart +++ b/lib/shared/state/app_state.dart @@ -78,7 +78,6 @@ class AppState extends ChangeNotifier { String? pendingEmail; String? authToken; bool isResettingPin = false; - bool isLoginFlow = false; String? _deviceToken; AppState() { @@ -127,52 +126,103 @@ class AppState extends ChangeNotifier { notifyListeners(); } + /// Sends a 6-digit OTP to [phoneNumber] via Firebase Phone Auth. Future sendOtp(String phoneNumber, {String? firstName, String? email, bool resetPin = false, required Function onSuccess, required Function(String) onError}) async { - final fullPhone = '+91${phoneNumber.replaceAll(' ', '')}'; setPhone(phoneNumber); - pendingFirstName = firstName; - pendingEmail = email; + pendingFirstName = firstName ?? pendingFirstName; + pendingEmail = email ?? pendingEmail; isResettingPin = resetPin; - if (email == null || email.isEmpty) { - onError('Email address is required for verification.'); + final rawPhone = phoneNumber.replaceAll(' ', ''); + if (rawPhone.length != 10) { + onError('A valid mobile number is required for verification.'); + return; + } + final fullPhone = '+91$rawPhone'; + + if (kIsWeb) { + debugPrint('[OTP Service] Web build: bypassing Firebase OTP (mocking success)'); + Future.delayed(const Duration(seconds: 1), () { + verificationId = 'mock_verification_id_$fullPhone'; + onSuccess(); + }); return; } try { - final res = await http.post( - Uri.parse(ApiConstants.sendEmailOtp), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({"email": email, "phone": fullPhone}), + debugPrint('[OTP Service] Initiating Firebase phone verification for $fullPhone'); + await FirebaseAuth.instance.verifyPhoneNumber( + phoneNumber: fullPhone, + verificationCompleted: (PhoneAuthCredential credential) async { + debugPrint('[OTP Service] Verification completed automatically'); + try { + await FirebaseAuth.instance.signInWithCredential(credential); + } catch (e) { + debugPrint('[OTP Service] Error signing in: $e'); + } + }, + verificationFailed: (FirebaseAuthException e) { + debugPrint('[OTP Service] Verification failed: ${e.code} - ${e.message}'); + onError(e.message ?? 'Failed to send verification code.'); + }, + codeSent: (String vId, int? resendToken) { + debugPrint('[OTP Service] Code sent. Verification ID: $vId'); + verificationId = vId; + onSuccess(); + }, + codeAutoRetrievalTimeout: (String vId) { + debugPrint('[OTP Service] Auto retrieval timeout. Verification ID: $vId'); + verificationId = vId; + }, ); - - if (res.statusCode >= 200 && res.statusCode < 300) { - onSuccess(); - } else { - final body = jsonDecode(res.body); - onError(body['message'] ?? 'Failed to send verification code.'); - } } catch (e) { - onError('Error: $e'); + debugPrint('[OTP Service] Exception during OTP request: $e'); + onError('Error sending verification code: $e'); } } - Future verifyOtp(String smsCode, {required Function onSuccess, required Function(String) onError}) async { - try { - final res = await http.post( - Uri.parse(ApiConstants.verifyEmailOtp), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({"email": pendingEmail, "otp": smsCode}), - ); + /// Debug-only tester shortcut: if `smsDefaultProvider`/`smsPassKey` are set + /// in SharedPreferences, that fixed passkey is accepted instead of the real + /// OTP. `kDebugMode` means this branch is compiled out of release builds + /// entirely, so it can never ship as a bypass. + Future _checkDebugBypass(String smsCode) async { + if (!kDebugMode) return false; + final prefs = await SharedPreferences.getInstance(); + final provider = prefs.getInt('smsDefaultProvider') ?? 0; + final passKey = prefs.getInt('smsPassKey'); + return provider == 1 && passKey != null && smsCode == passKey.toString().padLeft(6, '0'); + } - if (res.statusCode >= 200 && res.statusCode < 300) { + Future verifyOtp(String smsCode, {required Function onSuccess, required Function(String) onError}) async { + if (await _checkDebugBypass(smsCode)) { + onSuccess(); + return; + } + + if (verificationId == null) { + onError('Verification ID is missing. Please resend OTP.'); + return; + } + + if (kIsWeb) { + debugPrint('[OTP Service] Web build: bypassing Firebase OTP verification (mocking success)'); + if (smsCode.length == 6) { onSuccess(); } else { - final body = jsonDecode(res.body); - onError(body['message'] ?? 'Invalid verification code.'); + onError('Invalid mock OTP code (must be 6 digits).'); } + return; + } + + try { + final credential = PhoneAuthProvider.credential( + verificationId: verificationId!, + smsCode: smsCode, + ); + await FirebaseAuth.instance.signInWithCredential(credential); + onSuccess(); } catch (e) { - onError('Error: $e'); + onError('Invalid verification code.'); } } @@ -317,7 +367,6 @@ class AppState extends ChangeNotifier { void login() { isLoggedIn = true; - isLoginFlow = false; orders.clear(); savedAddresses.clear(); _saveSession(); @@ -333,7 +382,6 @@ class AppState extends ChangeNotifier { verificationId = null; pendingFirstName = null; pendingEmail = null; - isLoginFlow = false; orders.clear(); savedAddresses.clear(); notifyListeners(); @@ -517,6 +565,8 @@ class AppState extends ChangeNotifier { "deliverypincode": draft.dropPincode, "deliveryaddress": draft.dropAddress, "serviceoption": draft.service, + "contactname": draft.contactName, + "contactmobile": draft.contactMobile, "parcels": [ { "itemcategory": backendCategory, diff --git a/pubspec.lock b/pubspec.lock index 3db0889..232080b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -238,6 +238,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -280,6 +285,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" geoclue: dependency: transitive description: @@ -464,6 +474,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" json_annotation: dependency: transitive description: @@ -508,26 +523,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" nested: dependency: transitive description: @@ -640,6 +655,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + pin_input_text_field: + dependency: transitive + description: + name: pin_input_text_field + sha256: f45683032283d30b670ec343781660655e3e1953438b281a0bc6e2d358486236 + url: "https://pub.dev" + source: hosted + version: "4.5.2" platform: dependency: transitive description: @@ -664,6 +687,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" provider: dependency: "direct main" description: @@ -741,6 +772,14 @@ packages: description: flutter source: sdk version: "0.0.0" + sms_autofill: + dependency: "direct main" + description: + name: sms_autofill + sha256: "5c6c5569a310fce12117eaefab5be6e29cc676b635e43be8b70b1946ec21ae91" + url: "https://pub.dev" + source: hosted + version: "2.5.0" source_span: dependency: transitive description: @@ -781,6 +820,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -793,10 +840,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" typed_data: dependency: transitive description: @@ -925,6 +972,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9909370..54b1b39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: doormile description: "Doormile — EV-first parcel delivery app." publish_to: 'none' -version: 1.0.0+1 +version: 1.0.3+5 environment: sdk: ">=3.0.0 <4.0.0" @@ -22,10 +22,13 @@ dependencies: google_maps_flutter: ^2.9.0 web_socket_channel: ^3.0.1 firebase_messaging: 16.3.0 + sms_autofill: ^2.3.0 dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^4.0.0 flutter_launcher_icons: ^0.14.4 flutter_native_splash: ^2.4.7