Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=8000
DEMO_PROCESS_DELAY_MS=5000

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 holds processing ~8s so the queue is readable). 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 ~5s so the queue is readable). Open an event for pipeline steps → **Explain**. Check **Rules** for the demo alert rule the worker matches.

`POST /v1/demo/simulate` (auth) inserts fresh pending events for the journey; partner HMAC ingest remains `POST /v1/webhooks/events`.

Expand Down
28 changes: 28 additions & 0 deletions demos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Demo captures

## Latest (symlinks)

- Recording: [`recordings/latest.mp4`](recordings/latest.mp4)
- Screenshots: [`recordings/latest-screenshots/`](recordings/latest-screenshots)
- Working copy of the last run: [`walletops-demo-walkthrough.mp4`](walletops-demo-walkthrough.mp4) + [`screenshots/`](screenshots)

## Walkthrough steps

1. Login
2. Events
3. Rules list (alert rules matched by the worker)
4. Rule detail / edit sheet
5. Events → Run live demo (confirm)
6. PENDING → PROCESSING → PROCESSED
7. Event detail + matched rule in pipeline
8. Rules again after the demo

## Re-record

```bash
docker compose up --build -d # DEMO_PROCESS_DELAY_MS=5000
./scripts/record_demo_walkthrough.sh
```

Requires a booted iOS Simulator and API on `http://127.0.0.1:8080`.
Each run also archives a timestamped copy under `recordings/`.
1 change: 1 addition & 0 deletions demos/recordings/latest-screenshots
1 change: 1 addition & 0 deletions demos/recordings/latest.mp4
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file added demos/screenshots/01-login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/02-events.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/03-rules-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/04-rules-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/05-events-ready.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/06-demo-confirm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/07-demo-started.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/08-demo-processing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/09-demo-complete.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/10-event-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/screenshots/13-rules-after-demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added demos/walletops-demo-walkthrough.mp4
Binary file not shown.
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ services:
AI_PROVIDER: mock
AI_API_KEY: ""
HTTP_ADDR: ":8080"
DEMO_PROCESS_DELAY_MS: "8000"
DEMO_PROCESS_DELAY_MS: "5000"
ports:
- "8080:8080"
depends_on:
Expand Down
147 changes: 147 additions & 0 deletions mobile/integration_test/demo_walkthrough_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:integration_test/integration_test.dart';
import 'package:walletops_mobile/bootstrap.dart';
import 'package:walletops_mobile/core/storage/token_storage.dart';

void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();

Future<void> snap(String name) async {
await binding.takeScreenshot(name);
}

Future<void> settle(WidgetTester tester, {int pumps = 12}) async {
for (var i = 0; i < pumps; i++) {
await tester.pump(const Duration(milliseconds: 350));
}
}

Future<void> tapNav(WidgetTester tester, String label) async {
final dest = find.widgetWithText(NavigationDestination, label);
if (dest.evaluate().isNotEmpty) {
await tester.tap(dest);
} else {
await tester.tap(find.text(label).last);
}
await settle(tester, pumps: 16);
}

testWidgets(
'demo walkthrough screenshots',
(tester) async {
await GetIt.instance.reset();
await bootstrapApp(
tokenStorage: InMemoryTokenStorage(),
apiBase: const String.fromEnvironment(
'API_BASE',
defaultValue: 'http://127.0.0.1:8080',
),
);
await settle(tester, pumps: 18);
await snap('01-login');

await tester.tap(find.text('Sign in'));
await settle(tester, pumps: 22);
await snap('02-events');

// Show Rules before the live demo so the viewer sees the matcher.
await tapNav(tester, 'Rules');
await snap('03-rules-list');

final demoRule = find.textContaining('Demo balance watch');
if (demoRule.evaluate().isNotEmpty) {
await tester.tap(demoRule.first);
await settle(tester, pumps: 14);
await snap('04-rules-detail');
// Dismiss sheet / return to list.
final close = find.byIcon(Icons.close);
if (close.evaluate().isNotEmpty) {
await tester.tap(close.first);
} else {
await tester.tapAt(const Offset(20, 80));
}
await settle(tester, pumps: 10);
} else {
// Open create sheet to show how a rule is defined.
final add = find.byTooltip('New rule');
if (add.evaluate().isNotEmpty) {
await tester.tap(add);
await settle(tester, pumps: 12);
await snap('04-rules-create');
final cancel = find.text('Cancel');
if (cancel.evaluate().isNotEmpty) {
await tester.tap(cancel);
await settle(tester, pumps: 10);
}
}
}

await tapNav(tester, 'Events');
await snap('05-events-ready');

await tester.tap(find.text('Run live demo'));
await settle(tester, pumps: 10);
await snap('06-demo-confirm');

await tester.tap(find.text('Start demo'));
await settle(tester, pumps: 12);
await snap('07-demo-started');

var sawProcessing = false;
var sawComplete = false;
for (var i = 0; i < 100 && !sawComplete; i++) {
await tester.pump(const Duration(seconds: 1));
if (!sawProcessing &&
find.textContaining('PROCESSING').evaluate().isNotEmpty) {
await snap('08-demo-processing');
sawProcessing = true;
}
if (find.textContaining('Demo complete').evaluate().isNotEmpty) {
sawComplete = true;
}
}
expect(sawComplete, isTrue, reason: 'guided demo did not finish');
await settle(tester, pumps: 8);
await snap('09-demo-complete');

final eventTitle = find.textContaining('balance drop');
if (eventTitle.evaluate().isNotEmpty) {
await tester.tap(eventTitle.first);
} else {
await tester.tap(find.text('PROCESSED').first);
}
await settle(tester, pumps: 18);
await snap('10-event-detail');

// Scroll to show matched rule / explain CTA.
await tester.drag(find.byType(ListView).first, const Offset(0, -280));
await settle(tester, pumps: 8);
await snap('11-event-pipeline-matched-rule');

final explain = find.text('Explain with schema summary');
if (explain.evaluate().isNotEmpty) {
await tester.ensureVisible(explain);
await tester.tap(explain);
await settle(tester, pumps: 22);
await snap('12-explain');
final back = find.byTooltip('Back');
if (back.evaluate().isNotEmpty) {
await tester.tap(back);
await settle(tester, pumps: 10);
}
}

// Back to events then Rules again after matching happened.
final eventBack = find.byTooltip('Back');
if (eventBack.evaluate().isNotEmpty) {
await tester.tap(eventBack);
await settle(tester, pumps: 12);
}
await tapNav(tester, 'Rules');
await snap('13-rules-after-demo');
},
timeout: const Timeout(Duration(minutes: 4)),
);
}
27 changes: 27 additions & 0 deletions mobile/lib/bootstrap.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';

import 'app.dart';
import 'core/di/injection.dart';
import 'core/router/app_router.dart';
import 'core/storage/token_storage.dart';
import 'features/auth/presentation/cubit/auth_cubit.dart';
import 'features/auth/presentation/cubit/auth_state.dart';
import 'features/ops/presentation/cubit/ops_health_cubit.dart';

Future<void> bootstrapApp({
TokenStorage? tokenStorage,
String? apiBase,
}) async {
WidgetsFlutterBinding.ensureInitialized();
await configureDependencies(
tokenStorage: tokenStorage,
apiBase: apiBase,
);
final authCubit = getIt<AuthCubit>();
final router = createAppRouter(authCubit);
runApp(WalletOpsApp(authCubit: authCubit, router: router));
await authCubit.bootstrap();
if (authCubit.state.status == AuthStatus.authenticated) {
getIt<OpsHealthCubit>().start();
}
}
134 changes: 74 additions & 60 deletions mobile/lib/features/auth/presentation/login_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,72 +78,86 @@ class _LoginPageState extends State<LoginPage> {
),
),
const SizedBox(height: AppSpacing.lg),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius:
BorderRadius.circular(AppSpacing.radiusSm),
border: Border(
left: BorderSide(
color: scheme.primary,
width: 3,
),
top: BorderSide(
color: scheme.outline.withValues(alpha: 0.7),
),
right: BorderSide(
color: scheme.outline.withValues(alpha: 0.7),
),
bottom: BorderSide(
color: scheme.outline.withValues(alpha: 0.7),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'DEMO ACCOUNT',
style: theme.textTheme.labelMedium?.copyWith(
color: scheme.primary,
Stack(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: scheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(
AppSpacing.radiusSm,
),
),
const SizedBox(height: AppSpacing.xxs),
Text(
'Prefilled credentials',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: AppSpacing.xxs),
Text(
'$kDemoEmail\npassword: $kDemoPassword\n'
'webhook user_ref: $kDemoUserRef',
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
height: 1.45,
border: Border.all(
color:
scheme.outline.withValues(alpha: 0.7),
),
),
const SizedBox(height: AppSpacing.sm),
Text(
'Run ./scripts/seed_webhooks.sh once after '
'docker compose up.',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'DEMO ACCOUNT',
style:
theme.textTheme.labelMedium?.copyWith(
color: scheme.primary,
),
),
const SizedBox(height: AppSpacing.xxs),
Text(
'Prefilled credentials',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: AppSpacing.xxs),
Text(
'$kDemoEmail\npassword: $kDemoPassword\n'
'webhook user_ref: $kDemoUserRef',
style: theme.textTheme.bodySmall?.copyWith(
height: 1.45,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
'Prefer in-app “Run live demo” on Events '
'after sign-in.',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: AppSpacing.sm),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () {
_email.text = kDemoEmail;
_password.text = kDemoPassword;
},
child: const Text(
'Reset to demo credentials',
),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () {
_email.text = kDemoEmail;
_password.text = kDemoPassword;
},
child: const Text('Reset to demo credentials'),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(
width: 3,
decoration: BoxDecoration(
color: scheme.primary,
borderRadius: const BorderRadius.only(
topLeft:
Radius.circular(AppSpacing.radiusSm),
bottomLeft:
Radius.circular(AppSpacing.radiusSm),
),
),
),
],
),
),
],
),
const SizedBox(height: AppSpacing.lg),
TextFormField(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ class EventsCubit extends Cubit<EventsState> {
final DemoRepository _demo;
Timer? _liveTimer;

static const _betweenEventsPause = Duration(seconds: 3);
static const _terminalWait = Duration(seconds: 40);
static const _betweenEventsPause = Duration(milliseconds: 1500);
static const _terminalWait = Duration(seconds: 30);

Future<void> load({String? status, bool updateFilter = false}) async {
final filter = updateFilter ? status : (status ?? state.filter);
Expand Down
Loading
Loading