diff --git a/.env.example b/.env.example index 6870dd5..774efa6 100644 --- a/.env.example +++ b/.env.example @@ -6,5 +6,5 @@ AI_API_KEY= AI_BASE_URL= HTTP_ADDR=:8080 # Local demo: keep events in processing long enough to see PENDING → PROCESSING in the app. -DEMO_PROCESS_DELAY_MS=4000 +DEMO_PROCESS_DELAY_MS=8000 diff --git a/README.md b/README.md index 5a48e98..cb3cf40 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ fvm flutter run --dart-define=API_BASE=http://127.0.0.1:8080 # Android emulator: API_BASE=http://10.0.2.2:8080 ``` -In the app: sign in as `demo-user-1@walletops.local` / `ops-secret-1` → **Events → Run live demo → confirm**. Watch each webhook move **PENDING → PROCESSING → PROCESSED** (compose sets a short demo worker delay). Open an event for pipeline steps → **Explain**. +In the app: sign in as `demo-user-1@walletops.local` / `ops-secret-1` → **Events → Run live demo → confirm**. Watch each webhook move **PENDING → PROCESSING → PROCESSED** (compose holds processing ~8s so the queue is readable). Open an event for pipeline steps → **Explain**. `POST /v1/demo/simulate` (auth) inserts fresh pending events for the journey; partner HMAC ingest remains `POST /v1/webhooks/events`. diff --git a/docker-compose.yml b/docker-compose.yml index 3021c38..79bb1a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: AI_PROVIDER: mock AI_API_KEY: "" HTTP_ADDR: ":8080" - DEMO_PROCESS_DELAY_MS: "4000" + DEMO_PROCESS_DELAY_MS: "8000" ports: - "8080:8080" depends_on: diff --git a/mobile/lib/core/theme/app_theme.dart b/mobile/lib/core/theme/app_theme.dart index 8ff080c..f650f28 100644 --- a/mobile/lib/core/theme/app_theme.dart +++ b/mobile/lib/core/theme/app_theme.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'app_spacing.dart'; @@ -259,9 +260,7 @@ ThemeData _buildTheme(Brightness brightness) { } TextTheme _textTheme(ColorScheme scheme) { - final base = Typography.material2021( - platform: TargetPlatform.android, - ).black.apply( + final base = GoogleFonts.ibmPlexSansTextTheme().apply( bodyColor: scheme.onSurface, displayColor: scheme.onSurface, ); @@ -269,37 +268,37 @@ TextTheme _textTheme(ColorScheme scheme) { return base.copyWith( headlineLarge: base.headlineLarge?.copyWith( fontSize: 30, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.6, - height: 1.1, + height: 1.12, ), headlineMedium: base.headlineMedium?.copyWith( fontSize: 26, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.15, ), headlineSmall: base.headlineSmall?.copyWith( fontSize: 20, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: -0.3, height: 1.2, ), titleLarge: base.titleLarge?.copyWith( fontSize: 18, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, letterSpacing: -0.2, height: 1.25, ), titleMedium: base.titleMedium?.copyWith( fontSize: 15, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w600, height: 1.3, ), titleSmall: base.titleSmall?.copyWith( fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 0.15, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, height: 1.3, ), bodyLarge: base.bodyLarge?.copyWith( @@ -313,7 +312,7 @@ TextTheme _textTheme(ColorScheme scheme) { height: 1.45, color: scheme.onSurface, ), - bodySmall: base.bodySmall?.copyWith( + bodySmall: GoogleFonts.ibmPlexMono( fontSize: 12, fontWeight: FontWeight.w400, height: 1.4, @@ -321,18 +320,19 @@ TextTheme _textTheme(ColorScheme scheme) { ), labelLarge: base.labelLarge?.copyWith( fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 0.35, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, ), - labelMedium: base.labelMedium?.copyWith( + labelMedium: GoogleFonts.ibmPlexMono( fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.45, + fontWeight: FontWeight.w600, + letterSpacing: 0.4, + color: scheme.onSurface, ), - labelSmall: base.labelSmall?.copyWith( + labelSmall: GoogleFonts.ibmPlexMono( fontSize: 10, - fontWeight: FontWeight.w600, - letterSpacing: 0.55, + fontWeight: FontWeight.w500, + letterSpacing: 0.5, color: scheme.onSurfaceVariant, ), ); diff --git a/mobile/lib/features/auth/presentation/login_page.dart b/mobile/lib/features/auth/presentation/login_page.dart index 8b086e1..037b29c 100644 --- a/mobile/lib/features/auth/presentation/login_page.dart +++ b/mobile/lib/features/auth/presentation/login_page.dart @@ -69,10 +69,12 @@ class _LoginPageState extends State { ), const SizedBox(height: AppSpacing.md), Text( - 'Sign in to watch HMAC webhooks land, the worker claim ' - 'them, and rules match — all against a local API.', + 'Sign in, then run the guided demo on Events to watch ' + 'webhooks move through the queue — HMAC ingest, worker ' + 'claim, rule match — against your local API.', style: theme.textTheme.bodyMedium?.copyWith( color: scheme.onSurfaceVariant, + height: 1.45, ), ), const SizedBox(height: AppSpacing.lg), diff --git a/mobile/lib/features/events/presentation/cubit/events_cubit.dart b/mobile/lib/features/events/presentation/cubit/events_cubit.dart index cc95365..ba25f51 100644 --- a/mobile/lib/features/events/presentation/cubit/events_cubit.dart +++ b/mobile/lib/features/events/presentation/cubit/events_cubit.dart @@ -14,6 +14,9 @@ class EventsCubit extends Cubit { final DemoRepository _demo; Timer? _liveTimer; + static const _betweenEventsPause = Duration(seconds: 3); + static const _terminalWait = Duration(seconds: 40); + Future load({String? status, bool updateFilter = false}) async { final filter = updateFilter ? status : (status ?? state.filter); final quiet = state.liveWatching || state.demoBusy; @@ -65,7 +68,7 @@ class EventsCubit extends Cubit { load(status: status, updateFilter: true); void startLiveWatch() { - if (state.liveWatching) { + if (state.liveWatching && _liveTimer != null) { return; } emit(state.copyWith(liveWatching: true)); @@ -94,7 +97,6 @@ class EventsCubit extends Cubit { } } - /// Injects events one-by-one so the user can watch PENDING → PROCESSING → PROCESSED. Future runGuidedDemo({int eventCount = 3}) async { if (state.demoBusy) { return; @@ -103,6 +105,8 @@ class EventsCubit extends Cubit { state.copyWith( demoBusy: true, liveWatching: true, + demoStep: 0, + demoTotal: eventCount, journeyMessage: 'Preparing demo rule and live refresh…', clearError: true, ), @@ -113,8 +117,9 @@ class EventsCubit extends Cubit { for (var i = 0; i < eventCount; i++) { emit( state.copyWith( + demoStep: i, journeyMessage: - 'Injecting webhook ${i + 1} of $eventCount as PENDING…', + 'Step ${i + 1}/$eventCount — injecting webhook as PENDING…', ), ); final result = await _demo.simulate( @@ -127,24 +132,38 @@ class EventsCubit extends Cubit { : ''; emit( state.copyWith( + demoStep: i, journeyMessage: created == null ? 'Webhook ${i + 1} sent.$ruleNote' - : 'Webhook ${i + 1}: ${created.type} is ${created.status.toUpperCase()}.$ruleNote ' - 'Watch status move to PROCESSING then PROCESSED.', + : 'Step ${i + 1}/$eventCount — ${created.type} is ' + '${created.status.toUpperCase()}.$ruleNote ' + 'Watch PENDING → PROCESSING → PROCESSED.', ), ); await load(); if (created != null) { - await _waitUntilTerminal(created.id); + await _waitUntilTerminal(created.id, step: i + 1, total: eventCount); } else { - await Future.delayed(const Duration(seconds: 5)); + await Future.delayed(const Duration(seconds: 8)); + } + if (i < eventCount - 1) { + emit( + state.copyWith( + demoStep: i + 1, + journeyMessage: + 'Step ${i + 1}/$eventCount done. Pausing before the next webhook…', + ), + ); + await Future.delayed(_betweenEventsPause); } } emit( state.copyWith( demoBusy: false, + demoStep: eventCount, + demoTotal: eventCount, journeyMessage: - 'Demo complete. Open a PROCESSED event for the pipeline steps, then Explain.', + 'Demo complete. Open a PROCESSED event for pipeline detail, then Explain.', ), ); await load(); @@ -153,6 +172,8 @@ class EventsCubit extends Cubit { emit( state.copyWith( demoBusy: false, + demoStep: 0, + demoTotal: 0, errorMessage: mapError(e).message, clearJourney: true, ), @@ -161,23 +182,36 @@ class EventsCubit extends Cubit { } } - Future _waitUntilTerminal(String eventId) async { - final deadline = DateTime.now().add(const Duration(seconds: 20)); + Future _waitUntilTerminal( + String eventId, { + required int step, + required int total, + }) async { + final deadline = DateTime.now().add(_terminalWait); while (DateTime.now().isBefore(deadline) && !isClosed) { - await Future.delayed(const Duration(milliseconds: 800)); + await Future.delayed(const Duration(milliseconds: 900)); await load(); final match = state.items.where((e) => e.id == eventId); if (match.isEmpty) { continue; } - final status = match.first.status; + final current = match.first; + final status = current.status; if (status == 'processed' || status == 'failed') { + emit( + state.copyWith( + demoStep: step, + journeyMessage: + 'Step $step/$total — ${current.type} finished as ${status.toUpperCase()}.', + ), + ); return; } emit( state.copyWith( journeyMessage: - 'Live: ${match.first.type} is ${status.toUpperCase()} — worker claim / rule match in progress…', + 'Step $step/$total — ${current.type} is ${status.toUpperCase()} ' + '(worker claim / rule match in progress)…', ), ); } diff --git a/mobile/lib/features/events/presentation/cubit/events_state.dart b/mobile/lib/features/events/presentation/cubit/events_state.dart index 3fadfc6..77f2154 100644 --- a/mobile/lib/features/events/presentation/cubit/events_state.dart +++ b/mobile/lib/features/events/presentation/cubit/events_state.dart @@ -13,6 +13,8 @@ class EventsState extends Equatable { this.liveWatching = false, this.demoBusy = false, this.journeyMessage, + this.demoStep = 0, + this.demoTotal = 0, }); final EventsStatus status; @@ -22,10 +24,19 @@ class EventsState extends Equatable { final bool liveWatching; final bool demoBusy; final String? journeyMessage; + final int demoStep; + final int demoTotal; bool get hasActiveQueue => items.any((e) => e.status == 'pending' || e.status == 'processing'); + double get demoProgress { + if (demoTotal <= 0) { + return 0; + } + return (demoStep / demoTotal).clamp(0.0, 1.0); + } + EventsState copyWith({ EventsStatus? status, List? items, @@ -34,6 +45,8 @@ class EventsState extends Equatable { bool? liveWatching, bool? demoBusy, String? journeyMessage, + int? demoStep, + int? demoTotal, bool clearFilter = false, bool clearError = false, bool clearJourney = false, @@ -47,6 +60,8 @@ class EventsState extends Equatable { demoBusy: demoBusy ?? this.demoBusy, journeyMessage: clearJourney ? null : (journeyMessage ?? this.journeyMessage), + demoStep: demoStep ?? this.demoStep, + demoTotal: demoTotal ?? this.demoTotal, ); } @@ -59,5 +74,7 @@ class EventsState extends Equatable { liveWatching, demoBusy, journeyMessage, + demoStep, + demoTotal, ]; } diff --git a/mobile/lib/features/events/presentation/events_page.dart b/mobile/lib/features/events/presentation/events_page.dart index ccd73be..1d2b2f7 100644 --- a/mobile/lib/features/events/presentation/events_page.dart +++ b/mobile/lib/features/events/presentation/events_page.dart @@ -7,7 +7,6 @@ import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/error_state.dart'; import '../../../core/widgets/filter_chip_bar.dart'; import '../../../core/widgets/loading_state.dart'; -import '../../../core/widgets/ops_list_row.dart'; import '../data/event_models.dart'; import 'cubit/events_cubit.dart'; import 'cubit/events_state.dart'; @@ -46,9 +45,8 @@ class EventsPage extends StatelessWidget { buildWhen: (p, n) => p.demoBusy != n.demoBusy, builder: (context, state) { return FloatingActionButton.extended( - onPressed: state.demoBusy - ? null - : () => _confirmGuidedDemo(context), + onPressed: + state.demoBusy ? null : () => _confirmGuidedDemo(context), icon: state.demoBusy ? const SizedBox( width: 18, @@ -68,16 +66,16 @@ class EventsPage extends StatelessWidget { buildWhen: (p, n) => p.journeyMessage != n.journeyMessage || p.liveWatching != n.liveWatching || - p.demoBusy != n.demoBusy, + p.demoBusy != n.demoBusy || + p.demoStep != n.demoStep || + p.demoTotal != n.demoTotal, builder: (context, state) { - if (state.journeyMessage == null && !state.liveWatching) { + if (state.journeyMessage == null && + !state.liveWatching && + !state.demoBusy) { return const SizedBox.shrink(); } - return _JourneyBanner( - message: state.journeyMessage ?? - 'Live refresh on — watching PENDING / PROCESSING…', - live: state.liveWatching || state.demoBusy, - ); + return _JourneyBanner(state: state); }, ), BlocBuilder( @@ -108,9 +106,9 @@ class EventsPage extends StatelessWidget { child: EmptyState( title: 'No events yet', message: state.filter == null - ? 'Tap “Run live demo” to inject webhooks one by one ' - 'and watch PENDING → PROCESSING → PROCESSED.\n\n' - 'Or seed from the Mac:\n./scripts/seed_webhooks.sh' + ? 'Start the guided demo to inject webhooks ' + 'one by one and watch the queue move.\n\n' + 'Path of least effort: tap Run live demo below.' : 'No events match this status filter.', actionLabel: state.filter == null ? 'Run live demo' @@ -158,30 +156,62 @@ class EventsPage extends StatelessWidget { Future _confirmGuidedDemo(BuildContext context) async { final cubit = context.read(); - final ok = await showDialog( + final ok = await showModalBottomSheet( context: context, + isScrollControlled: true, + showDragHandle: true, builder: (ctx) { final theme = Theme.of(ctx); - return AlertDialog( - title: const Text('Run live webhook demo?'), - content: Text( - 'This will:\n' - '1. Ensure a “Demo balance watch” alert rule exists\n' - '2. Inject 3 simulated partner webhooks one by one\n' - '3. Auto-refresh so you can see PENDING → PROCESSING → PROCESSED\n\n' - 'Stay on this screen while it runs (~20–40 seconds with the demo worker delay).', - style: theme.textTheme.bodyMedium?.copyWith(height: 1.45), + final scheme = theme.colorScheme; + return Padding( + padding: EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.xs, + AppSpacing.lg, + AppSpacing.lg + MediaQuery.paddingOf(ctx).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Run live webhook demo?', style: theme.textTheme.titleLarge), + const SizedBox(height: AppSpacing.sm), + Text( + 'Confirmation before a multi-step action — you’ll see each ' + 'status change before the next webhook is injected.', + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + height: 1.45, + ), + ), + const SizedBox(height: AppSpacing.md), + const _ConfirmStep( + index: '1', + title: 'Ensure demo alert rule', + detail: 'Creates “Demo balance watch” if missing', + ), + const _ConfirmStep( + index: '2', + title: 'Inject 3 webhooks slowly', + detail: '~8s processing hold + pause between each', + ), + const _ConfirmStep( + index: '3', + title: 'Watch the queue live', + detail: 'PENDING → PROCESSING → PROCESSED with auto-refresh', + ), + const SizedBox(height: AppSpacing.lg), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Start demo'), + ), + const SizedBox(height: AppSpacing.xs), + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + ], ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text('Start demo'), - ), - ], ); }, ); @@ -191,6 +221,60 @@ class EventsPage extends StatelessWidget { } } +class _ConfirmStep extends StatelessWidget { + const _ConfirmStep({ + required this.index, + required this.title, + required this.detail, + }); + + final String index; + final String title; + final String detail; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: scheme.primaryContainer, + borderRadius: BorderRadius.circular(AppSpacing.radiusXs), + ), + child: Text( + index, + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall), + Text( + detail, + style: theme.textTheme.bodySmall?.copyWith(height: 1.35), + ), + ], + ), + ), + ], + ), + ); + } +} + class _ProcessGuideBanner extends StatelessWidget { const _ProcessGuideBanner(); @@ -211,16 +295,15 @@ class _ProcessGuideBanner extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'HOW THE PIPELINE WORKS', + 'PIPELINE OVERVIEW', style: theme.textTheme.labelMedium?.copyWith( color: scheme.primary, ), ), const SizedBox(height: AppSpacing.xxs), Text( - 'Partner webhook → HMAC verify → PENDING queue → worker ' - 'SKIP LOCKED claim → PROCESSING → match rules → PROCESSED ' - '→ optional Explain summary.', + 'Webhook → verify → PENDING → worker claim → PROCESSING → ' + 'match rules → PROCESSED → Explain', style: theme.textTheme.bodySmall?.copyWith(height: 1.4), ), ], @@ -231,39 +314,78 @@ class _ProcessGuideBanner extends StatelessWidget { } class _JourneyBanner extends StatelessWidget { - const _JourneyBanner({required this.message, required this.live}); + const _JourneyBanner({required this.state}); - final String message; - final bool live; + final EventsState state; @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; + final live = state.liveWatching || state.demoBusy; return Material( color: scheme.primaryContainer.withValues(alpha: 0.55), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, ), - child: Row( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - live ? Icons.sensors : Icons.info_outline, - size: 18, - color: scheme.primary, - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - message, - style: theme.textTheme.bodySmall?.copyWith( - height: 1.4, - color: scheme.onPrimaryContainer, + if (state.demoTotal > 0) ...[ + Row( + children: [ + Text( + 'DEMO PROGRESS', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onPrimaryContainer, + ), + ), + const Spacer(), + Text( + '${state.demoStep.clamp(0, state.demoTotal)} / ${state.demoTotal}', + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onPrimaryContainer, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + value: state.demoBusy + ? (state.demoStep + 0.35) / state.demoTotal + : state.demoProgress, + minHeight: 6, + backgroundColor: scheme.surface.withValues(alpha: 0.35), ), ), + const SizedBox(height: AppSpacing.sm), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + live ? Icons.sensors : Icons.info_outline, + size: 18, + color: scheme.primary, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + state.journeyMessage ?? + 'Live refresh on — watching PENDING / PROCESSING…', + style: theme.textTheme.bodySmall?.copyWith( + height: 1.4, + color: scheme.onPrimaryContainer, + ), + ), + ), + ], ), ], ), @@ -281,28 +403,93 @@ class EventsListView extends StatelessWidget { Widget build(BuildContext context) { return ListView.separated( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.only(bottom: 88), + padding: const EdgeInsets.only(bottom: 96), itemCount: items.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { final event = items[index]; - return OpsListRow( - title: event.type, - subtitle: - '${event.listSubtitle}\n${_ageLabel(event)} · ${event.idempotencyKey}', - trailing: StatusChip(status: event.status), - onTap: () => context.push('/events/${event.id}'), - ); + return _EventRow(event: event); }, ); } +} + +class _EventRow extends StatelessWidget { + const _EventRow({required this.event}); + + final OpsEvent event; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final accent = switch (event.status) { + 'pending' => scheme.secondary, + 'processing' => scheme.tertiary, + 'processed' => scheme.primary, + 'failed' => scheme.error, + _ => scheme.outline, + }; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => context.push('/events/${event.id}'), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 3, color: accent), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.type.replaceAll('_', ' '), + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 2), + Text( + event.listSubtitle, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + '${_ageLabel(event)} · ${event.idempotencyKey}', + style: theme.textTheme.labelSmall, + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + StatusChip(status: event.status), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } String _ageLabel(OpsEvent event) { final end = event.processedAt ?? DateTime.now().toUtc(); final ms = end.difference(event.receivedAt.toUtc()).inMilliseconds; if (ms < 1000) { - return '${ms}ms in pipeline'; + return '${ms}ms'; } - return '${(ms / 1000).toStringAsFixed(1)}s in pipeline'; + return '${(ms / 1000).toStringAsFixed(1)}s'; } } diff --git a/mobile/lib/features/events/presentation/widgets/status_chip.dart b/mobile/lib/features/events/presentation/widgets/status_chip.dart index 69bfead..9748aed 100644 --- a/mobile/lib/features/events/presentation/widgets/status_chip.dart +++ b/mobile/lib/features/events/presentation/widgets/status_chip.dart @@ -35,8 +35,7 @@ class StatusChip extends StatelessWidget { status.toUpperCase(), style: Theme.of(context).textTheme.labelSmall?.copyWith( color: fg, - fontWeight: FontWeight.w800, - fontFamily: 'monospace', + fontWeight: FontWeight.w700, letterSpacing: 0.6, ), ), diff --git a/mobile/lib/features/settings/presentation/settings_page.dart b/mobile/lib/features/settings/presentation/settings_page.dart index 496b623..abe7e60 100644 --- a/mobile/lib/features/settings/presentation/settings_page.dart +++ b/mobile/lib/features/settings/presentation/settings_page.dart @@ -70,8 +70,8 @@ class SettingsPage extends StatelessWidget { 'In-app demo (recommended)\n' '1) Keep docker compose up on your Mac\n' '2) Sign in with the demo account\n' - '3) Events → “Run live demo” → confirm\n' - '4) Watch each webhook: PENDING → PROCESSING → PROCESSED\n' + '3) Events → “Run live demo” → confirm in the sheet\n' + '4) Watch each webhook slowly: PENDING → PROCESSING → PROCESSED\n' '5) Open an event for pipeline steps → Explain\n\n' 'Optional Mac seed (instant, often already PROCESSED):\n' './scripts/seed_webhooks.sh', diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 837cb54..5f93a27 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -296,6 +296,14 @@ packages: url: "https://pub.dev" source: hosted version: "17.3.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: d3f251381df389f7418d4dff416bd27c0a23138df22969aafd28c4a4b1bb3cd6 + url: "https://pub.dev" + source: hosted + version: "8.2.0" hooks: dependency: transitive description: @@ -304,6 +312,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 8983286..a6873bc 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: dio: ^5.10.0 flutter_secure_storage: ^10.3.1 equatable: ^2.1.0 + google_fonts: ^8.2.0 dev_dependencies: flutter_test: