Replace the customer app with Doormile CX
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>
This commit is contained in:
187
lib/ui/widgets/feedback.dart
Normal file
187
lib/ui/widgets/feedback.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user