neoagent 3.0.1-beta.9 → 3.1.1-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/.env.example +2 -10
  2. package/README.md +12 -3
  3. package/docs/automation.md +37 -0
  4. package/docs/benchmarking.md +102 -0
  5. package/docs/billing.md +34 -8
  6. package/docs/configuration.md +11 -2
  7. package/docs/getting-started.md +10 -8
  8. package/docs/operations.md +1 -1
  9. package/flutter_app/lib/main.dart +3 -0
  10. package/flutter_app/lib/main_account_settings.dart +138 -0
  11. package/flutter_app/lib/main_app_shell.dart +38 -13
  12. package/flutter_app/lib/main_billing.dart +1465 -0
  13. package/flutter_app/lib/main_chat.dart +1612 -214
  14. package/flutter_app/lib/main_controller.dart +398 -26
  15. package/flutter_app/lib/main_devices.dart +293 -207
  16. package/flutter_app/lib/main_integrations.dart +252 -0
  17. package/flutter_app/lib/main_models.dart +142 -0
  18. package/flutter_app/lib/main_navigation.dart +19 -1
  19. package/flutter_app/lib/main_operations.dart +638 -317
  20. package/flutter_app/lib/main_settings.dart +510 -300
  21. package/flutter_app/lib/main_shared.dart +2 -0
  22. package/flutter_app/lib/main_timeline.dart +1378 -0
  23. package/flutter_app/lib/src/backend_client.dart +149 -19
  24. package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
  25. package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
  26. package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
  27. package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
  28. package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
  29. package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
  30. package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
  31. package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
  32. package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
  33. package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
  34. package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
  35. package/flutter_app/lib/src/recording_payloads.dart +9 -0
  36. package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
  37. package/flutter_app/windows/runner/flutter_window.cpp +75 -0
  38. package/landing/index.html +3 -3
  39. package/lib/manager.js +184 -66
  40. package/package.json +5 -1
  41. package/server/admin/access.js +12 -7
  42. package/server/admin/admin.js +436 -10
  43. package/server/admin/billing.js +158 -35
  44. package/server/admin/index.html +72 -2
  45. package/server/admin/users.js +15 -15
  46. package/server/db/database.js +125 -20
  47. package/server/guest-agent.browser.package.json +1 -0
  48. package/server/http/routes.js +1 -0
  49. package/server/http/static.js +4 -2
  50. package/server/public/.last_build_id +1 -1
  51. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  52. package/server/public/canvaskit/wimp.js.symbols +8475 -8467
  53. package/server/public/canvaskit/wimp.wasm +0 -0
  54. package/server/public/flutter_bootstrap.js +2 -2
  55. package/server/public/main.dart.js +91380 -87064
  56. package/server/routes/account.js +53 -0
  57. package/server/routes/admin.js +345 -64
  58. package/server/routes/agents.js +203 -21
  59. package/server/routes/billing.js +5 -0
  60. package/server/routes/browser.js +8 -1
  61. package/server/routes/recordings.js +96 -6
  62. package/server/routes/screenHistory.js +140 -2
  63. package/server/routes/timeline.js +43 -0
  64. package/server/services/account/erasure.js +263 -0
  65. package/server/services/ai/hooks.js +4 -1
  66. package/server/services/ai/loop/agent_engine_core.js +8 -1
  67. package/server/services/ai/loop/blank_recovery.js +36 -0
  68. package/server/services/ai/loop/conversation_loop.js +166 -33
  69. package/server/services/ai/messagingFallback.js +22 -0
  70. package/server/services/ai/rate_limits.js +28 -5
  71. package/server/services/ai/systemPrompt.js +6 -5
  72. package/server/services/ai/taskAnalysis.js +2 -0
  73. package/server/services/ai/toolEvidence.js +15 -0
  74. package/server/services/ai/toolResult.js +40 -0
  75. package/server/services/ai/tools.js +163 -4
  76. package/server/services/android/controller.js +6 -2
  77. package/server/services/billing/plans.js +2 -1
  78. package/server/services/browser/anti_detection.js +192 -0
  79. package/server/services/browser/controller.js +180 -54
  80. package/server/services/desktop/auth.js +3 -0
  81. package/server/services/desktop/registry.js +50 -2
  82. package/server/services/integrations/google/calendar.js +22 -14
  83. package/server/services/integrations/neomail/provider.js +993 -0
  84. package/server/services/integrations/registry.js +2 -0
  85. package/server/services/manager.js +12 -42
  86. package/server/services/memory/ingestion_chunking.js +268 -0
  87. package/server/services/messaging/telnyx.js +9 -8
  88. package/server/services/recordings/manager.js +60 -27
  89. package/server/services/runtime/docker-vm-manager.js +157 -266
  90. package/server/services/runtime/guest_bootstrap.js +17 -5
  91. package/server/services/runtime/guest_image.js +188 -0
  92. package/server/services/runtime/manager.js +0 -1
  93. package/server/services/runtime/validation.js +3 -8
  94. package/server/services/social_video/service.js +60 -10
  95. package/server/services/tasks/adapters/index.js +1 -1
  96. package/server/services/tasks/adapters/neomail_email_received.js +46 -0
  97. package/server/services/tasks/integration_runtime.js +67 -3
  98. package/server/services/tasks/runtime.js +240 -10
  99. package/server/services/tasks/task_repository.js +26 -6
  100. package/server/services/timeline/service.js +558 -0
  101. package/server/services/wearable/gateway.js +1 -1
  102. package/server/services/websocket.js +21 -3
  103. package/server/services/desktop/screenRecorder.js +0 -292
  104. package/server/services/desktop/screen_recorder_support.js +0 -46
@@ -0,0 +1,1465 @@
1
+ part of 'main.dart';
2
+
3
+ // ── Helpers ───────────────────────────────────────────────────────────────────
4
+
5
+ String _fmtPrice(int cents, String currency, {String? interval}) {
6
+ final amount = cents / 100;
7
+ final sym = currency.toUpperCase() == 'USD' ? '\$' : '€';
8
+ final str = amount == amount.truncateToDouble()
9
+ ? '$sym${amount.toInt()}'
10
+ : '$sym${amount.toStringAsFixed(2)}';
11
+ if (interval == null || interval.isEmpty) return '$str forever';
12
+ return '$str / $interval';
13
+ }
14
+
15
+ String _fmtDate(String? iso) {
16
+ if (iso == null) return '—';
17
+ try {
18
+ final d = DateTime.parse(iso).toLocal();
19
+ const months = [
20
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
21
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
22
+ ];
23
+ return '${months[d.month - 1]} ${d.day}, ${d.year}';
24
+ } catch (_) {
25
+ return iso.substring(0, 10);
26
+ }
27
+ }
28
+
29
+ String _fmtTs(int? ts) {
30
+ if (ts == null) return '—';
31
+ return _fmtDate(DateTime.fromMillisecondsSinceEpoch(ts * 1000).toIso8601String());
32
+ }
33
+
34
+ Color _statusColor(String? status) {
35
+ switch (status) {
36
+ case 'active': return _success;
37
+ case 'trialing': return _warning;
38
+ case 'past_due': return _danger;
39
+ default: return _textMuted;
40
+ }
41
+ }
42
+
43
+ Future<void> _openUrl(String url) async {
44
+ final uri = Uri.tryParse(url);
45
+ if (uri == null) return;
46
+ await url_launcher.launchUrl(uri, mode: url_launcher.LaunchMode.externalApplication);
47
+ }
48
+
49
+ // ── Main panel ────────────────────────────────────────────────────────────────
50
+
51
+ enum _BillingTab { overview, plans, history }
52
+
53
+ class BillingPanel extends StatefulWidget {
54
+ const BillingPanel({super.key, required this.controller});
55
+
56
+ final NeoAgentController controller;
57
+
58
+ @override
59
+ State<BillingPanel> createState() => _BillingPanelState();
60
+ }
61
+
62
+ class _BillingPanelState extends State<BillingPanel> {
63
+ _BillingTab _tab = _BillingTab.overview;
64
+ bool _annual = false;
65
+
66
+ NeoAgentController get _c => widget.controller;
67
+
68
+ @override
69
+ void initState() {
70
+ super.initState();
71
+ WidgetsBinding.instance.addPostFrameCallback((_) {
72
+ _c.refreshBilling();
73
+ });
74
+ }
75
+
76
+ @override
77
+ Widget build(BuildContext context) {
78
+ final sub = _c.billingSubscription;
79
+ final planName = sub?['plan']?['name'] as String? ?? '';
80
+ final status = sub?['status'] as String? ?? '';
81
+ final compact = MediaQuery.sizeOf(context).width < 860;
82
+
83
+ return _EntranceMotion(
84
+ child: ListView(
85
+ padding: _pagePadding(context),
86
+ children: <Widget>[
87
+ // ── Page header ──────────────────────────────────────────────────
88
+ Padding(
89
+ padding: const EdgeInsets.only(bottom: 24),
90
+ child: compact
91
+ ? Column(
92
+ crossAxisAlignment: CrossAxisAlignment.start,
93
+ children: <Widget>[
94
+ Text('SETTINGS', style: _sectionEyebrowStyle()),
95
+ const SizedBox(height: 6),
96
+ Text('Billing & subscription',
97
+ style: _displayTitleStyle(26)),
98
+ const SizedBox(height: 8),
99
+ Text(
100
+ 'Manage your plan, track usage, update payment, and review invoices.',
101
+ style: TextStyle(color: _textSecondary, height: 1.5),
102
+ ),
103
+ if (planName.isNotEmpty) ...<Widget>[
104
+ const SizedBox(height: 12),
105
+ _BillingStatusPill(plan: planName, status: status),
106
+ ],
107
+ ],
108
+ )
109
+ : Row(
110
+ crossAxisAlignment: CrossAxisAlignment.start,
111
+ children: <Widget>[
112
+ Expanded(
113
+ child: Column(
114
+ crossAxisAlignment: CrossAxisAlignment.start,
115
+ children: <Widget>[
116
+ Text('SETTINGS', style: _sectionEyebrowStyle()),
117
+ const SizedBox(height: 8),
118
+ Text('Billing & subscription',
119
+ style: _displayTitleStyle(32)),
120
+ const SizedBox(height: 10),
121
+ ConstrainedBox(
122
+ constraints:
123
+ const BoxConstraints(maxWidth: 640),
124
+ child: Text(
125
+ 'Manage your plan, track usage, update payment, and review invoices — all in one place.',
126
+ style: TextStyle(
127
+ color: _textSecondary, height: 1.5),
128
+ ),
129
+ ),
130
+ ],
131
+ ),
132
+ ),
133
+ if (planName.isNotEmpty)
134
+ Padding(
135
+ padding: const EdgeInsets.only(top: 6),
136
+ child: _BillingStatusPill(plan: planName, status: status),
137
+ ),
138
+ ],
139
+ ),
140
+ ),
141
+
142
+ // ── Tab bar ──────────────────────────────────────────────────────
143
+ _BillingTabBar(
144
+ selected: _tab,
145
+ invoiceCount: _c.billingInvoices.length,
146
+ onSelect: (t) => setState(() => _tab = t),
147
+ ),
148
+ const SizedBox(height: 20),
149
+
150
+ // ── Tab content ──────────────────────────────────────────────────
151
+ if (_c.isLoadingBilling && sub == null)
152
+ const Padding(
153
+ padding: EdgeInsets.symmetric(vertical: 60),
154
+ child: Center(child: CircularProgressIndicator()),
155
+ )
156
+ else if (_tab == _BillingTab.overview)
157
+ _BillingOverviewTab(
158
+ controller: _c,
159
+ onChangePlan: () => setState(() => _tab = _BillingTab.plans),
160
+ )
161
+ else if (_tab == _BillingTab.plans)
162
+ _BillingPlansTab(
163
+ controller: _c,
164
+ annual: _annual,
165
+ onToggleAnnual: (v) => setState(() => _annual = v),
166
+ )
167
+ else
168
+ _BillingHistoryTab(controller: _c),
169
+ ],
170
+ ),
171
+ );
172
+ }
173
+ }
174
+
175
+ // ── Status pill ───────────────────────────────────────────────────────────────
176
+
177
+ class _BillingStatusPill extends StatelessWidget {
178
+ const _BillingStatusPill({required this.plan, required this.status});
179
+
180
+ final String plan;
181
+ final String status;
182
+
183
+ @override
184
+ Widget build(BuildContext context) {
185
+ final dotColor = _statusColor(status);
186
+ return Container(
187
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
188
+ decoration: BoxDecoration(
189
+ color: _bgCard,
190
+ border: Border.all(color: _borderLight),
191
+ borderRadius: BorderRadius.circular(999),
192
+ ),
193
+ child: Row(
194
+ mainAxisSize: MainAxisSize.min,
195
+ children: <Widget>[
196
+ Container(
197
+ width: 8,
198
+ height: 8,
199
+ decoration: BoxDecoration(
200
+ color: dotColor,
201
+ shape: BoxShape.circle,
202
+ boxShadow: <BoxShadow>[
203
+ BoxShadow(color: dotColor.withValues(alpha: 0.45), blurRadius: 6),
204
+ ],
205
+ ),
206
+ ),
207
+ const SizedBox(width: 8),
208
+ Text(
209
+ '$plan · $status',
210
+ style: GoogleFonts.geist(
211
+ fontSize: 13,
212
+ fontWeight: FontWeight.w600,
213
+ color: _textPrimary,
214
+ ),
215
+ ),
216
+ ],
217
+ ),
218
+ );
219
+ }
220
+ }
221
+
222
+ // ── Tab bar ───────────────────────────────────────────────────────────────────
223
+
224
+ class _BillingTabBar extends StatelessWidget {
225
+ const _BillingTabBar({
226
+ required this.selected,
227
+ required this.invoiceCount,
228
+ required this.onSelect,
229
+ });
230
+
231
+ final _BillingTab selected;
232
+ final int invoiceCount;
233
+ final ValueChanged<_BillingTab> onSelect;
234
+
235
+ @override
236
+ Widget build(BuildContext context) {
237
+ return Container(
238
+ padding: const EdgeInsets.all(3),
239
+ decoration: BoxDecoration(
240
+ color: _bgCard,
241
+ border: Border.all(color: _border),
242
+ borderRadius: BorderRadius.circular(12),
243
+ ),
244
+ child: Row(
245
+ children: <Widget>[
246
+ _tab(_BillingTab.overview, 'Overview', null),
247
+ _tab(_BillingTab.plans, 'Plans', null),
248
+ _tab(_BillingTab.history, 'Billing history',
249
+ invoiceCount > 0 ? '$invoiceCount' : null),
250
+ ],
251
+ ),
252
+ );
253
+ }
254
+
255
+ Widget _tab(_BillingTab tab, String label, String? badge) {
256
+ final active = selected == tab;
257
+ return Expanded(
258
+ child: GestureDetector(
259
+ onTap: () => onSelect(tab),
260
+ child: AnimatedContainer(
261
+ duration: const Duration(milliseconds: 150),
262
+ padding: const EdgeInsets.symmetric(vertical: 9),
263
+ decoration: BoxDecoration(
264
+ color: active ? _bgSecondary : Colors.transparent,
265
+ borderRadius: BorderRadius.circular(9),
266
+ ),
267
+ child: Row(
268
+ mainAxisAlignment: MainAxisAlignment.center,
269
+ children: <Widget>[
270
+ Text(
271
+ label,
272
+ style: GoogleFonts.geist(
273
+ fontSize: 13,
274
+ fontWeight: active ? FontWeight.w600 : FontWeight.w500,
275
+ color: active ? _textPrimary : _textMuted,
276
+ ),
277
+ ),
278
+ if (badge != null) ...<Widget>[
279
+ const SizedBox(width: 6),
280
+ Container(
281
+ padding:
282
+ const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
283
+ decoration: BoxDecoration(
284
+ color: _accentMuted,
285
+ borderRadius: BorderRadius.circular(999),
286
+ ),
287
+ child: Text(
288
+ badge,
289
+ style: GoogleFonts.geist(
290
+ fontSize: 11,
291
+ fontWeight: FontWeight.w600,
292
+ color: _accent,
293
+ ),
294
+ ),
295
+ ),
296
+ ],
297
+ ],
298
+ ),
299
+ ),
300
+ ),
301
+ );
302
+ }
303
+ }
304
+
305
+ // ── Overview tab ──────────────────────────────────────────────────────────────
306
+
307
+ class _BillingOverviewTab extends StatefulWidget {
308
+ const _BillingOverviewTab({
309
+ required this.controller,
310
+ required this.onChangePlan,
311
+ });
312
+
313
+ final NeoAgentController controller;
314
+ final VoidCallback onChangePlan;
315
+
316
+ @override
317
+ State<_BillingOverviewTab> createState() => _BillingOverviewTabState();
318
+ }
319
+
320
+ class _BillingOverviewTabState extends State<_BillingOverviewTab> {
321
+ bool _canceling = false;
322
+
323
+ NeoAgentController get _c => widget.controller;
324
+
325
+ Future<void> _openPortal() async {
326
+ final url = await _c.createPortalSession();
327
+ if (url != null) await _openUrl(url);
328
+ }
329
+
330
+ Future<void> _cancel() async {
331
+ final ok = await showDialog<bool>(
332
+ context: context,
333
+ builder: (ctx) => AlertDialog(
334
+ backgroundColor: _bgCard,
335
+ title: Text('Cancel subscription',
336
+ style: TextStyle(color: _textPrimary, fontWeight: FontWeight.w700)),
337
+ content: Text(
338
+ 'Your subscription will remain active until the end of the billing period.',
339
+ style: TextStyle(color: _textSecondary),
340
+ ),
341
+ actions: <Widget>[
342
+ TextButton(
343
+ onPressed: () => Navigator.of(ctx).pop(false),
344
+ child: Text('Keep plan',
345
+ style: TextStyle(color: _textSecondary)),
346
+ ),
347
+ TextButton(
348
+ onPressed: () => Navigator.of(ctx).pop(true),
349
+ child: Text('Cancel subscription',
350
+ style: TextStyle(color: _danger)),
351
+ ),
352
+ ],
353
+ ),
354
+ );
355
+ if (ok != true || !mounted) return;
356
+ setState(() => _canceling = true);
357
+ await _c.cancelBillingSubscription();
358
+ if (mounted) setState(() => _canceling = false);
359
+ }
360
+
361
+ @override
362
+ Widget build(BuildContext context) {
363
+ final sub = _c.billingSubscription;
364
+ if (sub == null) {
365
+ return _NoSubscriptionCard(onViewPlans: widget.onChangePlan);
366
+ }
367
+
368
+ final plan = sub['plan'] as Map<String, dynamic>? ?? <String, dynamic>{};
369
+ final planName = plan['name'] as String? ?? 'Unknown';
370
+ final priceCents = (plan['price_cents'] as num?)?.toInt() ?? 0;
371
+ final currency = plan['currency'] as String? ?? 'usd';
372
+ final interval = plan['interval'] as String? ?? '';
373
+ final status = sub['status'] as String? ?? '';
374
+ final periodEnd = sub['current_period_end'] as String?;
375
+ final cancelAtEnd = sub['cancel_at_period_end'] == true;
376
+ final isTrialing = status == 'trialing';
377
+ final trialEnds = sub['trial_ends_at'] as String?;
378
+ final compact = MediaQuery.sizeOf(context).width < 720;
379
+
380
+ return Column(
381
+ crossAxisAlignment: CrossAxisAlignment.start,
382
+ children: <Widget>[
383
+ // ── Current plan card ────────────────────────────────────────────
384
+ Container(
385
+ padding: const EdgeInsets.all(22),
386
+ decoration: BoxDecoration(
387
+ color: _bgCard,
388
+ border: Border.all(color: _borderLight),
389
+ borderRadius: BorderRadius.circular(14),
390
+ ),
391
+ child: compact
392
+ ? Column(
393
+ crossAxisAlignment: CrossAxisAlignment.start,
394
+ children: <Widget>[
395
+ _planCardContent(planName, priceCents, currency, interval,
396
+ status, periodEnd, cancelAtEnd, isTrialing, trialEnds),
397
+ const SizedBox(height: 16),
398
+ _planCardActions(compact),
399
+ ],
400
+ )
401
+ : Row(
402
+ crossAxisAlignment: CrossAxisAlignment.start,
403
+ children: <Widget>[
404
+ Expanded(
405
+ child: _planCardContent(
406
+ planName, priceCents, currency, interval, status,
407
+ periodEnd, cancelAtEnd, isTrialing, trialEnds),
408
+ ),
409
+ const SizedBox(width: 20),
410
+ _planCardActions(compact),
411
+ ],
412
+ ),
413
+ ),
414
+ const SizedBox(height: 24),
415
+
416
+ // ── Usage this period ────────────────────────────────────────────
417
+ Row(
418
+ children: <Widget>[
419
+ Text('Usage this period',
420
+ style: GoogleFonts.geist(
421
+ fontSize: 16,
422
+ fontWeight: FontWeight.w700,
423
+ color: _textPrimary,
424
+ )),
425
+ const Spacer(),
426
+ if (periodEnd != null)
427
+ Container(
428
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
429
+ decoration: BoxDecoration(
430
+ color: _bgCard,
431
+ border: Border.all(color: _border),
432
+ borderRadius: BorderRadius.circular(999),
433
+ ),
434
+ child: Row(
435
+ mainAxisSize: MainAxisSize.min,
436
+ children: <Widget>[
437
+ Icon(Icons.refresh, size: 12, color: _textMuted),
438
+ const SizedBox(width: 5),
439
+ Text('Resets ${_fmtDate(periodEnd)}',
440
+ style: TextStyle(fontSize: 12, color: _textMuted)),
441
+ ],
442
+ ),
443
+ ),
444
+ ],
445
+ ),
446
+ const SizedBox(height: 12),
447
+ _UsageGrid(controller: _c, plan: plan),
448
+ ],
449
+ );
450
+ }
451
+
452
+ Widget _planCardContent(
453
+ String planName,
454
+ int priceCents,
455
+ String currency,
456
+ String interval,
457
+ String status,
458
+ String? periodEnd,
459
+ bool cancelAtEnd,
460
+ bool isTrialing,
461
+ String? trialEnds,
462
+ ) {
463
+ return Column(
464
+ crossAxisAlignment: CrossAxisAlignment.start,
465
+ children: <Widget>[
466
+ Text(
467
+ 'CURRENT PLAN',
468
+ style: GoogleFonts.geistMono(
469
+ fontSize: 10,
470
+ fontWeight: FontWeight.w600,
471
+ letterSpacing: 1.4,
472
+ color: _textMuted,
473
+ ),
474
+ ),
475
+ const SizedBox(height: 8),
476
+ Row(
477
+ crossAxisAlignment: CrossAxisAlignment.baseline,
478
+ textBaseline: TextBaseline.alphabetic,
479
+ children: <Widget>[
480
+ Text(planName,
481
+ style: GoogleFonts.geist(
482
+ fontSize: 28,
483
+ fontWeight: FontWeight.w800,
484
+ letterSpacing: -0.5,
485
+ color: _textPrimary,
486
+ )),
487
+ const SizedBox(width: 10),
488
+ Text(
489
+ _fmtPrice(priceCents, currency, interval: interval),
490
+ style: TextStyle(
491
+ fontSize: 15,
492
+ color: _textSecondary,
493
+ fontWeight: FontWeight.w500,
494
+ ),
495
+ ),
496
+ ],
497
+ ),
498
+ const SizedBox(height: 6),
499
+ Text(
500
+ isTrialing
501
+ ? 'Trial ends ${_fmtDate(trialEnds)}'
502
+ : cancelAtEnd
503
+ ? 'Cancels at end of period · ${_fmtDate(periodEnd)}'
504
+ : periodEnd != null
505
+ ? 'Renews ${_fmtDate(periodEnd)}'
506
+ : status,
507
+ style: TextStyle(fontSize: 13, color: _textMuted),
508
+ ),
509
+ ],
510
+ );
511
+ }
512
+
513
+ Widget _planCardActions(bool compact) {
514
+ return Column(
515
+ crossAxisAlignment:
516
+ compact ? CrossAxisAlignment.stretch : CrossAxisAlignment.end,
517
+ children: <Widget>[
518
+ FilledButton.icon(
519
+ onPressed: widget.onChangePlan,
520
+ icon: const Icon(Icons.swap_horiz_outlined, size: 16),
521
+ label: const Text('Change plan'),
522
+ style: FilledButton.styleFrom(
523
+ backgroundColor: _accent,
524
+ foregroundColor: _bgPrimary,
525
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
526
+ ),
527
+ ),
528
+ const SizedBox(height: 8),
529
+ OutlinedButton.icon(
530
+ onPressed: _canceling ? null : _cancel,
531
+ icon: _canceling
532
+ ? const SizedBox.square(
533
+ dimension: 14,
534
+ child: CircularProgressIndicator(strokeWidth: 1.5),
535
+ )
536
+ : const Icon(Icons.cancel_outlined, size: 16),
537
+ label: const Text('Cancel subscription'),
538
+ style: OutlinedButton.styleFrom(
539
+ foregroundColor: _danger,
540
+ side: BorderSide(color: _danger.withValues(alpha: 0.4)),
541
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
542
+ ),
543
+ ),
544
+ const SizedBox(height: 8),
545
+ TextButton.icon(
546
+ onPressed: _openPortal,
547
+ icon: const Icon(Icons.open_in_new_outlined, size: 14),
548
+ label: const Text('Manage payment'),
549
+ style: TextButton.styleFrom(
550
+ foregroundColor: _textMuted,
551
+ textStyle: const TextStyle(fontSize: 12),
552
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
553
+ ),
554
+ ),
555
+ ],
556
+ );
557
+ }
558
+ }
559
+
560
+ class _NoSubscriptionCard extends StatelessWidget {
561
+ const _NoSubscriptionCard({required this.onViewPlans});
562
+
563
+ final VoidCallback onViewPlans;
564
+
565
+ @override
566
+ Widget build(BuildContext context) {
567
+ return Container(
568
+ padding: const EdgeInsets.all(32),
569
+ decoration: BoxDecoration(
570
+ color: _bgCard,
571
+ border: Border.all(color: _border),
572
+ borderRadius: BorderRadius.circular(14),
573
+ ),
574
+ child: Column(
575
+ children: <Widget>[
576
+ Icon(Icons.credit_card_off_outlined, size: 40, color: _textMuted),
577
+ const SizedBox(height: 16),
578
+ Text('No active subscription',
579
+ style: TextStyle(
580
+ fontSize: 16,
581
+ fontWeight: FontWeight.w700,
582
+ color: _textPrimary)),
583
+ const SizedBox(height: 8),
584
+ Text('Choose a plan to get started.',
585
+ style: TextStyle(fontSize: 13, color: _textMuted)),
586
+ const SizedBox(height: 20),
587
+ FilledButton(
588
+ onPressed: onViewPlans,
589
+ style: FilledButton.styleFrom(
590
+ backgroundColor: _accent,
591
+ foregroundColor: _bgPrimary,
592
+ ),
593
+ child: const Text('View plans'),
594
+ ),
595
+ ],
596
+ ),
597
+ );
598
+ }
599
+ }
600
+
601
+ // ── Usage grid ────────────────────────────────────────────────────────────────
602
+
603
+ class _UsageGrid extends StatelessWidget {
604
+ const _UsageGrid({required this.controller, required this.plan});
605
+
606
+ final NeoAgentController controller;
607
+ final Map<String, dynamic> plan;
608
+
609
+ @override
610
+ Widget build(BuildContext context) {
611
+ final usage = controller.tokenUsage;
612
+ final usageLimits = controller.usageAndLimits;
613
+
614
+ final cards = <_UsageCardData>[
615
+ _UsageCardData(
616
+ icon: Icons.bolt_outlined,
617
+ label: 'Agent runs (7d)',
618
+ value: usage?.last7DaysRuns ?? 0,
619
+ max: null,
620
+ display: usage != null ? '${usage.last7DaysRuns}' : '—',
621
+ ),
622
+ _UsageCardData(
623
+ icon: Icons.token_outlined,
624
+ label: 'Weekly tokens',
625
+ value: usageLimits?.weeklyUsage ?? usage?.last7DaysTokens ?? 0,
626
+ max: usageLimits?.weeklyLimit,
627
+ display: _fmtTokenCount(
628
+ usageLimits?.weeklyUsage ?? usage?.last7DaysTokens ?? 0),
629
+ ),
630
+ _UsageCardData(
631
+ icon: Icons.timer_outlined,
632
+ label: '4h token window',
633
+ value: usageLimits?.fourHourUsage ?? 0,
634
+ max: usageLimits?.fourHourLimit,
635
+ display: _fmtTokenCount(usageLimits?.fourHourUsage ?? 0),
636
+ ),
637
+ _UsageCardData(
638
+ icon: Icons.memory_outlined,
639
+ label: 'Total runs',
640
+ value: usage?.totalRuns ?? 0,
641
+ max: null,
642
+ display: usage != null ? _fmtTokenCount(usage.totalRuns) : '—',
643
+ ),
644
+ ];
645
+
646
+ return LayoutBuilder(
647
+ builder: (context, constraints) {
648
+ final crossCount = constraints.maxWidth < 560 ? 1 : 2;
649
+ return GridView.builder(
650
+ shrinkWrap: true,
651
+ physics: const NeverScrollableScrollPhysics(),
652
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
653
+ crossAxisCount: crossCount,
654
+ crossAxisSpacing: 10,
655
+ mainAxisSpacing: 10,
656
+ childAspectRatio: crossCount == 1 ? 3.5 : 2.6,
657
+ ),
658
+ itemCount: cards.length,
659
+ itemBuilder: (_, i) => _UsageCard(data: cards[i]),
660
+ );
661
+ },
662
+ );
663
+ }
664
+
665
+ static String _fmtTokenCount(int n) {
666
+ if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
667
+ if (n >= 1000) return '${(n / 1000).toStringAsFixed(0)}K';
668
+ return '$n';
669
+ }
670
+ }
671
+
672
+ class _UsageCardData {
673
+ const _UsageCardData({
674
+ required this.icon,
675
+ required this.label,
676
+ required this.value,
677
+ required this.max,
678
+ required this.display,
679
+ });
680
+
681
+ final IconData icon;
682
+ final String label;
683
+ final int value;
684
+ final int? max;
685
+ final String display;
686
+ }
687
+
688
+ class _UsageCard extends StatelessWidget {
689
+ const _UsageCard({required this.data});
690
+
691
+ final _UsageCardData data;
692
+
693
+ @override
694
+ Widget build(BuildContext context) {
695
+ final ratio = data.max != null && data.max! > 0
696
+ ? (data.value / data.max!).clamp(0.0, 1.0)
697
+ : null;
698
+ final barColor = ratio == null
699
+ ? _accentAlt
700
+ : ratio > 0.85
701
+ ? _danger
702
+ : ratio > 0.65
703
+ ? _warning
704
+ : _accentAlt;
705
+
706
+ return Container(
707
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
708
+ decoration: BoxDecoration(
709
+ color: _bgCard,
710
+ border: Border.all(color: _border),
711
+ borderRadius: BorderRadius.circular(12),
712
+ ),
713
+ child: Column(
714
+ crossAxisAlignment: CrossAxisAlignment.start,
715
+ children: <Widget>[
716
+ Row(
717
+ children: <Widget>[
718
+ Icon(data.icon, size: 15, color: _accentAlt),
719
+ const SizedBox(width: 7),
720
+ Expanded(
721
+ child: Text(data.label,
722
+ style: TextStyle(fontSize: 12, color: _textMuted),
723
+ overflow: TextOverflow.ellipsis),
724
+ ),
725
+ ],
726
+ ),
727
+ const Spacer(),
728
+ Row(
729
+ crossAxisAlignment: CrossAxisAlignment.baseline,
730
+ textBaseline: TextBaseline.alphabetic,
731
+ children: <Widget>[
732
+ Text(data.display,
733
+ style: GoogleFonts.geist(
734
+ fontSize: 22,
735
+ fontWeight: FontWeight.w700,
736
+ color: _textPrimary,
737
+ letterSpacing: -0.5,
738
+ )),
739
+ if (data.max != null) ...<Widget>[
740
+ const SizedBox(width: 4),
741
+ Text(' / ${_UsageGrid._fmtTokenCount(data.max!)}',
742
+ style: TextStyle(fontSize: 13, color: _textMuted)),
743
+ ],
744
+ ],
745
+ ),
746
+ if (ratio != null) ...<Widget>[
747
+ const SizedBox(height: 8),
748
+ ClipRRect(
749
+ borderRadius: BorderRadius.circular(999),
750
+ child: LinearProgressIndicator(
751
+ value: ratio,
752
+ minHeight: 4,
753
+ backgroundColor: _bgSecondary,
754
+ valueColor: AlwaysStoppedAnimation<Color>(barColor),
755
+ ),
756
+ ),
757
+ ],
758
+ ],
759
+ ),
760
+ );
761
+ }
762
+ }
763
+
764
+ // ── Plans tab ─────────────────────────────────────────────────────────────────
765
+
766
+ class _BillingPlansTab extends StatefulWidget {
767
+ const _BillingPlansTab({
768
+ required this.controller,
769
+ required this.annual,
770
+ required this.onToggleAnnual,
771
+ });
772
+
773
+ final NeoAgentController controller;
774
+ final bool annual;
775
+ final ValueChanged<bool> onToggleAnnual;
776
+
777
+ @override
778
+ State<_BillingPlansTab> createState() => _BillingPlansTabState();
779
+ }
780
+
781
+ class _BillingPlansTabState extends State<_BillingPlansTab> {
782
+ String? _checkingOut;
783
+
784
+ NeoAgentController get _c => widget.controller;
785
+
786
+ Future<void> _checkout(String planId) async {
787
+ setState(() => _checkingOut = planId);
788
+ final url = await _c.createCheckoutSession(planId);
789
+ if (!mounted) return;
790
+ setState(() => _checkingOut = null);
791
+ if (url != null) {
792
+ await _openUrl(url);
793
+ } else {
794
+ ScaffoldMessenger.of(context).showSnackBar(
795
+ SnackBar(
796
+ content: Text(
797
+ _c.errorMessage ?? 'Could not start checkout. Check Stripe configuration.',
798
+ ),
799
+ backgroundColor: _danger,
800
+ ),
801
+ );
802
+ }
803
+ }
804
+
805
+ @override
806
+ Widget build(BuildContext context) {
807
+ final allPlans = _c.billingPlans;
808
+ if (allPlans.isEmpty) {
809
+ return Center(
810
+ child: Padding(
811
+ padding: const EdgeInsets.all(40),
812
+ child: Text('No plans configured yet.',
813
+ style: TextStyle(color: _textMuted)),
814
+ ),
815
+ );
816
+ }
817
+
818
+ // Filter by interval: annual = 'year', monthly = 'month'
819
+ final monthly =
820
+ allPlans.where((p) => p['interval'] == 'month' || p['price_cents'] == 0).toList();
821
+ final yearly =
822
+ allPlans.where((p) => p['interval'] == 'year').toList();
823
+ final hasAnnual = yearly.isNotEmpty;
824
+ final plans = widget.annual && hasAnnual ? yearly : monthly;
825
+ final currentPlanId =
826
+ _c.billingSubscription?['plan_id'] as String?;
827
+
828
+ // Savings pct
829
+ int? savingsPct;
830
+ if (hasAnnual && monthly.isNotEmpty && yearly.isNotEmpty) {
831
+ final mp = (monthly.first['price_cents'] as num?)?.toInt() ?? 0;
832
+ final yp = (yearly.first['price_cents'] as num?)?.toInt() ?? 0;
833
+ if (mp > 0) {
834
+ savingsPct = (((mp * 12 - yp) / (mp * 12)) * 100).round();
835
+ }
836
+ }
837
+
838
+ // Recommended = middle plan (or plan with sort_order matching middle index)
839
+ final recommendedIndex = plans.length > 2 ? 1 : (plans.length - 1);
840
+
841
+ return Column(
842
+ crossAxisAlignment: CrossAxisAlignment.start,
843
+ children: <Widget>[
844
+ // ── Monthly / Annual toggle ──────────────────────────────────────
845
+ Row(
846
+ children: <Widget>[
847
+ _IntervalToggle(
848
+ annual: widget.annual,
849
+ onToggle: widget.onToggleAnnual,
850
+ annualAvailable: hasAnnual,
851
+ ),
852
+ if (savingsPct != null && savingsPct > 0) ...<Widget>[
853
+ const SizedBox(width: 10),
854
+ Container(
855
+ padding:
856
+ const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
857
+ decoration: BoxDecoration(
858
+ color: _success.withValues(alpha: 0.15),
859
+ borderRadius: BorderRadius.circular(999),
860
+ ),
861
+ child: Text(
862
+ 'Save up to $savingsPct% yearly',
863
+ style: TextStyle(
864
+ fontSize: 12,
865
+ fontWeight: FontWeight.w600,
866
+ color: _success,
867
+ ),
868
+ ),
869
+ ),
870
+ ],
871
+ ],
872
+ ),
873
+ const SizedBox(height: 20),
874
+
875
+ // ── Plan cards ────────────────────────────────────────────────────
876
+ LayoutBuilder(
877
+ builder: (context, constraints) {
878
+ final crossCount = constraints.maxWidth < 480
879
+ ? 1
880
+ : constraints.maxWidth < 900
881
+ ? plans.length.clamp(1, 2)
882
+ : plans.length.clamp(1, 3);
883
+ return GridView.builder(
884
+ shrinkWrap: true,
885
+ physics: const NeverScrollableScrollPhysics(),
886
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
887
+ crossAxisCount: crossCount,
888
+ crossAxisSpacing: 12,
889
+ mainAxisSpacing: 12,
890
+ childAspectRatio: crossCount == 1 ? 2.0 : 0.72,
891
+ ),
892
+ itemCount: plans.length,
893
+ itemBuilder: (_, i) => _PlanCard(
894
+ plan: plans[i],
895
+ recommended: i == recommendedIndex,
896
+ current: plans[i]['id'] == currentPlanId,
897
+ loading: _checkingOut == plans[i]['id'] as String?,
898
+ onSelect: () =>
899
+ _checkout(plans[i]['id'] as String),
900
+ ),
901
+ );
902
+ },
903
+ ),
904
+ ],
905
+ );
906
+ }
907
+ }
908
+
909
+ class _IntervalToggle extends StatelessWidget {
910
+ const _IntervalToggle({
911
+ required this.annual,
912
+ required this.onToggle,
913
+ this.annualAvailable = true,
914
+ });
915
+
916
+ final bool annual;
917
+ final ValueChanged<bool> onToggle;
918
+ final bool annualAvailable;
919
+
920
+ @override
921
+ Widget build(BuildContext context) {
922
+ return Container(
923
+ padding: const EdgeInsets.all(3),
924
+ decoration: BoxDecoration(
925
+ color: _bgCard,
926
+ border: Border.all(color: _border),
927
+ borderRadius: BorderRadius.circular(999),
928
+ ),
929
+ child: Row(
930
+ mainAxisSize: MainAxisSize.min,
931
+ children: <Widget>[
932
+ _pill('Monthly', !annual, () => onToggle(false)),
933
+ _pill('Annual', annual, annualAvailable ? () => onToggle(true) : null),
934
+ ],
935
+ ),
936
+ );
937
+ }
938
+
939
+ Widget _pill(String label, bool active, VoidCallback? onTap) {
940
+ return GestureDetector(
941
+ onTap: onTap,
942
+ child: AnimatedContainer(
943
+ duration: const Duration(milliseconds: 150),
944
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
945
+ decoration: BoxDecoration(
946
+ color: active ? _accent : Colors.transparent,
947
+ borderRadius: BorderRadius.circular(999),
948
+ ),
949
+ child: Text(
950
+ label,
951
+ style: GoogleFonts.geist(
952
+ fontSize: 13,
953
+ fontWeight: FontWeight.w600,
954
+ color: active ? _bgPrimary : _textMuted,
955
+ ),
956
+ ),
957
+ ),
958
+ );
959
+ }
960
+ }
961
+
962
+ class _PlanCard extends StatelessWidget {
963
+ const _PlanCard({
964
+ required this.plan,
965
+ required this.recommended,
966
+ required this.current,
967
+ required this.loading,
968
+ required this.onSelect,
969
+ });
970
+
971
+ final Map<String, dynamic> plan;
972
+ final bool recommended;
973
+ final bool current;
974
+ final bool loading;
975
+ final VoidCallback onSelect;
976
+
977
+ @override
978
+ Widget build(BuildContext context) {
979
+ final name = plan['name'] as String? ?? '';
980
+ final desc = plan['description'] as String? ?? '';
981
+ final priceCents = (plan['price_cents'] as num?)?.toInt() ?? 0;
982
+ final currency = plan['currency'] as String? ?? 'usd';
983
+ final interval = plan['interval'] as String? ?? '';
984
+ final features = _asList<String>(plan['features']);
985
+
986
+ return Stack(
987
+ children: <Widget>[
988
+ Container(
989
+ decoration: BoxDecoration(
990
+ color: _bgCard,
991
+ border: Border.all(
992
+ color: recommended
993
+ ? _accent.withValues(alpha: 0.5)
994
+ : _borderLight,
995
+ width: recommended ? 1.5 : 1,
996
+ ),
997
+ borderRadius: BorderRadius.circular(16),
998
+ ),
999
+ child: Padding(
1000
+ padding: const EdgeInsets.all(22),
1001
+ child: Column(
1002
+ crossAxisAlignment: CrossAxisAlignment.start,
1003
+ children: <Widget>[
1004
+ // Plan name + description
1005
+ Column(
1006
+ crossAxisAlignment: CrossAxisAlignment.start,
1007
+ children: <Widget>[
1008
+ Text(name,
1009
+ style: GoogleFonts.geist(
1010
+ fontSize: 17,
1011
+ fontWeight: FontWeight.w800,
1012
+ color: _textPrimary,
1013
+ letterSpacing: -0.3,
1014
+ )),
1015
+ if (desc.isNotEmpty)
1016
+ Text(desc,
1017
+ style: TextStyle(
1018
+ fontSize: 12, color: _textMuted),
1019
+ maxLines: 1,
1020
+ overflow: TextOverflow.ellipsis),
1021
+ ],
1022
+ ),
1023
+ const SizedBox(height: 16),
1024
+
1025
+ // Price
1026
+ Row(
1027
+ crossAxisAlignment: CrossAxisAlignment.baseline,
1028
+ textBaseline: TextBaseline.alphabetic,
1029
+ children: <Widget>[
1030
+ Text(
1031
+ () {
1032
+ final sym = currency.toUpperCase() == 'USD' ? '\$' : '€';
1033
+ final amt = priceCents / 100;
1034
+ return priceCents == 0
1035
+ ? '${sym}0'
1036
+ : amt == amt.truncateToDouble()
1037
+ ? '$sym${amt.toInt()}'
1038
+ : '$sym${amt.toStringAsFixed(2)}';
1039
+ }(),
1040
+ style: GoogleFonts.geist(
1041
+ fontSize: 32,
1042
+ fontWeight: FontWeight.w800,
1043
+ color: _textPrimary,
1044
+ letterSpacing: -1,
1045
+ ),
1046
+ ),
1047
+ if (interval.isNotEmpty) ...<Widget>[
1048
+ const SizedBox(width: 4),
1049
+ Text(' / $interval',
1050
+ style: TextStyle(
1051
+ fontSize: 13, color: _textMuted)),
1052
+ ] else ...<Widget>[
1053
+ const SizedBox(width: 6),
1054
+ Text('forever',
1055
+ style: TextStyle(
1056
+ fontSize: 13, color: _textMuted)),
1057
+ ],
1058
+ ],
1059
+ ),
1060
+ const SizedBox(height: 16),
1061
+
1062
+ // Features
1063
+ ...features.map((f) => Padding(
1064
+ padding: const EdgeInsets.only(bottom: 7),
1065
+ child: Row(
1066
+ crossAxisAlignment: CrossAxisAlignment.start,
1067
+ children: <Widget>[
1068
+ Icon(Icons.check_circle_outline,
1069
+ size: 15, color: _accentAlt),
1070
+ const SizedBox(width: 8),
1071
+ Expanded(
1072
+ child: Text(f,
1073
+ style: TextStyle(
1074
+ fontSize: 13, color: _textSecondary)),
1075
+ ),
1076
+ ],
1077
+ ),
1078
+ )),
1079
+
1080
+ const Spacer(),
1081
+ const SizedBox(height: 14),
1082
+
1083
+ // Action button
1084
+ SizedBox(
1085
+ width: double.infinity,
1086
+ child: current
1087
+ ? OutlinedButton(
1088
+ onPressed: null,
1089
+ style: OutlinedButton.styleFrom(
1090
+ side: BorderSide(color: _border),
1091
+ foregroundColor: _textMuted,
1092
+ ),
1093
+ child: const Text('Current plan'),
1094
+ )
1095
+ : FilledButton(
1096
+ onPressed: loading ? null : onSelect,
1097
+ style: FilledButton.styleFrom(
1098
+ backgroundColor: recommended ? _accent : _bgSecondary,
1099
+ foregroundColor:
1100
+ recommended ? _bgPrimary : _textPrimary,
1101
+ ),
1102
+ child: loading
1103
+ ? const SizedBox.square(
1104
+ dimension: 16,
1105
+ child: CircularProgressIndicator(
1106
+ strokeWidth: 1.5),
1107
+ )
1108
+ : Text(
1109
+ priceCents == 0
1110
+ ? 'Get started'
1111
+ : 'Choose $name'),
1112
+ ),
1113
+ ),
1114
+ ],
1115
+ ),
1116
+ ),
1117
+ ),
1118
+
1119
+ // Recommended badge
1120
+ if (recommended)
1121
+ Positioned(
1122
+ top: -1,
1123
+ left: 0,
1124
+ right: 0,
1125
+ child: Center(
1126
+ child: Container(
1127
+ padding:
1128
+ const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
1129
+ decoration: BoxDecoration(
1130
+ color: _accent,
1131
+ borderRadius: const BorderRadius.only(
1132
+ bottomLeft: Radius.circular(8),
1133
+ bottomRight: Radius.circular(8),
1134
+ ),
1135
+ ),
1136
+ child: Text(
1137
+ 'RECOMMENDED',
1138
+ style: GoogleFonts.geistMono(
1139
+ fontSize: 10,
1140
+ fontWeight: FontWeight.w700,
1141
+ letterSpacing: 1.2,
1142
+ color: _bgPrimary,
1143
+ ),
1144
+ ),
1145
+ ),
1146
+ ),
1147
+ ),
1148
+ ],
1149
+ );
1150
+ }
1151
+
1152
+ static List<T> _asList<T>(dynamic val) {
1153
+ if (val is List) return val.whereType<T>().toList();
1154
+ return <T>[];
1155
+ }
1156
+ }
1157
+
1158
+ // ── Billing history tab ───────────────────────────────────────────────────────
1159
+
1160
+ class _BillingHistoryTab extends StatefulWidget {
1161
+ const _BillingHistoryTab({required this.controller});
1162
+
1163
+ final NeoAgentController controller;
1164
+
1165
+ @override
1166
+ State<_BillingHistoryTab> createState() => _BillingHistoryTabState();
1167
+ }
1168
+
1169
+ class _BillingHistoryTabState extends State<_BillingHistoryTab> {
1170
+ bool _openingPortal = false;
1171
+
1172
+ NeoAgentController get _c => widget.controller;
1173
+
1174
+ Future<void> _openPortal() async {
1175
+ setState(() => _openingPortal = true);
1176
+ final url = await _c.createPortalSession();
1177
+ if (!mounted) return;
1178
+ setState(() => _openingPortal = false);
1179
+ if (url != null) await _openUrl(url);
1180
+ }
1181
+
1182
+ @override
1183
+ Widget build(BuildContext context) {
1184
+ final invoices = _c.billingInvoices;
1185
+
1186
+ return Column(
1187
+ crossAxisAlignment: CrossAxisAlignment.start,
1188
+ children: <Widget>[
1189
+ // ── Payment method card ──────────────────────────────────────────
1190
+ Container(
1191
+ padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
1192
+ decoration: BoxDecoration(
1193
+ color: _bgCard,
1194
+ border: Border.all(color: _borderLight),
1195
+ borderRadius: BorderRadius.circular(12),
1196
+ ),
1197
+ child: Row(
1198
+ children: <Widget>[
1199
+ Container(
1200
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
1201
+ decoration: BoxDecoration(
1202
+ color: _bgSecondary,
1203
+ borderRadius: BorderRadius.circular(6),
1204
+ border: Border.all(color: _border),
1205
+ ),
1206
+ child: Icon(Icons.credit_card,
1207
+ size: 20, color: _textSecondary),
1208
+ ),
1209
+ const SizedBox(width: 14),
1210
+ Expanded(
1211
+ child: Column(
1212
+ crossAxisAlignment: CrossAxisAlignment.start,
1213
+ children: <Widget>[
1214
+ Text('Payment method',
1215
+ style: TextStyle(
1216
+ fontSize: 14,
1217
+ fontWeight: FontWeight.w600,
1218
+ color: _textPrimary,
1219
+ )),
1220
+ const SizedBox(height: 2),
1221
+ Text('Manage via Stripe Customer Portal',
1222
+ style: TextStyle(fontSize: 12, color: _textMuted)),
1223
+ ],
1224
+ ),
1225
+ ),
1226
+ const SizedBox(width: 12),
1227
+ OutlinedButton.icon(
1228
+ onPressed: _openingPortal ? null : _openPortal,
1229
+ icon: _openingPortal
1230
+ ? const SizedBox.square(
1231
+ dimension: 14,
1232
+ child: CircularProgressIndicator(strokeWidth: 1.5),
1233
+ )
1234
+ : const Icon(Icons.open_in_new_outlined, size: 14),
1235
+ label: const Text('Update'),
1236
+ style: OutlinedButton.styleFrom(
1237
+ foregroundColor: _textSecondary,
1238
+ side: BorderSide(color: _borderLight),
1239
+ padding:
1240
+ const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
1241
+ ),
1242
+ ),
1243
+ ],
1244
+ ),
1245
+ ),
1246
+ const SizedBox(height: 20),
1247
+
1248
+ // ── Billing history ──────────────────────────────────────────────
1249
+ Row(
1250
+ children: <Widget>[
1251
+ Text('Billing history',
1252
+ style: GoogleFonts.geist(
1253
+ fontSize: 16,
1254
+ fontWeight: FontWeight.w700,
1255
+ color: _textPrimary,
1256
+ )),
1257
+ const Spacer(),
1258
+ if (invoices.isNotEmpty)
1259
+ TextButton.icon(
1260
+ onPressed: _openPortal,
1261
+ icon: const Icon(Icons.download_outlined, size: 14),
1262
+ label: const Text('Export all'),
1263
+ style: TextButton.styleFrom(
1264
+ foregroundColor: _textMuted,
1265
+ textStyle: const TextStyle(fontSize: 12),
1266
+ ),
1267
+ ),
1268
+ ],
1269
+ ),
1270
+ const SizedBox(height: 12),
1271
+
1272
+ if (invoices.isEmpty)
1273
+ Container(
1274
+ padding: const EdgeInsets.all(32),
1275
+ decoration: BoxDecoration(
1276
+ color: _bgCard,
1277
+ border: Border.all(color: _border),
1278
+ borderRadius: BorderRadius.circular(12),
1279
+ ),
1280
+ child: Center(
1281
+ child: Text('No invoices yet.',
1282
+ style: TextStyle(color: _textMuted, fontSize: 13)),
1283
+ ),
1284
+ )
1285
+ else
1286
+ Container(
1287
+ decoration: BoxDecoration(
1288
+ color: _bgCard,
1289
+ border: Border.all(color: _border),
1290
+ borderRadius: BorderRadius.circular(12),
1291
+ ),
1292
+ clipBehavior: Clip.antiAlias,
1293
+ child: Column(
1294
+ children: <Widget>[
1295
+ // Header row
1296
+ Container(
1297
+ padding: const EdgeInsets.symmetric(
1298
+ horizontal: 16, vertical: 10),
1299
+ color: _bgSecondary,
1300
+ child: Row(
1301
+ children: <Widget>[
1302
+ _hCell('Date', flex: 2),
1303
+ _hCell('Description', flex: 3),
1304
+ _hCell('Invoice', flex: 2),
1305
+ _hCell('Status', flex: 2),
1306
+ _hCell('Amount', flex: 2),
1307
+ const SizedBox(width: 36),
1308
+ ],
1309
+ ),
1310
+ ),
1311
+ ...invoices.asMap().entries.map((e) => _InvoiceRow(
1312
+ invoice: e.value,
1313
+ last: e.key == invoices.length - 1)),
1314
+ ],
1315
+ ),
1316
+ ),
1317
+ ],
1318
+ );
1319
+ }
1320
+
1321
+ static Widget _hCell(String label, {int flex = 1}) {
1322
+ return Expanded(
1323
+ flex: flex,
1324
+ child: Text(
1325
+ label.toUpperCase(),
1326
+ style: GoogleFonts.geistMono(
1327
+ fontSize: 10,
1328
+ fontWeight: FontWeight.w600,
1329
+ letterSpacing: 0.8,
1330
+ color: _textMuted,
1331
+ ),
1332
+ ),
1333
+ );
1334
+ }
1335
+ }
1336
+
1337
+ class _InvoiceRow extends StatelessWidget {
1338
+ const _InvoiceRow({required this.invoice, required this.last});
1339
+
1340
+ final Map<String, dynamic> invoice;
1341
+ final bool last;
1342
+
1343
+ @override
1344
+ Widget build(BuildContext context) {
1345
+ final status = invoice['status'] as String? ?? '—';
1346
+ final created = invoice['created'] as int?;
1347
+ final amountPaid = (invoice['amount_paid'] as num?)?.toInt() ?? 0;
1348
+ final currency = invoice['currency'] as String? ?? 'usd';
1349
+ final pdfUrl = invoice['invoice_pdf'] as String?;
1350
+ final hostedUrl = invoice['hosted_invoice_url'] as String?;
1351
+
1352
+ final sym = currency.toUpperCase() == 'USD' ? '\$' : '€';
1353
+ final amountStr =
1354
+ '$sym${(amountPaid / 100).toStringAsFixed(2)}';
1355
+
1356
+ Color statusColor;
1357
+ switch (status) {
1358
+ case 'paid':
1359
+ statusColor = _success;
1360
+ break;
1361
+ case 'open':
1362
+ case 'uncollectible':
1363
+ statusColor = _warning;
1364
+ break;
1365
+ case 'void':
1366
+ statusColor = _textMuted;
1367
+ break;
1368
+ default:
1369
+ statusColor = _textMuted;
1370
+ }
1371
+
1372
+ return Container(
1373
+ decoration: BoxDecoration(
1374
+ border: last
1375
+ ? null
1376
+ : Border(bottom: BorderSide(color: _border)),
1377
+ ),
1378
+ child: Padding(
1379
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
1380
+ child: Row(
1381
+ children: <Widget>[
1382
+ Expanded(
1383
+ flex: 2,
1384
+ child: Text(_fmtTs(created),
1385
+ style: TextStyle(fontSize: 13, color: _textSecondary)),
1386
+ ),
1387
+ Expanded(
1388
+ flex: 3,
1389
+ child: Text(
1390
+ _deriveDesc(invoice),
1391
+ style: TextStyle(fontSize: 13, color: _textSecondary),
1392
+ overflow: TextOverflow.ellipsis,
1393
+ ),
1394
+ ),
1395
+ Expanded(
1396
+ flex: 2,
1397
+ child: Text(
1398
+ invoice['number'] as String? ?? '—',
1399
+ style: TextStyle(
1400
+ fontSize: 12,
1401
+ color: _textMuted,
1402
+ fontFamily: 'GeistMono'),
1403
+ ),
1404
+ ),
1405
+ Expanded(
1406
+ flex: 2,
1407
+ child: Row(
1408
+ children: <Widget>[
1409
+ Container(
1410
+ width: 7,
1411
+ height: 7,
1412
+ decoration: BoxDecoration(
1413
+ color: statusColor,
1414
+ shape: BoxShape.circle,
1415
+ ),
1416
+ ),
1417
+ const SizedBox(width: 6),
1418
+ Text(
1419
+ status[0].toUpperCase() + status.substring(1),
1420
+ style: TextStyle(
1421
+ fontSize: 13,
1422
+ color: statusColor,
1423
+ fontWeight: FontWeight.w500),
1424
+ ),
1425
+ ],
1426
+ ),
1427
+ ),
1428
+ Expanded(
1429
+ flex: 2,
1430
+ child: Text(amountStr,
1431
+ style: TextStyle(
1432
+ fontSize: 13,
1433
+ fontWeight: FontWeight.w600,
1434
+ color: _textPrimary,
1435
+ )),
1436
+ ),
1437
+ SizedBox(
1438
+ width: 36,
1439
+ child: IconButton(
1440
+ icon: const Icon(Icons.download_outlined, size: 16),
1441
+ color: _textMuted,
1442
+ padding: EdgeInsets.zero,
1443
+ tooltip: 'Download PDF',
1444
+ onPressed: pdfUrl != null
1445
+ ? () => _openUrl(pdfUrl)
1446
+ : hostedUrl != null
1447
+ ? () => _openUrl(hostedUrl)
1448
+ : null,
1449
+ ),
1450
+ ),
1451
+ ],
1452
+ ),
1453
+ ),
1454
+ );
1455
+ }
1456
+
1457
+ static String _deriveDesc(Map<String, dynamic> inv) {
1458
+ final lines = inv['lines'] as List?;
1459
+ if (lines != null && lines.isNotEmpty) {
1460
+ final first = lines.first;
1461
+ if (first is Map) return first['description'] as String? ?? '';
1462
+ }
1463
+ return inv['description'] as String? ?? 'Invoice';
1464
+ }
1465
+ }