Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
188 lines
4.7 KiB
Dart
188 lines
4.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../tokens.dart';
|
|
|
|
/// Dark pill toast, matching the prototype's transient confirmations.
|
|
class DmToast {
|
|
DmToast._();
|
|
|
|
static OverlayEntry? _entry;
|
|
|
|
static void show(BuildContext context, String message) {
|
|
final overlay = Overlay.maybeOf(context);
|
|
if (overlay == null) return;
|
|
|
|
_entry?.remove();
|
|
final entry = OverlayEntry(
|
|
builder: (context) => _ToastBody(message: message),
|
|
);
|
|
_entry = entry;
|
|
overlay.insert(entry);
|
|
|
|
Future<void>.delayed(const Duration(milliseconds: 2400), () {
|
|
if (_entry == entry) {
|
|
entry.remove();
|
|
_entry = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Called when the app is torn down in tests.
|
|
static void dismiss() {
|
|
_entry?.remove();
|
|
_entry = null;
|
|
}
|
|
}
|
|
|
|
class _ToastBody extends StatefulWidget {
|
|
const _ToastBody({required this.message});
|
|
final String message;
|
|
|
|
@override
|
|
State<_ToastBody> createState() => _ToastBodyState();
|
|
}
|
|
|
|
class _ToastBodyState extends State<_ToastBody>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _c = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 240),
|
|
)..forward();
|
|
|
|
@override
|
|
void dispose() {
|
|
_c.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bottom = MediaQuery.of(context).viewInsets.bottom;
|
|
return Positioned(
|
|
left: 20,
|
|
right: 20,
|
|
bottom: 96 + bottom,
|
|
child: FadeTransition(
|
|
opacity: _c,
|
|
child: SlideTransition(
|
|
position: Tween(
|
|
begin: const Offset(0, 0.3),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)),
|
|
child: Center(
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 18,
|
|
vertical: 11,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xF0191716),
|
|
borderRadius: DmRadius.all(DmRadius.sm),
|
|
),
|
|
child: Text(
|
|
widget.message,
|
|
textAlign: TextAlign.center,
|
|
style: DmText.label.copyWith(
|
|
fontSize: 13.5,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Bottom sheet with the prototype's grip, radius and padding.
|
|
Future<T?> showDmSheet<T>({
|
|
required BuildContext context,
|
|
required WidgetBuilder builder,
|
|
bool isScrollControlled = true,
|
|
}) {
|
|
return showModalBottomSheet<T>(
|
|
context: context,
|
|
isScrollControlled: isScrollControlled,
|
|
backgroundColor: DmColors.canvas,
|
|
barrierColor: DmColors.scrim,
|
|
elevation: 0,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(DmRadius.xl)),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
builder: (context) => Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(context).size.height * 0.86,
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
DmSpace.pad,
|
|
10,
|
|
DmSpace.pad,
|
|
20,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const _SheetGrip(),
|
|
Flexible(child: builder(context)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
class _SheetGrip extends StatelessWidget {
|
|
const _SheetGrip();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: 36,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(top: 2, bottom: 18),
|
|
decoration: BoxDecoration(
|
|
color: DmColors.border,
|
|
borderRadius: DmRadius.all(2),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Sheet heading + supporting line.
|
|
class DmSheetHeader extends StatelessWidget {
|
|
const DmSheetHeader({super.key, required this.title, this.subtitle});
|
|
|
|
final String title;
|
|
final String? subtitle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: DmText.sheetTitle),
|
|
if (subtitle != null) ...[
|
|
const SizedBox(height: 3),
|
|
Text(subtitle!, style: DmText.tiny),
|
|
],
|
|
const SizedBox(height: 18),
|
|
],
|
|
);
|
|
}
|
|
}
|