Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||||
@@ -226,7 +227,7 @@ class _CreateMpinBodyState extends State<_CreateMpinBody> {
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle_rounded,
|
||||
LucideIcons.circleCheck,
|
||||
size: 14.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
@@ -235,7 +236,7 @@ class _CreateMpinBodyState extends State<_CreateMpinBody> {
|
||||
'Matches',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.acceptGreen,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||||
import 'package:miler/views/onboardscreens/otp_page.dart';
|
||||
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
|
||||
import 'package:miler/widget/Bottom_page.dart';
|
||||
import 'package:miler/helpers/rider_shell.dart';
|
||||
import 'package:miler/controllers/auth.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -57,6 +58,15 @@ class _MpinViewState extends State<_MpinView> {
|
||||
|
||||
bool isVerifying = false;
|
||||
bool _rejected = false;
|
||||
|
||||
/// What actually went wrong, from [AuthController.lastPinFailure].
|
||||
///
|
||||
/// This screen used to hard-code "Incorrect MPIN. Try again." for every
|
||||
/// failure the controller reported — including the ones that never reached
|
||||
/// the server. A rider whose sign-in was dying on a device-id lookup was told
|
||||
/// his PIN was wrong, so the one thing he could report was the one thing that
|
||||
/// was not true. Whatever the controller knows, the screen now says.
|
||||
String? _failure;
|
||||
String _riderName = '';
|
||||
|
||||
@override
|
||||
@@ -101,7 +111,10 @@ class _MpinViewState extends State<_MpinView> {
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (_rejected) _rejected = false;
|
||||
if (_rejected) {
|
||||
_rejected = false;
|
||||
_failure = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (_isComplete) {
|
||||
@@ -117,7 +130,12 @@ class _MpinViewState extends State<_MpinView> {
|
||||
c.clear();
|
||||
}
|
||||
_focusNodes[0].requestFocus();
|
||||
if (mounted) setState(() => _rejected = true);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_rejected = true;
|
||||
_failure = _auth.lastPinFailure;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submitMpin() async {
|
||||
@@ -130,14 +148,27 @@ class _MpinViewState extends State<_MpinView> {
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => isVerifying = false);
|
||||
setState(() {
|
||||
isVerifying = false;
|
||||
// Say it on the screen, not only inside a sheet the rider dismisses.
|
||||
if (!ok) {
|
||||
_rejected = true;
|
||||
_failure = _auth.lastPinFailure;
|
||||
}
|
||||
});
|
||||
|
||||
if (ok) {
|
||||
// Straight to the app. There used to be a "Go on duty" gate between the
|
||||
// PIN and the tabs, which made every launch a two-step sign-in for a
|
||||
// decision the rider can make — and change — from the switch in Home's
|
||||
// app bar at any moment of the shift.
|
||||
Get.offAll(() => const BottomPage());
|
||||
//
|
||||
// Which app depends on the tenant the login just resolved: `loginParsed`
|
||||
// persists `tenantid`/`tenantname` and calls `TenantController.load()`
|
||||
// before returning, so by this line the answer is settled. A delivery
|
||||
// rider lands in the ported Xpress flow, everyone else in the parcel
|
||||
// dashboard. See [riderShell].
|
||||
Get.offAll(() => riderShell());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +192,7 @@ class _MpinViewState extends State<_MpinView> {
|
||||
children: [
|
||||
AuthPrimaryButton(
|
||||
label: 'Unlock',
|
||||
icon: Icons.lock_open_rounded,
|
||||
icon: LucideIcons.lockOpen,
|
||||
loading: isVerifying,
|
||||
onPressed: isVerifying || !_isComplete ? null : _submitMpin,
|
||||
),
|
||||
@@ -190,7 +221,9 @@ class _MpinViewState extends State<_MpinView> {
|
||||
// rejected and the boxes were reset — otherwise they empty themselves
|
||||
// with no stated reason.
|
||||
AuthInlineError(
|
||||
message: _rejected ? 'Incorrect MPIN. Try again.' : null,
|
||||
message: _rejected
|
||||
? (_failure ?? 'Incorrect MPIN. Try again.')
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -239,7 +240,7 @@ class _SignInState extends State<SignIn> {
|
||||
cursorColor: ColorConstants.primary,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -258,7 +259,7 @@ class _SignInState extends State<SignIn> {
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.1,
|
||||
color: error
|
||||
? ColorConstants.errorRed
|
||||
@@ -271,7 +272,7 @@ class _SignInState extends State<SignIn> {
|
||||
hintStyle: TextStyle(
|
||||
color: ColorConstants.secondaryText,
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 1.2,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -292,7 +293,7 @@ class _SignInState extends State<SignIn> {
|
||||
'+91',
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -312,7 +313,7 @@ class _SignInState extends State<SignIn> {
|
||||
? Padding(
|
||||
padding: EdgeInsets.only(right: 14.w),
|
||||
child: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
LucideIcons.circleCheck,
|
||||
size: 20.sp,
|
||||
color: ColorConstants.acceptGreen,
|
||||
),
|
||||
@@ -357,7 +358,7 @@ class _SignInState extends State<SignIn> {
|
||||
text: 'Terms of Service',
|
||||
style: TextStyle(
|
||||
color: ColorConstants.slateText,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: ColorConstants.borderStrong,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -369,7 +370,7 @@ class _SignInState extends State<SignIn> {
|
||||
text: 'Privacy Policy',
|
||||
style: TextStyle(
|
||||
color: ColorConstants.slateText,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: ColorConstants.borderStrong,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -418,7 +419,9 @@ class _IndiaFlag extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(2.5.r),
|
||||
child: Column(
|
||||
children: [
|
||||
const Expanded(child: ColoredBox(color: _saffron, child: SizedBox.expand())),
|
||||
const Expanded(
|
||||
child: ColoredBox(color: _saffron, child: SizedBox.expand()),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
@@ -433,7 +436,9 @@ class _IndiaFlag extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: ColoredBox(color: _green, child: SizedBox.expand())),
|
||||
const Expanded(
|
||||
child: ColoredBox(color: _green, child: SizedBox.expand()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
@@ -50,7 +51,7 @@ import 'package:miler/views/helpers/widgets/app_widgets.dart';
|
||||
abstract final class AuthType {
|
||||
static TextStyle get title => TextStyle(
|
||||
fontSize: 27.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.7,
|
||||
height: 1.15,
|
||||
color: ColorConstants.slateText,
|
||||
@@ -67,7 +68,7 @@ abstract final class AuthType {
|
||||
|
||||
static TextStyle get fieldLabel => TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -75,7 +76,7 @@ abstract final class AuthType {
|
||||
|
||||
static TextStyle get meta => TextStyle(
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.45,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -168,7 +169,7 @@ class AuthSteps extends StatelessWidget {
|
||||
'STEP $step OF $total',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.0,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -188,7 +189,9 @@ class AuthSteps extends StatelessWidget {
|
||||
color: i <= step
|
||||
? ColorConstants.primary
|
||||
: ColorConstants.borderStrong,
|
||||
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
|
||||
borderRadius: BorderRadius.circular(
|
||||
DesignConstants.radiusFull,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -278,7 +281,7 @@ class AuthScaffold extends StatelessWidget {
|
||||
onTap: onBack!,
|
||||
semanticLabel: 'Back',
|
||||
child: Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
LucideIcons.arrowLeft,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.slateText,
|
||||
),
|
||||
@@ -385,7 +388,7 @@ class AuthScaffold extends StatelessWidget {
|
||||
onTap: onBack!,
|
||||
semanticLabel: 'Back',
|
||||
child: Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
LucideIcons.arrowLeft,
|
||||
size: 22.sp,
|
||||
color: ColorConstants.onAccent,
|
||||
),
|
||||
@@ -513,7 +516,8 @@ class AuthBrandBanner extends StatelessWidget {
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOutCubic,
|
||||
width: double.infinity,
|
||||
height: (typing ? _typingHeight : _restHeight).h +
|
||||
height:
|
||||
(typing ? _typingHeight : _restHeight).h +
|
||||
MediaQuery.paddingOf(context).top,
|
||||
color: ColorConstants.primary,
|
||||
child: ClipRect(
|
||||
@@ -526,8 +530,16 @@ class AuthBrandBanner extends StatelessWidget {
|
||||
// ornament. Bled off both edges and held at 7% white: at that
|
||||
// weight they read as texture on the brand colour and never as
|
||||
// objects the rider might try to tap.
|
||||
Positioned(left: -78.w, top: -34.h, child: const _BrandRing(size: 196)),
|
||||
Positioned(right: -92.w, bottom: -78.h, child: const _BrandRing(size: 244)),
|
||||
Positioned(
|
||||
left: -78.w,
|
||||
top: -34.h,
|
||||
child: const _BrandRing(size: 196),
|
||||
),
|
||||
Positioned(
|
||||
right: -92.w,
|
||||
bottom: -78.h,
|
||||
child: const _BrandRing(size: 244),
|
||||
),
|
||||
|
||||
Center(
|
||||
child: Padding(
|
||||
@@ -566,7 +578,7 @@ class AuthBrandBanner extends StatelessWidget {
|
||||
'MILER · RIDER APP',
|
||||
style: TextStyle(
|
||||
fontSize: 10.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.6,
|
||||
color: ColorConstants.onAccent.withValues(alpha: 0.72),
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -667,7 +679,7 @@ class AuthPrimaryButton extends StatelessWidget {
|
||||
const AuthPrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.icon = Icons.arrow_forward_rounded,
|
||||
this.icon = LucideIcons.arrowRight,
|
||||
this.loading = false,
|
||||
required this.onPressed,
|
||||
});
|
||||
@@ -719,7 +731,7 @@ class AuthPrimaryButton extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.2,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -774,7 +786,7 @@ class AuthTextAction extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -812,7 +824,7 @@ class AuthInlineError extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline_rounded,
|
||||
LucideIcons.circleAlert,
|
||||
size: 15.sp,
|
||||
color: ColorConstants.errorRed,
|
||||
),
|
||||
@@ -823,7 +835,7 @@ class AuthInlineError extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: ColorConstants.errorRed,
|
||||
fontSize: 12.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.35,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -865,31 +877,65 @@ class AuthCodeRow extends StatelessWidget {
|
||||
this.hasError = false,
|
||||
});
|
||||
|
||||
/// ── The boxes stopped stretching ──
|
||||
///
|
||||
/// Each box was `Expanded` inside an `AspectRatio`, so four of them divided
|
||||
/// the *whole* content width between themselves: on a 390pt phone that is a
|
||||
/// **79×83pt** box, four of them, filling the screen edge to edge. A PIN
|
||||
/// field that size reads as a keypad rather than as an input — the digits
|
||||
/// float in the middle of a large empty square, the group dominates a screen
|
||||
/// whose only job is four taps, and it gets worse on a tablet, where the
|
||||
/// boxes keep growing with the window.
|
||||
///
|
||||
/// Every app that does this well — the ones a rider already uses to bank and
|
||||
/// to sign in — sizes the box to the *digit*, not to the screen, and centres
|
||||
/// the group. 56pt is a comfortable target well above the 44pt floor, and
|
||||
/// four of them plus their gaps come to 260 of the 390 available, so the
|
||||
/// group reads as one control with air around it instead of a wall.
|
||||
///
|
||||
/// The size is a *preference*, not a constant: [LayoutBuilder] caps it at
|
||||
/// whatever the width actually allows, so a 6-digit code on a 320pt phone
|
||||
/// shrinks to fit rather than overflowing. It can get smaller than preferred;
|
||||
/// it can never get bigger.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final count = controllers.length;
|
||||
// Taller boxes when there are fewer of them: a 4-digit PIN gets room to
|
||||
// feel deliberate, a 6-digit code has to stay inside the width.
|
||||
final ratio = count > 4 ? 1.20 : 1.05;
|
||||
final double gap = MilerCodeBox.gap(context).w * (count > 4 ? 1 : 1.5);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < count; i++) ...[
|
||||
if (i > 0) SizedBox(width: MilerCodeBox.gap(context).w),
|
||||
Expanded(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1 / ratio,
|
||||
child: _Box(
|
||||
controller: controllers[i],
|
||||
node: nodes[i],
|
||||
obscure: obscure,
|
||||
hasError: hasError,
|
||||
onChanged: (v) => onChanged(v, i),
|
||||
// A 4-digit PIN gets the full target; a 6-digit code starts smaller so it
|
||||
// is not relying on the cap to be legible on a narrow phone.
|
||||
final double preferred = (count > 4 ? 46 : 56).w;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final double available = constraints.maxWidth - gap * (count - 1);
|
||||
final double box = preferred < available / count
|
||||
? preferred
|
||||
: available / count;
|
||||
// Barely taller than it is wide. A tall box puts the digit in the
|
||||
// middle of empty space; this reads as a field.
|
||||
final double height = box * 1.12;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < count; i++) ...[
|
||||
if (i > 0) SizedBox(width: gap),
|
||||
SizedBox(
|
||||
width: box,
|
||||
height: height,
|
||||
child: _Box(
|
||||
controller: controllers[i],
|
||||
node: nodes[i],
|
||||
obscure: obscure,
|
||||
hasError: hasError,
|
||||
onChanged: (v) => onChanged(v, i),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:miler/views/helpers/widgets/page_transitions.dart';
|
||||
@@ -203,7 +204,7 @@ class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill {
|
||||
TextSpan(
|
||||
text: _maskedPhone,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -241,7 +242,7 @@ class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill {
|
||||
child: canResend
|
||||
? AuthTextAction(
|
||||
label: 'Send a new code',
|
||||
icon: Icons.refresh_rounded,
|
||||
icon: LucideIcons.rotateCw,
|
||||
onPressed: () async {
|
||||
setState(() => _error = null);
|
||||
_startTimer();
|
||||
|
||||
Reference in New Issue
Block a user