neoagent 3.0.1-beta.9 → 3.1.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.
- package/.env.example +2 -10
- package/README.md +12 -3
- package/docs/automation.md +37 -0
- package/docs/benchmarking.md +102 -0
- package/docs/billing.md +34 -8
- package/docs/configuration.md +11 -2
- package/docs/getting-started.md +10 -8
- package/docs/operations.md +1 -1
- package/flutter_app/lib/main.dart +3 -0
- package/flutter_app/lib/main_account_settings.dart +138 -0
- package/flutter_app/lib/main_app_shell.dart +38 -13
- package/flutter_app/lib/main_billing.dart +1465 -0
- package/flutter_app/lib/main_chat.dart +1612 -214
- package/flutter_app/lib/main_controller.dart +398 -26
- package/flutter_app/lib/main_devices.dart +293 -207
- package/flutter_app/lib/main_models.dart +142 -0
- package/flutter_app/lib/main_navigation.dart +19 -1
- package/flutter_app/lib/main_operations.dart +288 -9
- package/flutter_app/lib/main_settings.dart +510 -300
- package/flutter_app/lib/main_shared.dart +2 -0
- package/flutter_app/lib/main_timeline.dart +1378 -0
- package/flutter_app/lib/src/backend_client.dart +149 -19
- package/flutter_app/lib/src/desktop_companion_actions.dart +132 -21
- package/flutter_app/lib/src/desktop_companion_io.dart +65 -1
- package/flutter_app/lib/src/desktop_companion_stub.dart +12 -0
- package/flutter_app/lib/src/desktop_ocr_bridge.dart +2 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +125 -0
- package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +30 -0
- package/flutter_app/lib/src/desktop_passive_history.dart +332 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +80 -72
- package/flutter_app/lib/src/recording_bridge_web.dart +127 -114
- package/flutter_app/lib/src/recording_chunk_queue.dart +149 -0
- package/flutter_app/lib/src/recording_chunk_queue_io.dart +182 -0
- package/flutter_app/lib/src/recording_payloads.dart +9 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +25 -0
- package/flutter_app/windows/runner/flutter_window.cpp +75 -0
- package/landing/index.html +3 -3
- package/lib/manager.js +184 -66
- package/package.json +4 -1
- package/server/admin/access.js +12 -7
- package/server/admin/admin.js +436 -10
- package/server/admin/billing.js +158 -35
- package/server/admin/index.html +72 -2
- package/server/admin/users.js +15 -15
- package/server/db/database.js +125 -20
- package/server/http/routes.js +1 -0
- package/server/http/static.js +4 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/canvaskit/wimp.js.symbols +8475 -8467
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +91077 -87037
- package/server/routes/account.js +53 -0
- package/server/routes/admin.js +345 -64
- package/server/routes/agents.js +203 -21
- package/server/routes/billing.js +5 -0
- package/server/routes/browser.js +8 -1
- package/server/routes/recordings.js +96 -6
- package/server/routes/screenHistory.js +140 -2
- package/server/routes/timeline.js +43 -0
- package/server/services/account/erasure.js +263 -0
- package/server/services/ai/hooks.js +4 -1
- package/server/services/ai/loop/agent_engine_core.js +8 -1
- package/server/services/ai/loop/blank_recovery.js +36 -0
- package/server/services/ai/loop/conversation_loop.js +166 -33
- package/server/services/ai/messagingFallback.js +22 -0
- package/server/services/ai/rate_limits.js +28 -5
- package/server/services/ai/systemPrompt.js +6 -5
- package/server/services/ai/taskAnalysis.js +2 -0
- package/server/services/ai/toolEvidence.js +15 -0
- package/server/services/ai/toolResult.js +40 -0
- package/server/services/ai/tools.js +163 -4
- package/server/services/android/controller.js +6 -2
- package/server/services/billing/plans.js +2 -1
- package/server/services/browser/anti_detection.js +192 -0
- package/server/services/browser/controller.js +180 -54
- package/server/services/desktop/auth.js +3 -0
- package/server/services/desktop/registry.js +50 -2
- package/server/services/integrations/google/calendar.js +22 -14
- package/server/services/manager.js +12 -42
- package/server/services/memory/ingestion_chunking.js +268 -0
- package/server/services/messaging/telnyx.js +9 -8
- package/server/services/recordings/manager.js +60 -27
- package/server/services/runtime/docker-vm-manager.js +157 -266
- package/server/services/runtime/guest_bootstrap.js +17 -5
- package/server/services/runtime/guest_image.js +188 -0
- package/server/services/runtime/manager.js +0 -1
- package/server/services/runtime/validation.js +3 -8
- package/server/services/social_video/service.js +60 -10
- package/server/services/tasks/runtime.js +234 -9
- package/server/services/tasks/task_repository.js +13 -0
- package/server/services/timeline/service.js +558 -0
- package/server/services/wearable/gateway.js +1 -1
- package/server/services/websocket.js +21 -3
- package/server/services/desktop/screenRecorder.js +0 -292
- 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
|
+
}
|