From 48517de06b199fb7c6e68d67830edb2ba0987059 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 26 May 2026 16:19:27 -0500 Subject: [PATCH 01/90] fix(shopinbit): escape non-ASCII in request bodies --- lib/services/shopinbit/src/client.dart | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index fe48184129..a1f9b8be81 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -544,14 +544,14 @@ class ShopInBitClient { return _httpClient.post( url: uri, headers: headers, - body: body != null ? jsonEncode(body) : null, + body: body != null ? _asciiSafeJson(body) : null, proxyInfo: proxy, ); case 'PATCH': return _httpClient.patch( url: uri, headers: headers, - body: body != null ? jsonEncode(body) : null, + body: body != null ? _asciiSafeJson(body) : null, proxyInfo: proxy, ); case 'DELETE': @@ -561,6 +561,24 @@ class ShopInBitClient { } } + // Encode [body] as JSON with all non-ASCII characters replaced by \uXXXX + // escapes. The HTTP wrapper writes string bodies with the latin1 default of + // HttpClientRequest.write, which mangles multi-byte UTF-8 like the U+00B1/±. + static String _asciiSafeJson(Object body) { + final raw = jsonEncode(body); + final buf = StringBuffer(); + for (int i = 0; i < raw.length; i++) { + final c = raw.codeUnitAt(i); + if (c < 0x80) { + buf.writeCharCode(c); + } else { + buf.write('\\u'); + buf.write(c.toRadixString(16).padLeft(4, '0')); + } + } + return buf.toString(); + } + Future> _request( String method, String path, { From e230633842ea85137d24446acf64c9457d664123 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 26 May 2026 17:08:21 -0500 Subject: [PATCH 02/90] feat(shopinbit): migrate to PUT /payment for 1.0.4 --- lib/networking/http.dart | 31 ++++++++++++++++ .../shopinbit/shopinbit_payment_view.dart | 6 ++- lib/services/shopinbit/src/client.dart | 37 +++++++++++++++---- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/lib/networking/http.dart b/lib/networking/http.dart index efa997e64a..246891da43 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -87,6 +87,37 @@ class HTTP { } } + Future put({ + required Uri url, + Map? headers, + Object? body, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final httpClient = HttpClient(); + try { + if (proxyInfo != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final HttpClientRequest request = await httpClient.putUrl(url); + + if (headers != null) { + headers.forEach((key, value) => request.headers.add(key, value)); + } + + if (body != null) request.write(body); + + final response = await request.close(); + return Response(await _bodyBytes(response), response.statusCode); + } catch (e, s) { + Logging.instance.w("HTTP.put() rethrew: ", error: e, stackTrace: s); + rethrow; + } finally { + httpClient.close(force: true); + } + } + Future patch({ required Uri url, Map? headers, diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index eea1f437c8..23afcb31c1 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -121,13 +121,15 @@ class _ShopInBitPaymentViewState extends ConsumerState { } catch (_) {} } + // Entered from the shipping view's PAY NOW button: create the invoice + // via PUT per the 1.0.4 spec. GET no longer creates invoices. Future _loadPayment() async { setState(() => _loading = true); try { final resp = await ref .read(pShopinBitService) .client - .getPayment(widget.model.apiTicketId); + .putPayment(widget.model.apiTicketId); if (!resp.hasError && resp.value != null) { _applyPaymentInfo(resp.value!); } @@ -147,7 +149,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { final resp = await ref .read(pShopinBitService) .client - .getPayment(widget.model.apiTicketId, retry: true); + .putPayment(widget.model.apiTicketId); if (!resp.hasError && resp.value != null) { _applyPaymentInfo(resp.value!); } diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index a1f9b8be81..ad695e2417 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -292,13 +292,29 @@ class ShopInBitClient { // -- Payment -- - Future> getPayment( - int ticketId, { - bool retry = false, - }) async { - final path = '/tickets/$ticketId/payment'; - final query = retry ? {'retry': 'true'} : null; - return _request('GET', path, query: query, parse: PaymentInfo.fromJson); + /// Read existing invoice state. Use this for polling, page-reload recovery, + /// and any view that just wants to show the current invoice; per ShopinBit + /// 1.0.4 this endpoint is read-only and will not create or regenerate the + /// invoice. Call [putPayment] for that. + Future> getPayment(int ticketId) async { + return _request( + 'GET', + '/tickets/$ticketId/payment', + parse: PaymentInfo.fromJson, + ); + } + + /// Create or regenerate the BTCPay invoice for [ticketId]. Per the 1.0.4 + /// spec call this only after the customer has accepted the offer, submitted + /// shipping/billing, seen the Terms & Conditions, and explicitly clicked + /// PAY NOW. Repeated calls regenerate the invoice and invalidate any in- + /// flight payment. + Future> putPayment(int ticketId) async { + return _request( + 'PUT', + '/tickets/$ticketId/payment', + parse: PaymentInfo.fromJson, + ); } // -- Vouchers -- @@ -547,6 +563,13 @@ class ShopInBitClient { body: body != null ? _asciiSafeJson(body) : null, proxyInfo: proxy, ); + case 'PUT': + return _httpClient.put( + url: uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + proxyInfo: proxy, + ); case 'PATCH': return _httpClient.patch( url: uri, From 0f51d51caa2d5ceb26e61ab5f864fa8a0208740c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 26 May 2026 17:21:04 -0500 Subject: [PATCH 03/90] fix(shopinbit): GET payment first, PUT only if no live invoice --- .../shopinbit/shopinbit_payment_view.dart | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 23afcb31c1..38895fd6f0 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -121,17 +121,31 @@ class _ShopInBitPaymentViewState extends ConsumerState { } catch (_) {} } - // Entered from the shipping view's PAY NOW button: create the invoice - // via PUT per the 1.0.4 spec. GET no longer creates invoices. + // The shipping view's PAY NOW button is the only path into this view today, + // but we still GET first per the 1.0.4 spec's "page reload recovery" + // guidance: if a live invoice already exists for this ticket, reuse it. PUT + // (which regenerates) only when GET shows there isn't one. An empty + // paymentLinks map covers all "no live invoice" cases the server returns + // (fresh ticket, expired, invalid) and a non-empty map covers everything + // worth preserving (live, paid, paid_late, processing). Future _loadPayment() async { setState(() => _loading = true); try { - final resp = await ref - .read(pShopinBitService) - .client - .putPayment(widget.model.apiTicketId); - if (!resp.hasError && resp.value != null) { - _applyPaymentInfo(resp.value!); + final client = ref.read(pShopinBitService).client; + final getResp = await client.getPayment(widget.model.apiTicketId); + PaymentInfo? info; + if (!getResp.hasError && + getResp.value != null && + getResp.value!.paymentLinks.isNotEmpty) { + info = getResp.value!; + } else { + final putResp = await client.putPayment(widget.model.apiTicketId); + if (!putResp.hasError && putResp.value != null) { + info = putResp.value!; + } + } + if (info != null) { + _applyPaymentInfo(info); } } catch (_) { // Fall back to local/dummy data From 48bfd1af6affa7e7a04c5ae6cb96b29594fc2f40 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 May 2026 07:39:57 -0600 Subject: [PATCH 04/90] chore: Log errors --- lib/pages/shopinbit/shopinbit_car_fee_view.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 8186a66585..3f2f48d086 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -254,6 +254,13 @@ class _ShopInBitCarFeeViewState extends ConsumerState { .createCarResearchInvoice(billing: billing); if (resp.hasError || resp.value == null) { + Logging.instance.e( + "Failed to create invoice", + error: resp.exception, + stackTrace: StackTrace.current, + ); + // TODO: show error dialogs so users can easily see what happened and share with support without digging through logs + if (mounted) { setState(() => _submitting = false); unawaited( @@ -293,7 +300,9 @@ class _ShopInBitCarFeeViewState extends ConsumerState { arguments: (widget.model, invoice), ), ); - } catch (e) { + } catch (e, s) { + Logging.instance.e("Create invoice failed", error: e, stackTrace: s); + // TODO: show error dialogs so users can easily see what happened and share with support without digging through logs if (mounted) { setState(() => _submitting = false); unawaited( From 571fc3250d9d61352d9b623f2036087c8b8765e8 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 May 2026 07:41:56 -0600 Subject: [PATCH 05/90] fix(ui): mobile button height/size --- lib/pages/shopinbit/shopinbit_settings_view.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index a4b36b2ab8..8580f04730 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -832,7 +832,7 @@ class _VerifyKeyDialogState extends State<_VerifyKeyDialog> { Expanded( child: SecondaryButton( label: "Cancel", - buttonHeight: ButtonHeight.l, + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, onPressed: () => Navigator.of( context, rootNavigator: Util.isDesktop, @@ -845,7 +845,7 @@ class _VerifyKeyDialogState extends State<_VerifyKeyDialog> { Expanded( child: PrimaryButton( label: "Confirm", - buttonHeight: ButtonHeight.l, + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, enabled: _confirmEnabled, onPressed: _confirmEnabled ? () => Navigator.of( From 726c21dfad4bee948b016af48e7dad059b285365 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 May 2026 07:46:36 -0600 Subject: [PATCH 06/90] fix(ui): fix keyboard covering textfield/dialog on mobile --- lib/pages/shopinbit/shopinbit_settings_view.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index 8580f04730..886ba4c8c3 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -790,6 +790,7 @@ class _VerifyKeyDialogState extends State<_VerifyKeyDialog> { child: ConditionalParent( condition: !Util.isDesktop, builder: (child) => StackDialogBase( + keyboardPaddingAmount: MediaQuery.of(context).viewInsets.bottom, child: Column( mainAxisSize: .min, children: [ From e915b025fcd58cbff35dae7d5423984944d7aab2 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 May 2026 08:24:53 -0600 Subject: [PATCH 07/90] fix(ui): more navigation and layout/styling cleanup --- lib/pages/shopinbit/shopinbit_offer_view.dart | 135 ++++++++++-------- .../shopinbit/shopinbit_shipping_view.dart | 78 +++++----- .../shopinbit/shopinbit_ticket_detail.dart | 17 +-- .../shopinbit/shopinbit_tickets_view.dart | 1 - ...sted_navigator_dialog_route_generator.dart | 28 ++++ 5 files changed, 144 insertions(+), 115 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart index 98946c14dd..544a554c21 100644 --- a/lib/pages/shopinbit/shopinbit_offer_view.dart +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -7,11 +7,12 @@ import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_shipping_view.dart'; @@ -65,6 +66,7 @@ class _ShopInBitOfferViewState extends ConsumerState { final model = widget.model; final content = Column( + mainAxisSize: .min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( @@ -124,73 +126,86 @@ class _ShopInBitOfferViewState extends ConsumerState { ], ), ), - const Spacer(), - PrimaryButton( - label: "Accept offer", - enabled: !_loading, - onPressed: () { - model.status = ShopInBitOrderStatus.accepted; - if (isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - builder: (_) => ShopInBitShippingView(model: model), - ); - } else { - Navigator.of( - context, - ).pushNamed(ShopInBitShippingView.routeName, arguments: model); - } - }, - ), - SizedBox(height: isDesktop ? 16 : 12), - SecondaryButton( - label: "Decline", - onPressed: () { - if (isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - } else { - Navigator.of(context).pop(); - } - }, + isDesktop ? const SizedBox(height: 40) : const Spacer(), + BranchedParent( + condition: isDesktop, + conditionBranchBuilder: (children) => Row( + children: [ + Expanded(child: children[1]), + const SizedBox(width: 16), + Expanded(child: children[0]), + ], + ), + otherBranchBuilder: (children) => Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [children[0], const SizedBox(height: 16), children[1]], + ), + children: [ + PrimaryButton( + label: "Accept offer", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + enabled: !_loading, + onPressed: () { + // TODO verify this is ok to stay set to accepted if the next route pops back and then decline is tapped + model.status = ShopInBitOrderStatus.accepted; + + Navigator.of( + context, + ).pushNamed(ShopInBitShippingView.routeName, arguments: model); + }, + ), + SecondaryButton( + label: "Decline", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], ), ], ); if (isDesktop) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 600, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "ShopinBit", - style: STextStyles.desktopH3(context), + return SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const .only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: Stack( + children: [ + content, + if (_loading) + const LoadingIndicator(width: 24, height: 24), + ], ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - child: Stack( - children: [ - content, - if (_loading) const LoadingIndicator(width: 24, height: 24), - ], ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index ccebcb30b1..298e293698 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -15,9 +15,9 @@ import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; import 'shopinbit_payment_view.dart'; @@ -235,21 +235,12 @@ class _ShopInBitShippingViewState extends ConsumerState { } if (!mounted) return; - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitPaymentView(model: widget.model), - ), - ); - } else { - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitPaymentView.routeName, arguments: widget.model), - ); - } + + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitPaymentView.routeName, arguments: widget.model), + ); } @override @@ -258,6 +249,7 @@ class _ShopInBitShippingViewState extends ConsumerState { final spacing = SizedBox(height: isDesktop ? 16 : 12); final content = Column( + mainAxisSize: .min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( @@ -666,34 +658,38 @@ class _ShopInBitShippingViewState extends ConsumerState { ); if (isDesktop) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 700, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "ShopinBit", - style: STextStyles.desktopH3(context), + return SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const .only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + child: SingleChildScrollView(child: content), ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - child: SingleChildScrollView(child: content), ), - ), - ], + ], + ), ), ); } diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index dc5127c573..6251f624e7 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -417,19 +417,10 @@ class _ShopInBitTicketDetailState extends ConsumerState { PrimaryButton( label: "Review offer", onPressed: () { - if (isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - - builder: (_) => ShopInBitOfferView(model: model), - ); - } else { - Navigator.of(context).pushNamed( - ShopInBitOfferView.routeName, - arguments: model, - ); - } + Navigator.of(context).pushNamed( + ShopInBitOfferView.routeName, + arguments: model, + ); }, ), ], diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index 71445aedd7..8226f81bc4 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -78,7 +78,6 @@ class _ShopInBitTicketsViewState extends ConsumerState { final model = ShopInBitOrderModel.fromDriftRow(pending); final expiresAt = pending.carResearchExpiresAt; final linksJson = pending.carResearchPaymentLinks; - final isDesktop = Util.isDesktop; if (expiresAt != null && expiresAt.isAfter(DateTime.now()) && diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart index 235aca453a..eb0fa8ef9a 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -9,7 +9,9 @@ import '../../../pages/cakepay/cakepay_orders_view.dart'; import '../../../pages/cakepay/cakepay_vendors_view.dart'; import '../../../pages/shopinbit/shopinbit_car_fee_view.dart'; import '../../../pages/shopinbit/shopinbit_car_research_payment_view.dart'; +import '../../../pages/shopinbit/shopinbit_offer_view.dart'; import '../../../pages/shopinbit/shopinbit_order_created.dart'; +import '../../../pages/shopinbit/shopinbit_shipping_view.dart'; import '../../../pages/shopinbit/shopinbit_step_1.dart'; import '../../../pages/shopinbit/shopinbit_step_2.dart'; import '../../../pages/shopinbit/shopinbit_step_3.dart'; @@ -166,6 +168,32 @@ abstract final class NestedNavigatorDialogRouteGenerator { "Expected ShopInBitOrderModel", ); + case ShopInBitOfferView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitOfferView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + + case ShopInBitShippingView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitShippingView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + case CakePayVendorsView.routeName: return getRoute( builder: (_) => const CakePayVendorsView(), From 4c5680f73928ae15d677d06b134ccd57c23bb611 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 May 2026 08:41:13 -0600 Subject: [PATCH 08/90] fix(ui): shopinbit ticket detail review offer options styling --- .../shopinbit/shopinbit_ticket_detail.dart | 78 ++++++++++++------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 6251f624e7..fa374fe3cb 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -396,34 +396,56 @@ class _ShopInBitTicketDetailState extends ConsumerState { context, ).extension()!.textFieldDefaultBG : null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Offer available", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.titleBold12(context), - ), - const SizedBox(height: 4), - Text( - "${model.offerProductName ?? 'Item'} \u2014 " - "${model.offerPrice ?? '0'} EUR", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), - ), - SizedBox(height: isDesktop ? 12 : 8), - PrimaryButton( - label: "Review offer", - onPressed: () { - Navigator.of(context).pushNamed( - ShopInBitOfferView.routeName, - arguments: model, - ); - }, - ), - ], + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => Row( + children: [ + Expanded(child: child), + PrimaryButton( + label: "Review offer", + width: 220, + buttonHeight: ButtonHeight.l, + onPressed: () { + Navigator.of(context).pushNamed( + ShopInBitOfferView.routeName, + arguments: model, + ); + }, + ), + ], + ), + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + "Offer available", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(height: 4), + Text( + "${model.offerProductName ?? 'Item'} \u2014 " + "${model.offerPrice ?? '0'} EUR", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + if (!Util.isDesktop) const SizedBox(height: 12), + if (!Util.isDesktop) + PrimaryButton( + label: "Review offer", + buttonHeight: Util.isDesktop ? ButtonHeight.l : null, + onPressed: () { + Navigator.of(context).pushNamed( + ShopInBitOfferView.routeName, + arguments: model, + ); + }, + ), + ], + ), ), ), ) From 2de3346ffda78e90de98cbde047777c388587440 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 May 2026 08:42:59 -0600 Subject: [PATCH 09/90] fix(ui): this should have been done a long time ago --- lib/widgets/rounded_white_container.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/widgets/rounded_white_container.dart b/lib/widgets/rounded_white_container.dart index a24059c8c1..46ffd5a3be 100644 --- a/lib/widgets/rounded_white_container.dart +++ b/lib/widgets/rounded_white_container.dart @@ -9,14 +9,16 @@ */ import 'package:flutter/material.dart'; + import '../themes/stack_colors.dart'; +import '../utilities/util.dart'; import 'rounded_container.dart'; class RoundedWhiteContainer extends StatelessWidget { const RoundedWhiteContainer({ super.key, this.child, - this.padding = const EdgeInsets.all(12), + this.padding, this.radiusMultiplier = 1.0, this.width, this.height, @@ -27,7 +29,7 @@ class RoundedWhiteContainer extends StatelessWidget { }); final Widget? child; - final EdgeInsets padding; + final EdgeInsets? padding; final double radiusMultiplier; final double? width; final double? height; @@ -40,7 +42,7 @@ class RoundedWhiteContainer extends StatelessWidget { Widget build(BuildContext context) { return RoundedContainer( color: Theme.of(context).extension()!.popupBG, - padding: padding, + padding: padding ?? (Util.isDesktop ? const .all(16) : const .all(12)), radiusMultiplier: radiusMultiplier, width: width, height: height, From 98c4dd9cf1fc1a8f1df0af9e8607ea84f3eae48c Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 27 May 2026 11:54:04 -0600 Subject: [PATCH 10/90] fix(ui): small nav fix --- .../nested_navigator_dialog_route_generator.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart index eb0fa8ef9a..f625a63fec 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -11,6 +11,7 @@ import '../../../pages/shopinbit/shopinbit_car_fee_view.dart'; import '../../../pages/shopinbit/shopinbit_car_research_payment_view.dart'; import '../../../pages/shopinbit/shopinbit_offer_view.dart'; import '../../../pages/shopinbit/shopinbit_order_created.dart'; +import '../../../pages/shopinbit/shopinbit_payment_view.dart'; import '../../../pages/shopinbit/shopinbit_shipping_view.dart'; import '../../../pages/shopinbit/shopinbit_step_1.dart'; import '../../../pages/shopinbit/shopinbit_step_2.dart'; @@ -194,6 +195,19 @@ abstract final class NestedNavigatorDialogRouteGenerator { "Expected ShopInBitOrderModel", ); + case ShopInBitPaymentView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitPaymentView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + case CakePayVendorsView.routeName: return getRoute( builder: (_) => const CakePayVendorsView(), From adcbf651da3ece723f0b63253e9a22d498e2f3f1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:19:42 -0500 Subject: [PATCH 11/90] refactor: extract shared payment flow --- .../shopinbit_car_research_payment_view.dart | 195 ++----------- .../shopinbit/shopinbit_payment_shared.dart | 271 ++++++++++++++++++ .../shopinbit/shopinbit_payment_view.dart | 200 ++----------- .../shopinbit/shopinbit_shipping_view.dart | 11 +- 4 files changed, 323 insertions(+), 354 deletions(-) create mode 100644 lib/pages/shopinbit/shopinbit_payment_shared.dart diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 0d3d3a14a8..40c366d616 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -1,28 +1,20 @@ import 'dart:async'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; -import '../../route_generator.dart'; import '../../services/shopinbit/src/models/car_research.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/address_utils.dart'; -import '../../utilities/amount/amount.dart'; import '../../utilities/assets.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../widgets/background.dart'; -import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; @@ -32,7 +24,7 @@ import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import '../more_view/services_view.dart'; import 'shopinbit_order_created.dart'; -import 'shopinbit_send_from_view.dart'; +import 'shopinbit_payment_shared.dart'; import 'shopinbit_tickets_view.dart'; enum _PaymentFlowState { @@ -101,78 +93,24 @@ class _ShopInBitCarResearchPaymentViewState final method = _methods[_selectedMethod]; final ticker = method.toUpperCase(); - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - - String address = ""; - Amount? amount; - EthContract? tokenContract; - - if (_currentAddress.isNotEmpty) { - final parsed = AddressUtils.parsePaymentUri(_currentAddress); - - if (parsed?.address != null && parsed!.address.isNotEmpty) { - address = parsed.address; - } else { - final raw = _currentAddress; - final colonIdx = raw.indexOf(':'); - if (colonIdx != -1) { - final afterScheme = raw.substring(colonIdx + 1); - final qIdx = afterScheme.indexOf('?'); - address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; - } else { - address = raw; - } - } - - String? amountStr = parsed?.amount; - if (amountStr == null || amountStr.isEmpty) { - final uri = Uri.tryParse(_currentAddress); - if (uri != null) { - amountStr = uri.queryParameters['amount']; - } - } - // Car research flow has no concierge PaymentInfo.due fallback. - - final int fractionDigits; - if (coin != null) { - fractionDigits = coin.fractionDigits; - } else if (ticker == "USDT") { - fractionDigits = 6; - } else { - fractionDigits = 8; - } - - if (amountStr != null && amountStr.isNotEmpty) { - try { - amount = Amount.fromDecimal( - Decimal.parse(amountStr), - fractionDigits: fractionDigits, - ); - } catch (_) {} - } - } + final target = parseShopInBitPaymentTarget( + paymentUri: _currentAddress, + ticker: ticker, + coin: AppConfig.getCryptoCurrencyForTicker(ticker), + ); - if (coin != null && address.isNotEmpty) { - _navigateToSendFrom(coin: coin, amount: amount, address: address); - return; - } + final navigated = tryNavigateToShopInBitWalletSend( + ref: ref, + context: context, + ticker: ticker, + address: target.address, + amount: target.amount, + model: widget.model, + // After the wallet send, pop back here so polling can continue. + routeOnSuccessName: ShopInBitCarResearchPaymentView.routeName, + ); - if (ticker == "USDT" && address.isNotEmpty) { - const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - tokenContract = ref.read(mainDBProvider).getEthContractSync(usdtAddress); - if (tokenContract != null) { - final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); - if (ethCoin != null) { - _navigateToSendFrom( - coin: ethCoin, - amount: amount, - address: address, - tokenContract: tokenContract, - ); - return; - } - } - } + if (navigated) return; // No compatible wallet coin found: surface an info flushbar and keep // the user on this screen so they can pay externally and then use the @@ -188,46 +126,6 @@ class _ShopInBitCarResearchPaymentViewState ); } - void _navigateToSendFrom({ - required CryptoCurrency coin, - required Amount? amount, - required String address, - EthContract? tokenContract, - }) { - if (Util.isDesktop) { - // Show send-from on top of the payment dialog, not instead of it. - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: widget.model, - shouldPopRoot: true, - tokenContract: tokenContract, - ), - ), - ); - } else { - Navigator.of(context).push( - RouteGenerator.getRoute( - shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: widget.model, - tokenContract: tokenContract, - // After wallet send, pop back to this view to continue polling. - routeOnSuccessName: ShopInBitCarResearchPaymentView.routeName, - ), - settings: const RouteSettings(name: ShopInBitSendFromView.routeName), - ), - ); - } - } - Future _checkForPayment() async { if (_flowState != _PaymentFlowState.idle) return; setState(() => _flowState = _PaymentFlowState.polling); @@ -731,26 +629,7 @@ class _ShopInBitCarResearchPaymentViewState ? _methods[_selectedMethod].toUpperCase() : ""; - bool hasWallets = false; - if (ticker == "USDT") { - const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - hasWallets = ref - .watch(pWallets) - .wallets - .any( - (w) => - w.info.coin is Ethereum && - w.info.tokenContractAddresses.contains(usdtAddress), - ); - } else { - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - if (coin != null) { - hasWallets = ref - .watch(pWallets) - .wallets - .any((e) => e.info.coin == coin); - } - } + final hasWallets = hasShopInBitWalletForTicker(ref.watch(pWallets), ticker); final methodSelector = _methods.length <= 1 ? Padding( @@ -985,41 +864,9 @@ class _ShopInBitCarResearchPaymentViewState ); } - return Background( - child: PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, dynamic result) { - if (!didPop) { - _popToTickets(); - } - }, - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton(onPressed: _popToTickets), - title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), - ), - ), - ); - }, - ), - ), - ), - ), + return ShopInBitPaymentMobileScaffold( + onBack: _popToTickets, + child: content, ); } } diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart new file mode 100644 index 0000000000..d15e22ee20 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -0,0 +1,271 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app_config.dart'; +import '../../models/isar/models/ethereum/eth_contract.dart'; +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../services/wallets.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/default_eth_tokens.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/loading_indicator.dart'; +import 'shopinbit_send_from_view.dart'; + +final String kShopInBitUsdtContractAddress = DefaultTokens.list + .firstWhere((t) => t.symbol == "USDT") + .address; + +// Address + amount pulled out of one of the API's payment_links entries. +class ShopInBitPaymentTarget { + const ShopInBitPaymentTarget({required this.address, required this.amount}); + + final String address; + final Amount? amount; +} + +// Parses a BIP21-style payment URI (or a bare address) into a destination +// address and optional Amount. `amountFallback` covers the concierge case +// where the URI itself has no amount but the API response carries one +// (PaymentInfo.due). +ShopInBitPaymentTarget parseShopInBitPaymentTarget({ + required String paymentUri, + required String ticker, + CryptoCurrency? coin, + String? amountFallback, +}) { + String address = ""; + final parsed = AddressUtils.parsePaymentUri(paymentUri); + + if (parsed?.address != null && parsed!.address.isNotEmpty) { + address = parsed.address; + } else { + final colonIdx = paymentUri.indexOf(':'); + if (colonIdx != -1) { + final afterScheme = paymentUri.substring(colonIdx + 1); + final qIdx = afterScheme.indexOf('?'); + address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; + } else { + address = paymentUri; + } + } + + String? amountStr = parsed?.amount; + if (amountStr == null || amountStr.isEmpty) { + final uri = Uri.tryParse(paymentUri); + if (uri != null) { + amountStr = uri.queryParameters['amount']; + } + } + if (amountStr == null || amountStr.isEmpty) { + amountStr = amountFallback; + } + + final int fractionDigits; + if (coin != null) { + fractionDigits = coin.fractionDigits; + } else if (ticker == "USDT") { + fractionDigits = 6; + } else { + fractionDigits = 8; + } + + Amount? amount; + if (amountStr != null && amountStr.isNotEmpty) { + try { + amount = Amount.fromDecimal( + Decimal.parse(amountStr), + fractionDigits: fractionDigits, + ); + } catch (_) {} + } + + return ShopInBitPaymentTarget(address: address, amount: amount); +} + +// True if any wallet in [wallets] can send the given upper-cased [ticker]. +// USDT is special-cased to look at Ethereum wallets' token contracts. +bool hasShopInBitWalletForTicker(Wallets wallets, String ticker) { + if (ticker == "USDT") { + return wallets.wallets.any( + (w) => + w.info.coin is Ethereum && + w.info.tokenContractAddresses.contains(kShopInBitUsdtContractAddress), + ); + } + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin == null) return false; + return wallets.wallets.any((e) => e.info.coin == coin); +} + +void _pushShopInBitSendFrom({ + required BuildContext context, + required CryptoCurrency coin, + required Amount? amount, + required String address, + required ShopInBitOrderModel model, + EthContract? tokenContract, + bool popDesktopBeforeShow = false, + String? routeOnSuccessName, +}) { + if (Util.isDesktop) { + if (popDesktopBeforeShow) { + Navigator.of(context, rootNavigator: true).pop(); + } + unawaited( + showDialog( + context: context, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: model, + shouldPopRoot: true, + tokenContract: tokenContract, + ), + ), + ); + } else { + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: model, + tokenContract: tokenContract, + routeOnSuccessName: routeOnSuccessName, + ), + settings: const RouteSettings(name: ShopInBitSendFromView.routeName), + ), + ); + } +} + +// Tries to launch the in-wallet send flow for [ticker]/[address]. Returns +// true when navigation happened. Returns false when no compatible wallet +// or token contract was found, leaving the caller to handle the +// "pay externally" path (flushbar, status change, etc). +bool tryNavigateToShopInBitWalletSend({ + required WidgetRef ref, + required BuildContext context, + required String ticker, + required String address, + required Amount? amount, + required ShopInBitOrderModel model, + bool popDesktopBeforeShow = false, + String? routeOnSuccessName, +}) { + if (address.isEmpty) return false; + + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin != null) { + _pushShopInBitSendFrom( + context: context, + coin: coin, + amount: amount, + address: address, + model: model, + popDesktopBeforeShow: popDesktopBeforeShow, + routeOnSuccessName: routeOnSuccessName, + ); + return true; + } + + if (ticker == "USDT") { + final tokenContract = ref + .read(mainDBProvider) + .getEthContractSync(kShopInBitUsdtContractAddress); + if (tokenContract != null) { + final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); + if (ethCoin != null) { + _pushShopInBitSendFrom( + context: context, + coin: ethCoin, + amount: amount, + address: address, + model: model, + tokenContract: tokenContract, + popDesktopBeforeShow: popDesktopBeforeShow, + routeOnSuccessName: routeOnSuccessName, + ); + return true; + } + } + } + + return false; +} + +// Shared mobile chrome for the two ShopInBit payment views: Background + +// PopScope (back goes through [onBack]) + AppBar + scrollable, intrinsic +// height body. Set [showLoading] to overlay a spinner. +class ShopInBitPaymentMobileScaffold extends StatelessWidget { + const ShopInBitPaymentMobileScaffold({ + super.key, + required this.onBack, + required this.child, + this.showLoading = false, + }); + + final VoidCallback onBack; + final Widget child; + final bool showLoading; + + @override + Widget build(BuildContext context) { + return Background( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (!didPop) { + onBack(); + } + }, + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton(onPressed: onBack), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: child), + ), + ), + ), + if (showLoading) + const LoadingIndicator(width: 24, height: 24), + ], + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 38895fd6f0..f9216cfffa 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -1,37 +1,30 @@ import 'dart:async'; import 'dart:io'; -import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; -import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; -import '../../route_generator.dart'; import '../../services/shopinbit/src/models/payment.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; -import '../../utilities/amount/amount.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../../widgets/background.dart'; -import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; -import 'shopinbit_send_from_view.dart'; +import 'shopinbit_payment_shared.dart'; class ShopInBitPaymentView extends ConsumerStatefulWidget { const ShopInBitPaymentView({super.key, required this.model}); @@ -255,81 +248,25 @@ class _ShopInBitPaymentViewState extends ConsumerState { final method = _methods[_selectedMethod]; final ticker = method.toUpperCase(); - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - - String address = ""; - Amount? amount; - EthContract? tokenContract; - - if (_currentAddress.isNotEmpty) { - final parsed = AddressUtils.parsePaymentUri(_currentAddress); - - if (parsed?.address != null && parsed!.address.isNotEmpty) { - address = parsed.address; - } else { - final raw = _currentAddress; - final colonIdx = raw.indexOf(':'); - if (colonIdx != -1) { - final afterScheme = raw.substring(colonIdx + 1); - final qIdx = afterScheme.indexOf('?'); - address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; - } else { - address = raw; - } - } - - String? amountStr = parsed?.amount; - if (amountStr == null || amountStr.isEmpty) { - final uri = Uri.tryParse(_currentAddress); - if (uri != null) { - amountStr = uri.queryParameters['amount']; - } - } - if (amountStr == null || amountStr.isEmpty) { - amountStr = _paymentInfo?.due; - } - - final int fractionDigits; - if (coin != null) { - fractionDigits = coin.fractionDigits; - } else if (ticker == "USDT") { - fractionDigits = 6; - } else { - fractionDigits = 8; - } - - if (amountStr != null && amountStr.isNotEmpty) { - try { - amount = Amount.fromDecimal( - Decimal.parse(amountStr), - fractionDigits: fractionDigits, - ); - } catch (_) {} - } - } + final target = parseShopInBitPaymentTarget( + paymentUri: _currentAddress, + ticker: ticker, + coin: AppConfig.getCryptoCurrencyForTicker(ticker), + amountFallback: _paymentInfo?.due, + ); - if (coin != null && address.isNotEmpty) { - _navigateToSendFrom(coin: coin, amount: amount, address: address); + if (tryNavigateToShopInBitWalletSend( + ref: ref, + context: context, + ticker: ticker, + address: target.address, + amount: target.amount, + model: widget.model, + popDesktopBeforeShow: true, + )) { return; } - if (ticker == "USDT" && address.isNotEmpty) { - const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - tokenContract = ref.read(mainDBProvider).getEthContractSync(usdtAddress); - if (tokenContract != null) { - final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); - if (ethCoin != null) { - _navigateToSendFrom( - coin: ethCoin, - amount: amount, - address: address, - tokenContract: tokenContract, - ); - return; - } - } - } - widget.model.status = ShopInBitOrderStatus.paymentPending; widget.model.paymentMethod = method; @@ -352,64 +289,6 @@ class _ShopInBitPaymentViewState extends ConsumerState { } } - void _navigateToSendFrom({ - required CryptoCurrency coin, - required Amount? amount, - required String address, - EthContract? tokenContract, - }) { - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: widget.model, - shouldPopRoot: true, - tokenContract: tokenContract, - ), - ), - ); - } else { - Navigator.of(context).push( - RouteGenerator.getRoute( - shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: widget.model, - tokenContract: tokenContract, - ), - settings: const RouteSettings(name: ShopInBitSendFromView.routeName), - ), - ); - } - } - - bool _hasWalletForTicker(String ticker) { - if (ticker == "USDT") { - const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - return ref - .read(pWallets) - .wallets - .any( - (w) => - w.info.coin is Ethereum && - w.info.tokenContractAddresses.contains(usdtAddress), - ); - } else { - final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - if (coin != null) { - return ref.read(pWallets).wallets.any((e) => e.info.coin == coin); - } - } - return false; - } - String? _parseBip21Amount(String bip21Uri) { final parsed = AddressUtils.parsePaymentUri(bip21Uri); String? amountStr = parsed?.amount; @@ -491,12 +370,13 @@ class _ShopInBitPaymentViewState extends ConsumerState { Widget build(BuildContext context) { final isDesktop = Util.isDesktop; + final wallets = ref.watch(pWallets); // Build coin rows from _methods/_addresses final coinRows = []; for (int i = 0; i < _methods.length; i++) { final ticker = _methods[i].toUpperCase(); final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - final hasWallet = _hasWalletForTicker(ticker); + final hasWallet = hasShopInBitWalletForTicker(wallets, ticker); final amountStr = _addresses[i].isNotEmpty ? _parseBip21Amount(_addresses[i]) : null; @@ -759,46 +639,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { ); } - return Background( - child: PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, dynamic result) { - if (!didPop) { - _popToTickets(); - } - }, - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton(onPressed: _popToTickets), - title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), - ), - ), - ), - if (_loading) const LoadingIndicator(width: 24, height: 24), - ], - ); - }, - ), - ), - ), - ), + return ShopInBitPaymentMobileScaffold( + onBack: _popToTickets, + showLoading: _loading, + child: content, ); } } diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 298e293698..597656f688 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -63,6 +63,10 @@ class _ShopInBitShippingViewState extends ConsumerState { List> _countries = []; String? _selectedCountryIso; bool _loadingCountries = false; + // True when we arrived with a pre-set delivery country (the normal new-order + // path). Restored-from-API orders land here with no country, so we unlock + // the dropdown only in that case. + late final bool _countryLocked; bool _submitting = false; @@ -109,6 +113,7 @@ class _ShopInBitShippingViewState extends ConsumerState { _selectedCountryIso = widget.model.deliveryCountry.isNotEmpty ? widget.model.deliveryCountry : null; + _countryLocked = _selectedCountryIso != null; for (final node in [ _nameFocusNode, @@ -341,9 +346,11 @@ class _ShopInBitShippingViewState extends ConsumerState { _countrySearchController.clear(); } }, - onChanged: null, + onChanged: (_countryLocked || _loadingCountries) + ? null + : (value) => setState(() => _selectedCountryIso = value), hint: Text( - "Country", + _loadingCountries ? "Loading countries..." : "Country", style: isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context) From 738dc1e42b1a9e3cabb9c807dcf74b8effb4fc47 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:23:33 -0500 Subject: [PATCH 12/90] fix: use CopyIcon --- .../shopinbit/shopinbit_car_research_payment_view.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 40c366d616..a7f43dced2 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -19,6 +19,7 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/qr.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; @@ -780,9 +781,9 @@ class _ShopInBitCarResearchPaymentViewState : STextStyles.itemSubtitle12(context), ), const Spacer(), - Icon( - Icons.copy, - size: 14, + CopyIcon( + width: 14, + height: 14, color: Theme.of( context, ).extension()!.accentColorBlue, From c2bd57084f35cdf551e8275b6563debb6c7c250a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:32:19 -0500 Subject: [PATCH 13/90] fix: guard against non-ETH TRON addresses --- .../shopinbit_car_research_payment_view.dart | 7 ++++- .../shopinbit/shopinbit_payment_shared.dart | 27 ++++++++++++++++--- .../shopinbit/shopinbit_payment_view.dart | 7 ++++- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index a7f43dced2..484fed7210 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -104,6 +104,7 @@ class _ShopInBitCarResearchPaymentViewState ref: ref, context: context, ticker: ticker, + paymentUri: _currentAddress, address: target.address, amount: target.amount, model: widget.model, @@ -630,7 +631,11 @@ class _ShopInBitCarResearchPaymentViewState ? _methods[_selectedMethod].toUpperCase() : ""; - final hasWallets = hasShopInBitWalletForTicker(ref.watch(pWallets), ticker); + final hasWallets = hasShopInBitWalletForTicker( + wallets: ref.watch(pWallets), + ticker: ticker, + paymentUri: _currentAddress, + ); final methodSelector = _methods.length <= 1 ? Padding( diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index d15e22ee20..fab7f89545 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -93,10 +93,29 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({ return ShopInBitPaymentTarget(address: address, amount: amount); } -// True if any wallet in [wallets] can send the given upper-cased [ticker]. -// USDT is special-cased to look at Ethereum wallets' token contracts. -bool hasShopInBitWalletForTicker(Wallets wallets, String ticker) { +// USDT exists on multiple chains (ERC-20, TRC-20, BEP-20, ...) and the +// ShopInBit API just keys the payment link as "USDT". Only treat it as +// ETH-USDT when the URI scheme is `ethereum:` or the address looks like a +// bare Ethereum hex address. Anything else (Tron, etc.) we don't support +// in-app and the user has to pay externally. +final RegExp _kEthAddressRegExp = RegExp(r'^0x[0-9a-fA-F]{40}$'); + +bool _isEthereumUsdtUri(String paymentUri) { + final trimmed = paymentUri.trim(); + if (trimmed.toLowerCase().startsWith('ethereum:')) return true; + return _kEthAddressRegExp.hasMatch(trimmed); +} + +// True if any wallet in [wallets] can send the given upper-cased [ticker] +// for the given [paymentUri]. USDT is special-cased to look at Ethereum +// wallets' token contracts, gated on the URI actually being ETH-chain. +bool hasShopInBitWalletForTicker({ + required Wallets wallets, + required String ticker, + required String paymentUri, +}) { if (ticker == "USDT") { + if (!_isEthereumUsdtUri(paymentUri)) return false; return wallets.wallets.any( (w) => w.info.coin is Ethereum && @@ -161,6 +180,7 @@ bool tryNavigateToShopInBitWalletSend({ required WidgetRef ref, required BuildContext context, required String ticker, + required String paymentUri, required String address, required Amount? amount, required ShopInBitOrderModel model, @@ -184,6 +204,7 @@ bool tryNavigateToShopInBitWalletSend({ } if (ticker == "USDT") { + if (!_isEthereumUsdtUri(paymentUri)) return false; final tokenContract = ref .read(mainDBProvider) .getEthContractSync(kShopInBitUsdtContractAddress); diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index f9216cfffa..2ade2f5d40 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -259,6 +259,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { ref: ref, context: context, ticker: ticker, + paymentUri: _currentAddress, address: target.address, amount: target.amount, model: widget.model, @@ -376,7 +377,11 @@ class _ShopInBitPaymentViewState extends ConsumerState { for (int i = 0; i < _methods.length; i++) { final ticker = _methods[i].toUpperCase(); final coin = AppConfig.getCryptoCurrencyForTicker(ticker); - final hasWallet = hasShopInBitWalletForTicker(wallets, ticker); + final hasWallet = hasShopInBitWalletForTicker( + wallets: wallets, + ticker: ticker, + paymentUri: _addresses[i], + ); final amountStr = _addresses[i].isNotEmpty ? _parseBip21Amount(_addresses[i]) : null; From cd2a1b889708e47731d87c75980f2b972dd9fe71 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:32:51 -0500 Subject: [PATCH 14/90] fix: use more SW-standard icons --- lib/pages/cakepay/cakepay_order_view.dart | 15 +++++++++------ lib/pages/cakepay/cakepay_orders_view.dart | 8 ++++++-- lib/pages/shopinbit/shopinbit_payment_view.dart | 14 ++++++++------ lib/pages/shopinbit/shopinbit_setup_view.dart | 6 +++++- lib/pages/shopinbit/shopinbit_ticket_detail.dart | 8 ++++++-- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index aa693c7d84..892d3b681c 100644 --- a/lib/pages/cakepay/cakepay_order_view.dart +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -4,6 +4,7 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../notifications/show_flush_bar.dart'; @@ -558,9 +559,10 @@ class _CakePayOrderViewState extends ConsumerState { children: [ Row( children: [ - Icon( - Icons.check_circle, - size: 20, + SvgPicture.asset( + Assets.svg.checkCircle, + width: 20, + height: 20, color: Theme.of( context, ).extension()!.accentColorGreen, @@ -622,9 +624,10 @@ class _CakePayOrderViewState extends ConsumerState { RoundedWhiteContainer( child: Row( children: [ - Icon( - Icons.cancel, - size: 20, + SvgPicture.asset( + Assets.svg.circleX, + width: 20, + height: 20, color: Theme.of( context, ).extension()!.textSubtitle1, diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index 0e476089fa..990f43cdb7 100644 --- a/lib/pages/cakepay/cakepay_orders_view.dart +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; import '../../providers/global/cakepay_orders_provider.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -135,8 +137,10 @@ class _CakePayOrdersViewState extends ConsumerState { ), ), SizedBox(width: isDesktop ? 16 : 8), - Icon( - Icons.chevron_right, + SvgPicture.asset( + Assets.svg.chevronRight, + width: 24, + height: 24, color: Theme.of( context, ).extension()!.textSubtitle1, diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 2ade2f5d40..fce38927ae 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -22,6 +22,7 @@ import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_payment_shared.dart'; @@ -342,9 +343,9 @@ class _ShopInBitPaymentViewState extends ConsumerState { ), ), const SizedBox(width: 8), - Icon( - Icons.copy, - size: 14, + CopyIcon( + width: 14, + height: 14, color: Theme.of( context, ).extension()!.accentColorBlue, @@ -437,9 +438,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { if (hasWallet) Text("PAY NOW", style: STextStyles.link2(context)) else - Icon( - Icons.info_outline, - size: 18, + SvgPicture.asset( + Assets.svg.circleInfo, + width: 18, + height: 18, color: Theme.of( context, ).extension()!.textSubtitle2, diff --git a/lib/pages/shopinbit/shopinbit_setup_view.dart b/lib/pages/shopinbit/shopinbit_setup_view.dart index 1ce525f258..b02d5f19f9 100644 --- a/lib/pages/shopinbit/shopinbit_setup_view.dart +++ b/lib/pages/shopinbit/shopinbit_setup_view.dart @@ -11,6 +11,7 @@ import '../../utilities/text_styles.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; import 'shopinbit_step_2.dart'; @@ -141,7 +142,10 @@ class _ShopInBitSetupViewState extends ConsumerState { ), ), IconButton( - icon: const Icon(Icons.copy, size: 20), + icon: const CopyIcon( + width: 20, + height: 20, + ), onPressed: () { Clipboard.setData( ClipboardData(text: key), diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index fa374fe3cb..65a0b8e196 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:intl/intl.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; @@ -12,6 +13,7 @@ import '../../providers/global/shopin_bit_orders_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/shopinbit_orders_service.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -509,8 +511,10 @@ class _ShopInBitTicketDetailState extends ConsumerState { if (!Util.isDesktop) IconButton( onPressed: _sendMessage, - icon: Icon( - Icons.send, + icon: SvgPicture.asset( + Assets.svg.send, + width: 24, + height: 24, color: Theme.of( context, ).extension()!.accentColorBlue, From 574392ab72654fe611b43983eca3409fbcd73323 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 15:56:39 -0500 Subject: [PATCH 15/90] refactor(shopinbit): await send-from navigation before returning true --- .../shopinbit_car_research_payment_view.dart | 7 ++-- .../shopinbit/shopinbit_payment_shared.dart | 42 ++++++++----------- .../shopinbit/shopinbit_payment_view.dart | 7 ++-- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 484fed7210..af854f7889 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -89,7 +89,7 @@ class _ShopInBitCarResearchPaymentViewState bool get _payNowEnabled => !_isTerminal && _flowState == _PaymentFlowState.idle; - void _confirmPayment() { + Future _confirmPayment() async { // Keep polling while the user is in the send flow. final method = _methods[_selectedMethod]; final ticker = method.toUpperCase(); @@ -100,7 +100,7 @@ class _ShopInBitCarResearchPaymentViewState coin: AppConfig.getCryptoCurrencyForTicker(ticker), ); - final navigated = tryNavigateToShopInBitWalletSend( + final navigated = await tryNavigateToShopInBitWalletSend( ref: ref, context: context, ticker: ticker, @@ -113,6 +113,7 @@ class _ShopInBitCarResearchPaymentViewState ); if (navigated) return; + if (!mounted) return; // No compatible wallet coin found: surface an info flushbar and keep // the user on this screen so they can pay externally and then use the @@ -826,7 +827,7 @@ class _ShopInBitCarResearchPaymentViewState enabled: _payNowEnabled, onPressed: _payNowEnabled ? (hasWallets - ? _confirmPayment + ? () => unawaited(_confirmPayment()) : () => unawaited(_checkForPayment())) : null, ), diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index fab7f89545..c70f2531e8 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -127,7 +125,8 @@ bool hasShopInBitWalletForTicker({ return wallets.wallets.any((e) => e.info.coin == coin); } -void _pushShopInBitSendFrom({ +// Pushes the send-from view and awaits it. +Future _pushShopInBitSendFrom({ required BuildContext context, required CryptoCurrency coin, required Amount? amount, @@ -136,26 +135,24 @@ void _pushShopInBitSendFrom({ EthContract? tokenContract, bool popDesktopBeforeShow = false, String? routeOnSuccessName, -}) { +}) async { if (Util.isDesktop) { if (popDesktopBeforeShow) { Navigator.of(context, rootNavigator: true).pop(); } - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitSendFromView( - coin: coin, - amount: amount, - address: address, - model: model, - shouldPopRoot: true, - tokenContract: tokenContract, - ), + await showDialog( + context: context, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: model, + shouldPopRoot: true, + tokenContract: tokenContract, ), ); } else { - Navigator.of(context).push( + await Navigator.of(context).push( RouteGenerator.getRoute( shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, builder: (_) => ShopInBitSendFromView( @@ -172,11 +169,8 @@ void _pushShopInBitSendFrom({ } } -// Tries to launch the in-wallet send flow for [ticker]/[address]. Returns -// true when navigation happened. Returns false when no compatible wallet -// or token contract was found, leaving the caller to handle the -// "pay externally" path (flushbar, status change, etc). -bool tryNavigateToShopInBitWalletSend({ +// Tries to launch the in-wallet send flow for [ticker]/[address]. +Future tryNavigateToShopInBitWalletSend({ required WidgetRef ref, required BuildContext context, required String ticker, @@ -186,12 +180,12 @@ bool tryNavigateToShopInBitWalletSend({ required ShopInBitOrderModel model, bool popDesktopBeforeShow = false, String? routeOnSuccessName, -}) { +}) async { if (address.isEmpty) return false; final coin = AppConfig.getCryptoCurrencyForTicker(ticker); if (coin != null) { - _pushShopInBitSendFrom( + await _pushShopInBitSendFrom( context: context, coin: coin, amount: amount, @@ -211,7 +205,7 @@ bool tryNavigateToShopInBitWalletSend({ if (tokenContract != null) { final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); if (ethCoin != null) { - _pushShopInBitSendFrom( + await _pushShopInBitSendFrom( context: context, coin: ethCoin, amount: amount, diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index fce38927ae..e876a71202 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -244,7 +244,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { } } - void _confirmPayment() { + Future _confirmPayment() async { _pollTimer?.cancel(); final method = _methods[_selectedMethod]; final ticker = method.toUpperCase(); @@ -256,7 +256,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { amountFallback: _paymentInfo?.due, ); - if (tryNavigateToShopInBitWalletSend( + if (await tryNavigateToShopInBitWalletSend( ref: ref, context: context, ticker: ticker, @@ -268,6 +268,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { )) { return; } + if (!mounted) return; widget.model.status = ShopInBitOrderStatus.paymentPending; widget.model.paymentMethod = method; @@ -306,7 +307,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { void _onOwnedCoinTap(int methodIndex) { if (!_payNowEnabled) return; _selectedMethod = methodIndex; - _confirmPayment(); + unawaited(_confirmPayment()); } void _onUnownedCoinTap(int methodIndex) { From fc57247daecf76a9bb60fd10702c5251a52826bd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 16:19:13 -0500 Subject: [PATCH 16/90] fix(ui): pre-load ShopInBit payment info instead of in-page spinner overlay --- lib/pages/shopinbit/shopinbit_offer_view.dart | 30 +-- .../shopinbit/shopinbit_payment_shared.dart | 56 ++++-- .../shopinbit/shopinbit_payment_view.dart | 184 +++++++----------- .../shopinbit/shopinbit_shipping_view.dart | 18 +- lib/route_generator.dart | 8 +- ...sted_navigator_dialog_route_generator.dart | 9 +- 6 files changed, 143 insertions(+), 162 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart index 544a554c21..64e90d1a7b 100644 --- a/lib/pages/shopinbit/shopinbit_offer_view.dart +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -13,7 +13,6 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; -import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_shipping_view.dart'; @@ -195,13 +194,7 @@ class _ShopInBitOfferViewState extends ConsumerState { bottom: 32, top: 16, ), - child: Stack( - children: [ - content, - if (_loading) - const LoadingIndicator(width: 24, height: 24), - ], - ), + child: content, ), ), ], @@ -222,21 +215,16 @@ class _ShopInBitOfferViewState extends ConsumerState { body: SafeArea( child: LayoutBuilder( builder: (context, constraints) { - return Stack( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), - ), + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, ), + child: IntrinsicHeight(child: content), ), - if (_loading) const LoadingIndicator(width: 24, height: 24), - ], + ), ); }, ), diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index c70f2531e8..bd6aade79f 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -5,8 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/shopinbit/src/models/payment.dart'; import '../../services/wallets.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; @@ -17,7 +19,6 @@ import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../widgets/loading_indicator.dart'; import 'shopinbit_send_from_view.dart'; final String kShopInBitUsdtContractAddress = DefaultTokens.list @@ -223,20 +224,45 @@ Future tryNavigateToShopInBitWalletSend({ return false; } +// Fetches the live payment info for a ticket so the caller can pass it into +// the payment view as an arg (rather than loading it after the view is up). +// GET first to reuse an existing invoice per the spec's "page reload +// recovery" guidance; PUT (which regenerates) only when GET shows none. +// Returns null on any failure so the view can fall back to polling. +Future fetchShopInBitPaymentInfo( + WidgetRef ref, + int apiTicketId, +) async { + try { + final client = ref.read(pShopinBitService).client; + final getResp = await client.getPayment(apiTicketId); + if (!getResp.hasError && + getResp.value != null && + getResp.value!.paymentLinks.isNotEmpty) { + return getResp.value; + } + final putResp = await client.putPayment(apiTicketId); + if (!putResp.hasError && putResp.value != null) { + return putResp.value; + } + } catch (_) { + // Degrade to polling-only. + } + return null; +} + // Shared mobile chrome for the two ShopInBit payment views: Background + // PopScope (back goes through [onBack]) + AppBar + scrollable, intrinsic -// height body. Set [showLoading] to overlay a spinner. +// height body. class ShopInBitPaymentMobileScaffold extends StatelessWidget { const ShopInBitPaymentMobileScaffold({ super.key, required this.onBack, required this.child, - this.showLoading = false, }); final VoidCallback onBack; final Widget child; - final bool showLoading; @override Widget build(BuildContext context) { @@ -259,22 +285,16 @@ class ShopInBitPaymentMobileScaffold extends StatelessWidget { body: SafeArea( child: LayoutBuilder( builder: (context, constraints) { - return Stack( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: child), - ), + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, ), + child: IntrinsicHeight(child: child), ), - if (showLoading) - const LoadingIndicator(width: 24, height: 24), - ], + ), ); }, ), diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index e876a71202..589e6f2470 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -16,6 +16,7 @@ import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/assets.dart'; +import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/desktop/desktop_dialog.dart'; @@ -23,24 +24,30 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/icon_widgets/copy_icon.dart'; -import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_payment_shared.dart'; class ShopInBitPaymentView extends ConsumerStatefulWidget { - const ShopInBitPaymentView({super.key, required this.model}); + const ShopInBitPaymentView({ + super.key, + required this.model, + this.initialPaymentInfo, + }); static const String routeName = "/shopInBitPayment"; final ShopInBitOrderModel model; + // Pre-loaded by the caller (see fetchShopInBitPaymentInfo) so the view can + // render populated immediately instead of fetching after it's pushed. + final PaymentInfo? initialPaymentInfo; + @override ConsumerState createState() => _ShopInBitPaymentViewState(); } class _ShopInBitPaymentViewState extends ConsumerState { - bool _loading = false; int _selectedMethod = 0; Timer? _pollTimer; @@ -72,8 +79,13 @@ class _ShopInBitPaymentViewState extends ConsumerState { @override void initState() { super.initState(); + if (widget.initialPaymentInfo != null) { + _applyPaymentInfo(widget.initialPaymentInfo!); + } + // Poll even when the pre-load returned null so the view can still recover + // a live invoice on its own. if (widget.model.apiTicketId != 0) { - _loadPayment(); + _startPolling(); } } @@ -115,132 +127,80 @@ class _ShopInBitPaymentViewState extends ConsumerState { } catch (_) {} } - // The shipping view's PAY NOW button is the only path into this view today, - // but we still GET first per the 1.0.4 spec's "page reload recovery" - // guidance: if a live invoice already exists for this ticket, reuse it. PUT - // (which regenerates) only when GET shows there isn't one. An empty - // paymentLinks map covers all "no live invoice" cases the server returns - // (fresh ticket, expired, invalid) and a non-empty map covers everything - // worth preserving (live, paid, paid_late, processing). - Future _loadPayment() async { - setState(() => _loading = true); - try { - final client = ref.read(pShopinBitService).client; - final getResp = await client.getPayment(widget.model.apiTicketId); - PaymentInfo? info; - if (!getResp.hasError && - getResp.value != null && - getResp.value!.paymentLinks.isNotEmpty) { - info = getResp.value!; - } else { - final putResp = await client.putPayment(widget.model.apiTicketId); - if (!putResp.hasError && putResp.value != null) { - info = putResp.value!; - } - } - if (info != null) { - _applyPaymentInfo(info); - } - } catch (_) { - // Fall back to local/dummy data - } finally { - if (mounted) { - setState(() => _loading = false); - _startPolling(); - } - } - } - Future _refreshInvoice() async { - setState(() => _loading = true); - try { - final resp = await ref + _pollTimer?.cancel(); + final resp = await showLoading( + whileFuture: ref .read(pShopinBitService) .client - .putPayment(widget.model.apiTicketId); - if (!resp.hasError && resp.value != null) { - _applyPaymentInfo(resp.value!); - } - } catch (_) {} - if (mounted) { - setState(() => _loading = false); - _startPolling(); + .putPayment(widget.model.apiTicketId), + context: context, + message: "Refreshing invoice", + ); + if (!mounted) return; + if (resp != null && !resp.hasError && resp.value != null) { + setState(() => _applyPaymentInfo(resp.value!)); } + _startPolling(); } Future _checkForPayment() async { _pollTimer?.cancel(); - setState(() => _loading = true); - try { - final resp = await ref + final resp = await showLoading( + whileFuture: ref .read(pShopinBitService) .client - .getPayment(widget.model.apiTicketId); - if (!resp.hasError && resp.value != null && mounted) { - setState(() => _applyPaymentInfo(resp.value!)); - final status = resp.value!.status; - if (const { - 'paid', - 'paid_over', - 'paid_late', - 'payment_processing', - }.contains(status)) { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Payment received!", - context: context, - ), - ); - } - } else if (status == 'underpaid') { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.", - context: context, - ), - ); - } - } else { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "No payment detected yet.", - context: context, - ), - ); - } - } - } else if (mounted) { + .getPayment(widget.model.apiTicketId), + context: context, + message: "Checking for payment", + ); + if (!mounted) return; + + if (resp != null && !resp.hasError && resp.value != null) { + setState(() => _applyPaymentInfo(resp.value!)); + final status = resp.value!.status; + if (const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + }.contains(status)) { unawaited( showFloatingFlushBar( - type: FlushBarType.warning, - message: resp.exception?.message ?? "Failed to check payment.", + type: FlushBarType.success, + message: "Payment received!", context: context, ), ); - } - } catch (e) { - if (mounted) { + } else if (status == 'underpaid') { unawaited( showFloatingFlushBar( type: FlushBarType.warning, - message: e.toString(), + message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.", + context: context, + ), + ); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "No payment detected yet.", context: context, ), ); } - } finally { - if (mounted) { - setState(() => _loading = false); - if (!_isTerminal) { - _startPolling(); - } - } + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: resp?.exception?.message ?? "Failed to check payment.", + context: context, + ), + ); + } + + if (!_isTerminal) { + _startPolling(); } } @@ -634,12 +594,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { horizontal: 32, vertical: 8, ), - child: Stack( - children: [ - SingleChildScrollView(child: content), - if (_loading) const LoadingIndicator(width: 24, height: 24), - ], - ), + child: SingleChildScrollView(child: content), ), ), ], @@ -649,7 +604,6 @@ class _ShopInBitPaymentViewState extends ConsumerState { return ShopInBitPaymentMobileScaffold( onBack: _popToTickets, - showLoading: _loading, child: content, ); } diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 597656f688..4dc567de5d 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -8,6 +8,7 @@ import 'package:flutter_svg/svg.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/address.dart'; +import '../../services/shopinbit/src/models/payment.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; @@ -19,6 +20,7 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; +import 'shopinbit_payment_shared.dart'; import 'shopinbit_payment_view.dart'; class ShopInBitShippingView extends ConsumerStatefulWidget { @@ -186,6 +188,10 @@ class _ShopInBitShippingViewState extends ConsumerState { country: country, ); + // Pre-load the payment info before pushing the payment view so it renders + // populated immediately. The Continue button's spinner (_submitting) + // already covers this wait. + PaymentInfo? paymentInfo; if (widget.model.apiTicketId != 0) { setState(() => _submitting = true); try { @@ -232,6 +238,11 @@ class _ShopInBitShippingViewState extends ConsumerState { // Sandbox may fail here; continue anyway. debugPrint("submitAddress failed: ${resp.exception?.message}"); } + + paymentInfo = await fetchShopInBitPaymentInfo( + ref, + widget.model.apiTicketId, + ); } catch (e) { debugPrint("submitAddress threw: $e"); } finally { @@ -242,9 +253,10 @@ class _ShopInBitShippingViewState extends ConsumerState { if (!mounted) return; unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitPaymentView.routeName, arguments: widget.model), + Navigator.of(context).pushNamed( + ShopInBitPaymentView.routeName, + arguments: (widget.model, paymentInfo), + ), ); } diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 2a42a8af7c..df03fd5811 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -262,6 +262,7 @@ import 'services/cakepay/src/models/order.dart'; import 'services/event_bus/events/global/node_connection_status_changed_event.dart'; import 'services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'services/shopinbit/src/models/car_research.dart'; +import 'services/shopinbit/src/models/payment.dart'; import 'utilities/amount/amount.dart'; import 'utilities/enums/add_wallet_type_enum.dart'; import 'wallets/crypto_currency/crypto_currency.dart'; @@ -1258,10 +1259,13 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitPaymentView.routeName: - if (args is ShopInBitOrderModel) { + if (args is (ShopInBitOrderModel, PaymentInfo?)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitPaymentView(model: args), + builder: (_) => ShopInBitPaymentView( + model: args.$1, + initialPaymentInfo: args.$2, + ), settings: RouteSettings(name: settings.name), ); } diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart index f625a63fec..e5561b4e21 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -196,16 +196,19 @@ abstract final class NestedNavigatorDialogRouteGenerator { ); case ShopInBitPaymentView.routeName: - if (args is ShopInBitOrderModel) { + if (args is (ShopInBitOrderModel, PaymentInfo?)) { return getRoute( - builder: (_) => ShopInBitPaymentView(model: args), + builder: (_) => ShopInBitPaymentView( + model: args.$1, + initialPaymentInfo: args.$2, + ), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected (ShopInBitOrderModel, PaymentInfo?)", ); case CakePayVendorsView.routeName: From b4cb894985abd1619b3a18318d6053ce1a8b6ff9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 16:33:22 -0500 Subject: [PATCH 17/90] fix(shopinbit): don't pop the whole nav stack when PAY NOW has no address Auto stash before rebase of "josh/fixes" --- .../shopinbit/shopinbit_payment_view.dart | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 589e6f2470..6ea6d7f8f0 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -82,13 +82,26 @@ class _ShopInBitPaymentViewState extends ConsumerState { if (widget.initialPaymentInfo != null) { _applyPaymentInfo(widget.initialPaymentInfo!); } - // Poll even when the pre-load returned null so the view can still recover - // a live invoice on its own. if (widget.model.apiTicketId != 0) { - _startPolling(); + // If the pre-load didn't hand us usable payment links, recover them: + // GET, then PUT to generate one. + if (_addresses.every((a) => a.isEmpty)) { + unawaited(_recoverPaymentInfo()); + } else { + _startPolling(); + } } } + Future _recoverPaymentInfo() async { + final info = await fetchShopInBitPaymentInfo(ref, widget.model.apiTicketId); + if (!mounted) return; + if (info != null) { + setState(() => _applyPaymentInfo(info)); + } + _startPolling(); + } + @override void dispose() { _pollTimer?.cancel(); @@ -230,13 +243,18 @@ class _ShopInBitPaymentViewState extends ConsumerState { } if (!mounted) return; - widget.model.status = ShopInBitOrderStatus.paymentPending; - widget.model.paymentMethod = method; - - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - } else { - Navigator.of(context).popUntil((route) => route.isFirst); + // Couldn't launch the in-wallet send. + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Payment details for $ticker aren't ready yet. " + "Please wait a moment or refresh the invoice.", + context: context, + ), + ); + if (!_isTerminal) { + _startPolling(); } } From 9f558ea23dafe31cb66d7b33e285ca32efbc3700 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 16:58:35 -0500 Subject: [PATCH 18/90] fix(shopinbit): keep delivery country consistent in shipping view --- .../shopinbit/shopinbit_shipping_view.dart | 301 +++++++++++------- 1 file changed, 188 insertions(+), 113 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 4dc567de5d..344ebd8560 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -187,6 +187,11 @@ class _ShopInBitShippingViewState extends ConsumerState { postalCode: postalCode, country: country, ); + // Keep deliveryCountry authoritative and in sync with the shipping + // country. No-op when it was already set (the normal flow); fills the gap + // for restored orders, where deliveryCountry came back empty from the API + // and the user picked one here. + widget.model.deliveryCountry = country; // Pre-load the payment info before pushing the payment view so it renders // populated immediately. The Continue button's spinner (_submitting) @@ -260,6 +265,171 @@ class _ShopInBitShippingViewState extends ConsumerState { ); } + // Read-only display of the locked delivery country. Looks like the other + // fields but isn't editable; the country was fixed when the offer was priced. + Widget _buildLockedCountryField( + BuildContext context, { + required bool isDesktop, + }) { + final label = + _countries + .where((c) => c['iso'] == _selectedCountryIso) + .map((c) => c['label'] as String) + .firstOrNull ?? + (_selectedCountryIso ?? ""); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Country", + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + ) + : STextStyles.fieldLabel(context), + ), + Text( + label, + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + ) + : STextStyles.w500_14(context), + ), + ], + ), + ); + } + + // Editable, searchable country dropdown. Only shown when the delivery country + // wasn't pre-set (restored-from-API orders). + Widget _buildCountryDropdown( + BuildContext context, { + required bool isDesktop, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), + child: DropdownButtonHideUnderline( + child: DropdownButton2( + value: _selectedCountryIso, + items: _countries + .map( + (c) => DropdownMenuItem( + value: c['iso'] as String, + child: Text( + c['label'] as String, + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + ) + : STextStyles.w500_14(context), + ), + ), + ) + .toList(), + onMenuStateChange: (isOpen) { + if (!isOpen) { + _countrySearchController.clear(); + } + }, + onChanged: _loadingCountries + ? null + : (value) => setState(() => _selectedCountryIso = value), + hint: Text( + _loadingCountries ? "Loading countries..." : "Country", + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + ) + : STextStyles.fieldLabel(context), + ), + isExpanded: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, 0), + elevation: 0, + maxHeight: 300, + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + ), + dropdownSearchData: DropdownSearchData( + searchController: _countrySearchController, + searchInnerWidgetHeight: 48, + searchInnerWidget: TextFormField( + controller: _countrySearchController, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + hintText: "Search...", + hintStyle: STextStyles.fieldLabel(context), + border: InputBorder.none, + ), + ), + searchMatchFn: (item, searchValue) { + final label = _countries + .where((c) => c['iso'] == item.value) + .map((c) => c['label'] as String) + .firstOrNull; + return label?.toLowerCase().contains(searchValue.toLowerCase()) ?? + false; + }, + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; @@ -327,120 +497,25 @@ class _ShopInBitShippingViewState extends ConsumerState { ], ), spacing, - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: _selectedCountryIso, - items: _countries - .map( - (c) => DropdownMenuItem( - value: c['iso'] as String, - child: Text( - c['label'] as String, - style: isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ), - ) - .toList(), - onMenuStateChange: (isOpen) { - if (!isOpen) { - _countrySearchController.clear(); - } - }, - onChanged: (_countryLocked || _loadingCountries) - ? null - : (value) => setState(() => _selectedCountryIso = value), - hint: Text( - _loadingCountries ? "Loading countries..." : "Country", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - maxHeight: 300, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - dropdownSearchData: DropdownSearchData( - searchController: _countrySearchController, - searchInnerWidgetHeight: 48, - searchInnerWidget: TextFormField( - controller: _countrySearchController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - hintText: "Search...", - hintStyle: STextStyles.fieldLabel(context), - border: InputBorder.none, - ), - ), - searchMatchFn: (item, searchValue) { - final label = _countries - .where((c) => c['iso'] == item.value) - .map((c) => c['label'] as String) - .firstOrNull; - return label?.toLowerCase().contains( - searchValue.toLowerCase(), - ) ?? - false; - }, - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), + // The delivery country was chosen when the offer was requested and the + // price (incl. shipping + VAT) was calculated from it, so it can't be + // changed here. Restored-from-API orders are the exception: they come + // back with no country, so we let the user supply one (and warn that it + // may not match what the offer was priced for). + if (_countryLocked) + _buildLockedCountryField(context, isDesktop: isDesktop) + else ...[ + _buildCountryDropdown(context, isDesktop: isDesktop), + SizedBox(height: isDesktop ? 8 : 6), + Text( + "This order was started on another device. Choosing a country " + "here may not match the delivery destination the offer was " + "priced for.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), ), - ), + ], spacing, // Billing address toggle. GestureDetector( From 1659182d6d7afc8dc0ea05efcd4684b075609f0e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 27 May 2026 17:42:15 -0500 Subject: [PATCH 19/90] fix(shopinbit): render locked country as disabled text field --- .../shopinbit/shopinbit_shipping_view.dart | 49 ++++--------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 344ebd8560..d62e9b4326 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -16,6 +16,7 @@ import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/detail_item.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; @@ -265,12 +266,9 @@ class _ShopInBitShippingViewState extends ConsumerState { ); } - // Read-only display of the locked delivery country. Looks like the other - // fields but isn't editable; the country was fixed when the offer was priced. - Widget _buildLockedCountryField( - BuildContext context, { - required bool isDesktop, - }) { + // Read-only display of the locked delivery country: it was fixed when the + // offer was priced and can't change here. + Widget _buildLockedCountryField() { final label = _countries .where((c) => c['iso'] == _selectedCountryIso) @@ -278,39 +276,10 @@ class _ShopInBitShippingViewState extends ConsumerState { .firstOrNull ?? (_selectedCountryIso ?? ""); - return Container( - decoration: BoxDecoration( - color: Theme.of(context).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Country", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - Text( - label, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ], - ), + return DetailItem( + title: "Country", + detail: label, + disableSelectableText: true, ); } @@ -503,7 +472,7 @@ class _ShopInBitShippingViewState extends ConsumerState { // back with no country, so we let the user supply one (and warn that it // may not match what the offer was priced for). if (_countryLocked) - _buildLockedCountryField(context, isDesktop: isDesktop) + _buildLockedCountryField() else ...[ _buildCountryDropdown(context, isDesktop: isDesktop), SizedBox(height: isDesktop ? 8 : 6), From cf0b4437db71fc0537767ac96eebf535ee79a350 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 12:45:17 -0500 Subject: [PATCH 20/90] fix(shopinbit): show payment-check API errors as a blocking dialog --- lib/pages/shopinbit/shopinbit_payment_view.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 6ea6d7f8f0..af80536f10 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -25,6 +25,7 @@ import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import 'shopinbit_payment_shared.dart'; class ShopInBitPaymentView extends ConsumerStatefulWidget { @@ -83,7 +84,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { _applyPaymentInfo(widget.initialPaymentInfo!); } if (widget.model.apiTicketId != 0) { - // If the pre-load didn't hand us usable payment links, recover them: + // If the pre-load didn't hand us usable payment links, recover them: // GET, then PUT to generate one. if (_addresses.every((a) => a.isEmpty)) { unawaited(_recoverPaymentInfo()); @@ -203,13 +204,17 @@ class _ShopInBitPaymentViewState extends ConsumerState { ); } } else { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: resp?.exception?.message ?? "Failed to check payment.", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to check payment", + maxWidth: Util.isDesktop ? 500 : null, + message: resp?.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); + if (!mounted) return; } if (!_isTerminal) { From e691f22b3cf9e3628f5d178e262d86d145840bc7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 13:22:51 -0500 Subject: [PATCH 21/90] fix(shopinbit): show car research payment processing errors as a dialog --- .../shopinbit_car_research_payment_view.dart | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index af854f7889..349b045ea3 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -396,11 +396,14 @@ class _ShopInBitCarResearchPaymentViewState } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to submit car research request", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -419,11 +422,14 @@ class _ShopInBitCarResearchPaymentViewState if (logResp.hasError || logResp.value == null) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: logResp.exception?.message ?? "Failed to log payment", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to log car research payment", + maxWidth: Util.isDesktop ? 500 : null, + message: logResp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -520,11 +526,14 @@ class _ShopInBitCarResearchPaymentViewState } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to process car research payment", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } From d1e0a72a7371a12ab8684c9fddb2f3a69f63c965 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 14:01:08 -0500 Subject: [PATCH 22/90] fix(shopinbit): show car research request retry errors as a dialog --- .../shopinbit_car_research_payment_view.dart | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 349b045ea3..fc24f8c43f 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -566,11 +566,14 @@ class _ShopInBitCarResearchPaymentViewState if (reqResp.hasError || reqResp.value == null) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: reqResp.exception?.message ?? "Retry failed", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Retry failed", + maxWidth: Util.isDesktop ? 500 : null, + message: reqResp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -608,11 +611,14 @@ class _ShopInBitCarResearchPaymentViewState } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Retry failed", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } From 086831356d80e0cf483af4203f6e3d9afbda44e2 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 14:38:42 -0500 Subject: [PATCH 23/90] fix(shopinbit): show car research invoice errors as a dialog --- .../shopinbit/shopinbit_car_fee_view.dart | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 3f2f48d086..8b4b2805b6 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -25,6 +25,7 @@ import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; import '../more_view/services_view.dart'; import 'shopinbit_car_research_payment_view.dart'; @@ -259,15 +260,17 @@ class _ShopInBitCarFeeViewState extends ConsumerState { error: resp.exception, stackTrace: StackTrace.current, ); - // TODO: show error dialogs so users can easily see what happened and share with support without digging through logs if (mounted) { setState(() => _submitting = false); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: resp.exception?.message ?? "Failed to create invoice", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create invoice", + maxWidth: Util.isDesktop ? 500 : null, + message: resp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -302,14 +305,16 @@ class _ShopInBitCarFeeViewState extends ConsumerState { ); } catch (e, s) { Logging.instance.e("Create invoice failed", error: e, stackTrace: s); - // TODO: show error dialogs so users can easily see what happened and share with support without digging through logs if (mounted) { setState(() => _submitting = false); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create invoice", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } From c3e5340ccba5b2100f9368d5358c4f57a41f0cb3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 15:16:05 -0500 Subject: [PATCH 24/90] fix(shopinbit): show customer key generation errors as a dialog --- .../shopinbit/shopinbit_settings_view.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index 886ba4c8c3..d9574f6978 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -121,16 +121,21 @@ class _ShopInBitSettingsViewState extends ConsumerState { } } catch (e) { if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to generate key: $e", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to generate key", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, ), ); } } finally { - setState(() => _loading = false); + // Awaiting the error dialog above means the widget can unmount before + // we get here. + if (mounted) setState(() => _loading = false); } } From bc567af6266d50708770a005e946d50e3e0dded8 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 15:53:29 -0500 Subject: [PATCH 25/90] fix(shopinbit): show manual customer key set errors as a dialog --- .../shopinbit/shopinbit_settings_view.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index d9574f6978..bb92bcd9a1 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -166,16 +166,21 @@ class _ShopInBitSettingsViewState extends ConsumerState { } } catch (e) { if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to set key: $e", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to set key", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, ), ); } } finally { - setState(() => _loading = false); + // Awaiting the error dialog above means the widget can unmount before + // we get here. + if (mounted) setState(() => _loading = false); } } From 05d6f8241bda7e2ac7f5450127a340ccc9e44817 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 16:31:44 -0500 Subject: [PATCH 26/90] fix(shopinbit): show ticket retry request errors as a dialog --- .../shopinbit/shopinbit_ticket_detail.dart | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 65a0b8e196..597c8bbc52 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -25,6 +25,7 @@ import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/refresh_control.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import 'shopinbit_offer_view.dart'; class ShopInBitTicketDetail extends ConsumerStatefulWidget { @@ -139,11 +140,14 @@ class _ShopInBitTicketDetailState extends ConsumerState { if (reqResp.hasError || reqResp.value == null) { if (mounted) { setState(() => _retrying = false); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: reqResp.exception?.message ?? "Failed to create request", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, + message: reqResp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -183,11 +187,14 @@ class _ShopInBitTicketDetailState extends ConsumerState { } catch (e) { if (mounted) { setState(() => _retrying = false); - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, message: e.toString(), - context: context, + desktopPopRootNavigator: Util.isDesktop, ), ); } From c15dae48119d561de79780291ff65835d0e29862 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 17:08:22 -0500 Subject: [PATCH 27/90] fix(shopinbit): show step 4 submit errors as a dialog --- .../shopinbit_step4_submit.dart | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart index 9e0eedae68..ed95f8c99b 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart @@ -4,8 +4,9 @@ import "package:flutter/material.dart"; import "../../../db/drift/shared_db/shared_database.dart"; import "../../../models/shopinbit/shopinbit_order_model.dart"; -import "../../../notifications/show_flush_bar.dart"; import "../../../services/shopinbit/shopinbit_service.dart"; +import "../../../utilities/util.dart"; +import "../../../widgets/stack_dialog.dart"; import "../shopinbit_order_created.dart"; /// Submits a ShopinBit request to the API and navigates to the order-created @@ -48,11 +49,14 @@ Future submitShopInBitRequest( if (resp.hasError) { if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: resp.exception?.message ?? "Failed to create request", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, + message: resp.exception?.message, + desktopPopRootNavigator: Util.isDesktop, ), ); } @@ -77,11 +81,14 @@ Future submitShopInBitRequest( ); } catch (e) { if (context.mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to create request: $e", - context: context, + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Failed to create request", + maxWidth: Util.isDesktop ? 500 : null, + message: e.toString(), + desktopPopRootNavigator: Util.isDesktop, ), ); } From 48de9d073833c53cd776a370ae9124f9cc693645 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 17:45:53 -0500 Subject: [PATCH 28/90] fix(cakepay): show missing-payment-data errors as a dialog --- lib/pages/cakepay/cakepay_order_view.dart | 29 ++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index 892d3b681c..1f5cead0f5 100644 --- a/lib/pages/cakepay/cakepay_order_view.dart +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -28,6 +28,7 @@ import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/qr.dart'; import '../../widgets/refresh_control.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import '../wallet_view/transaction_views/transaction_details_view.dart'; import 'cakepay_send_from_view.dart'; @@ -174,19 +175,31 @@ class _CakePayOrderViewState extends ConsumerState { final coin = _resolveCoin(option.ticker); if (option.address.trim().isEmpty) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: "No payment address available for $label", - context: context, + unawaited( + showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "No payment address available for $label", + maxWidth: Util.isDesktop ? 500 : null, + desktopPopRootNavigator: Util.isDesktop, + ), + ), ); return; } if (coin == null) { - showFloatingFlushBar( - type: FlushBarType.warning, - message: "No wallet support for $label", - context: context, + unawaited( + showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "No wallet support for $label", + maxWidth: Util.isDesktop ? 500 : null, + desktopPopRootNavigator: Util.isDesktop, + ), + ), ); return; } From 13144c21c4e118179c6cf753cc1cf2af6f9bd5aa Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 18:23:16 -0500 Subject: [PATCH 29/90] chore(shopinbit): drop unused show_flush_bar import from car fee view --- lib/pages/shopinbit/shopinbit_car_fee_view.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 8b4b2805b6..d6741e3632 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../notifications/show_flush_bar.dart'; import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/address.dart'; From 9335dd5f00a28794ba67821b9e9902f20d258408 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 28 May 2026 18:58:54 -0500 Subject: [PATCH 30/90] fix(shopinbit): require a live invoice before opening the payment view --- .../shopinbit/shopinbit_payment_view.dart | 47 ++---- .../shopinbit/shopinbit_shipping_view.dart | 140 +++++++++++------- lib/route_generator.dart | 4 +- ...sted_navigator_dialog_route_generator.dart | 6 +- 4 files changed, 105 insertions(+), 92 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index af80536f10..8f4105c9ea 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -32,16 +32,15 @@ class ShopInBitPaymentView extends ConsumerStatefulWidget { const ShopInBitPaymentView({ super.key, required this.model, - this.initialPaymentInfo, + required this.paymentInfo, }); static const String routeName = "/shopInBitPayment"; final ShopInBitOrderModel model; - // Pre-loaded by the caller (see fetchShopInBitPaymentInfo) so the view can - // render populated immediately instead of fetching after it's pushed. - final PaymentInfo? initialPaymentInfo; + // Caller loads this before pushing, so we always open with usable addresses. + final PaymentInfo paymentInfo; @override ConsumerState createState() => @@ -80,27 +79,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { @override void initState() { super.initState(); - if (widget.initialPaymentInfo != null) { - _applyPaymentInfo(widget.initialPaymentInfo!); - } + _applyPaymentInfo(widget.paymentInfo); if (widget.model.apiTicketId != 0) { - // If the pre-load didn't hand us usable payment links, recover them: - // GET, then PUT to generate one. - if (_addresses.every((a) => a.isEmpty)) { - unawaited(_recoverPaymentInfo()); - } else { - _startPolling(); - } - } - } - - Future _recoverPaymentInfo() async { - final info = await fetchShopInBitPaymentInfo(ref, widget.model.apiTicketId); - if (!mounted) return; - if (info != null) { - setState(() => _applyPaymentInfo(info)); + _startPolling(); } - _startPolling(); } @override @@ -289,6 +271,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { void _onOwnedCoinTap(int methodIndex) { if (!_payNowEnabled) return; + if (_addresses[methodIndex].isEmpty) return; _selectedMethod = methodIndex; unawaited(_confirmPayment()); } @@ -362,14 +345,14 @@ class _ShopInBitPaymentViewState extends ConsumerState { for (int i = 0; i < _methods.length; i++) { final ticker = _methods[i].toUpperCase(); final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + final hasAddress = _addresses[i].isNotEmpty; final hasWallet = hasShopInBitWalletForTicker( wallets: wallets, ticker: ticker, paymentUri: _addresses[i], ); - final amountStr = _addresses[i].isNotEmpty - ? _parseBip21Amount(_addresses[i]) - : null; + final canPayNow = hasWallet && hasAddress; + final amountStr = hasAddress ? _parseBip21Amount(_addresses[i]) : null; if (i > 0) { coinRows.add(const SizedBox(height: 8)); @@ -378,11 +361,13 @@ class _ShopInBitPaymentViewState extends ConsumerState { coinRows.add( RoundedWhiteContainer( child: Opacity( - opacity: hasWallet ? 1.0 : 0.5, + opacity: canPayNow ? 1.0 : 0.5, child: InkWell( - onTap: hasWallet - ? () => _onOwnedCoinTap(i) - : () => _onUnownedCoinTap(i), + onTap: !hasAddress + ? null + : (hasWallet + ? () => _onOwnedCoinTap(i) + : () => _onUnownedCoinTap(i)), child: Row( children: [ if (coin != null) @@ -419,7 +404,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { ], ), ), - if (hasWallet) + if (canPayNow) Text("PAY NOW", style: STextStyles.link2(context)) else SvgPicture.asset( diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index d62e9b4326..88be18d18e 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -20,6 +20,7 @@ import '../../widgets/detail_item.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/stack_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; import 'shopinbit_payment_shared.dart'; import 'shopinbit_payment_view.dart'; @@ -194,70 +195,84 @@ class _ShopInBitShippingViewState extends ConsumerState { // and the user picked one here. widget.model.deliveryCountry = country; - // Pre-load the payment info before pushing the payment view so it renders - // populated immediately. The Continue button's spinner (_submitting) - // already covers this wait. + // The payment view needs a live invoice, so load it here and only navigate + // once we have usable payment links. + if (widget.model.apiTicketId == 0) { + // No ticket, nothing to invoice. + await _showPaymentLoadError( + "This request isn't ready for payment yet. Please try again.", + ); + return; + } + PaymentInfo? paymentInfo; - if (widget.model.apiTicketId != 0) { - setState(() => _submitting = true); - try { - // Split name into first/last - final parts = name.split(' '); - final firstName = parts.first; - final lastName = parts.length > 1 ? parts.sublist(1).join(' ') : ''; - - Address? billingAddress; - if (_differentBilling) { - final billingName = _billingNameController.text.trim(); - final billingParts = billingName.split(' '); - final billingFirst = billingParts.first; - final billingLast = billingParts.length > 1 - ? billingParts.sublist(1).join(' ') - : ''; - billingAddress = Address( - firstName: billingFirst, - lastName: billingLast, - street: _billingStreetController.text.trim(), - zip: _billingPostalCodeController.text.trim(), - city: _billingCityController.text.trim(), - country: _billingSelectedCountryIso!, - ); - } - - final resp = await ref - .read(pShopinBitService) - .client - .submitAddress( - widget.model.apiTicketId, - shipping: Address( - firstName: firstName, - lastName: lastName, - street: street, - zip: postalCode, - city: city, - country: country, - ), - billing: billingAddress, - ); + setState(() => _submitting = true); + try { + // Split name into first/last + final parts = name.split(' '); + final firstName = parts.first; + final lastName = parts.length > 1 ? parts.sublist(1).join(' ') : ''; + + Address? billingAddress; + if (_differentBilling) { + final billingName = _billingNameController.text.trim(); + final billingParts = billingName.split(' '); + final billingFirst = billingParts.first; + final billingLast = billingParts.length > 1 + ? billingParts.sublist(1).join(' ') + : ''; + billingAddress = Address( + firstName: billingFirst, + lastName: billingLast, + street: _billingStreetController.text.trim(), + zip: _billingPostalCodeController.text.trim(), + city: _billingCityController.text.trim(), + country: _billingSelectedCountryIso!, + ); + } - if (resp.hasError) { - // Sandbox may fail here; continue anyway. - debugPrint("submitAddress failed: ${resp.exception?.message}"); - } + final resp = await ref + .read(pShopinBitService) + .client + .submitAddress( + widget.model.apiTicketId, + shipping: Address( + firstName: firstName, + lastName: lastName, + street: street, + zip: postalCode, + city: city, + country: country, + ), + billing: billingAddress, + ); - paymentInfo = await fetchShopInBitPaymentInfo( - ref, - widget.model.apiTicketId, - ); - } catch (e) { - debugPrint("submitAddress threw: $e"); - } finally { - if (mounted) setState(() => _submitting = false); + if (resp.hasError) { + // Sandbox may fail here; continue anyway. + debugPrint("submitAddress failed: ${resp.exception?.message}"); } + + paymentInfo = await fetchShopInBitPaymentInfo( + ref, + widget.model.apiTicketId, + ); + } catch (e) { + debugPrint("submitAddress threw: $e"); + } finally { + if (mounted) setState(() => _submitting = false); } if (!mounted) return; + if (paymentInfo == null || paymentInfo.paymentLinks.isEmpty) { + // No live invoice; don't open a payment view with empty addresses. + await _showPaymentLoadError( + "We couldn't load the payment details for this order. " + "Please try again in a moment.", + ); + return; + } + unawaited( Navigator.of(context).pushNamed( ShopInBitPaymentView.routeName, @@ -266,6 +281,19 @@ class _ShopInBitShippingViewState extends ConsumerState { ); } + Future _showPaymentLoadError(String message) async { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Couldn't load payment details", + maxWidth: Util.isDesktop ? 500 : null, + message: message, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + } + // Read-only display of the locked delivery country: it was fixed when the // offer was priced and can't change here. Widget _buildLockedCountryField() { diff --git a/lib/route_generator.dart b/lib/route_generator.dart index df03fd5811..9c98d6fb17 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1259,12 +1259,12 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitPaymentView.routeName: - if (args is (ShopInBitOrderModel, PaymentInfo?)) { + if (args is (ShopInBitOrderModel, PaymentInfo)) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => ShopInBitPaymentView( model: args.$1, - initialPaymentInfo: args.$2, + paymentInfo: args.$2, ), settings: RouteSettings(name: settings.name), ); diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart index e5561b4e21..f97e6f2b27 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -196,11 +196,11 @@ abstract final class NestedNavigatorDialogRouteGenerator { ); case ShopInBitPaymentView.routeName: - if (args is (ShopInBitOrderModel, PaymentInfo?)) { + if (args is (ShopInBitOrderModel, PaymentInfo)) { return getRoute( builder: (_) => ShopInBitPaymentView( model: args.$1, - initialPaymentInfo: args.$2, + paymentInfo: args.$2, ), settings: RouteSettings(name: settings.name), ); @@ -208,7 +208,7 @@ abstract final class NestedNavigatorDialogRouteGenerator { return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected (ShopInBitOrderModel, PaymentInfo?)", + "Expected (ShopInBitOrderModel, PaymentInfo)", ); case CakePayVendorsView.routeName: From bdb6f7aacc12749c010f79d1030753dfe8fde9ee Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:50:59 -0500 Subject: [PATCH 31/90] feat(shopinbit): add car request payload and invoice recovery to client --- lib/services/shopinbit/src/client.dart | 27 +++++++ .../shopinbit/src/models/car_research.dart | 79 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index ad695e2417..1ca8197852 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -355,12 +355,14 @@ class ShopInBitClient { Future> createCarResearchInvoice({ required Address billing, + CarResearchRequest? request, }) async { return _request( 'POST', '/car-research/invoice', body: { 'billing': billing.toJson(), + if (request != null) 'request': request.toJson(), if (_externalCustomerKey != null) 'external_customer_key': _externalCustomerKey, }, @@ -368,6 +370,31 @@ class ShopInBitClient { ); } + /// Unresolved car research invoices for the current partner/customer pair. + /// Used to recover a fee payment the user started but did not finish. + Future>> + getCurrentCarResearchInvoices() async { + return _requestRaw( + 'GET', + '/car-research/invoices/current', + parse: (body) { + if (body.isEmpty) return []; + final decoded = jsonDecode(body); + final list = decoded is List + ? decoded + : (decoded as Map)['invoices'] as List? ?? + const []; + return list + .map( + (e) => CarResearchCurrentInvoice.fromJson( + e as Map, + ), + ) + .toList(); + }, + ); + } + Future>> getCarResearchInvoiceStatus( String invoiceId, ) async { diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart index ea1eceb0d2..e5bf15be3b 100644 --- a/lib/services/shopinbit/src/models/car_research.dart +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -1,3 +1,82 @@ +/// Optional request payload cached with a car research fee invoice. When +/// provided, the backend creates the real car research ticket itself after the +/// fee is paid (the BTCPay webhook failsafe), so the client does not have to. +class CarResearchRequest { + final String customerPseudonym; + final String comment; + final String deliveryCountry; + + CarResearchRequest({ + required this.customerPseudonym, + required this.comment, + required this.deliveryCountry, + }); + + Map toJson() => { + 'customer_pseudonym': customerPseudonym, + 'comment': comment, + 'delivery_country': deliveryCountry, + }; +} + +/// An unresolved car research invoice returned by +/// GET /car-research/invoices/current, used to recover a payment the user +/// started but did not finish. +class CarResearchCurrentInvoice { + final String invoiceId; + final String status; + final String? additional; + final DateTime? expiresAt; + final Map paymentLinks; + final bool hasRequestPayload; + final DateTime? createdAt; + + CarResearchCurrentInvoice({ + required this.invoiceId, + required this.status, + required this.additional, + required this.expiresAt, + required this.paymentLinks, + required this.hasRequestPayload, + required this.createdAt, + }); + + factory CarResearchCurrentInvoice.fromJson(Map json) { + final linksRaw = json['payment_links'] as Map? ?? {}; + final expiresRaw = json['expires_at'] as String?; + final createdRaw = json['created_at'] as String?; + return CarResearchCurrentInvoice( + invoiceId: json['invoice_id'] as String, + status: json['status'] as String? ?? '', + additional: json['additional'] as String?, + expiresAt: expiresRaw == null ? null : DateTime.tryParse(expiresRaw), + paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), + hasRequestPayload: json['has_request_payload'] as bool? ?? false, + createdAt: createdRaw == null ? null : DateTime.tryParse(createdRaw), + ); + } +} + +/// Whether a car research invoice status counts as paid/finalized per the +/// ShopinBit 1.0.4 rules: Processing, Settled, or Expired with PaidLate. The +/// extra lowercase values keep older concierge-style statuses working. +bool carResearchIsFinalized(String? status, String? additional) { + final s = (status ?? '').toLowerCase().trim(); + final a = (additional ?? '').toLowerCase().trim(); + if (s == 'processing' || s == 'settled') return true; + if (s == 'expired' && a == 'paidlate') return true; + return const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + 'confirmed', + 'complete', + 'completed', + 'finalized', + }.contains(s); +} + class CarResearchInvoice { final String btcpayInvoice; final DateTime expiresAt; From fb4952db12491d83305a990d146079a14d9d05c7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:51:17 -0500 Subject: [PATCH 32/90] feat(shopinbit): cache car request payload when creating the fee invoice --- lib/pages/shopinbit/shopinbit_car_fee_view.dart | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index d6741e3632..1606d4ae08 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -248,10 +248,18 @@ class _ShopInBitCarFeeViewState extends ConsumerState { ); } + // Cache the car request alongside billing so the backend failsafe can + // create the real car research ticket once the fee is paid. + final request = CarResearchRequest( + customerPseudonym: widget.model.displayName, + comment: widget.model.requestDescription, + deliveryCountry: widget.model.deliveryCountry, + ); + final resp = await ref .read(pShopinBitService) .client - .createCarResearchInvoice(billing: billing); + .createCarResearchInvoice(billing: billing, request: request); if (resp.hasError || resp.value == null) { Logging.instance.e( From 8981054bba86230166f23154af4be55108123a11 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:53:36 -0500 Subject: [PATCH 33/90] refactor(shopinbit): finalize car research via backend failsafe --- .../shopinbit_car_research_payment_view.dart | 371 ++++-------------- lib/services/shopinbit/src/models/ticket.dart | 6 +- 2 files changed, 78 insertions(+), 299 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index fc24f8c43f..138b331f8a 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -10,6 +10,7 @@ import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../services/shopinbit/src/models/car_research.dart'; +import '../../services/shopinbit/src/models/ticket.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/logger.dart'; @@ -17,7 +18,6 @@ import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; -import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/qr.dart'; @@ -28,14 +28,7 @@ import 'shopinbit_order_created.dart'; import 'shopinbit_payment_shared.dart'; import 'shopinbit_tickets_view.dart'; -enum _PaymentFlowState { - idle, - polling, - loggingPayment, - creatingRequest, - complete, - error, -} +enum _PaymentFlowState { idle, polling, finalizing, complete, error } class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { const ShopInBitCarResearchPaymentView({ @@ -56,24 +49,11 @@ class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { class _ShopInBitCarResearchPaymentViewState extends ConsumerState { - static const Set _terminalStates = { - // concierge heritage - "paid", - "paid_over", - "paid_late", - "payment_processing", - // BTCPay / car research likely - "settled", - "confirmed", - "complete", - "completed", - "finalized", - }; - Timer? _pollTimer; Map? _status; _PaymentFlowState _flowState = _PaymentFlowState.idle; String _statusString = "ready_to_pay"; + String? _additional; List _methods = []; List _addresses = []; int _selectedMethod = 0; @@ -81,10 +61,7 @@ class _ShopInBitCarResearchPaymentViewState String get _currentAddress => _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : ""; - bool get _isTerminal { - final s = _statusString.toLowerCase().trim(); - return _terminalStates.contains(s); - } + bool get _isTerminal => carResearchIsFinalized(_statusString, _additional); bool get _payNowEnabled => !_isTerminal && _flowState == _PaymentFlowState.idle; @@ -135,7 +112,7 @@ class _ShopInBitCarResearchPaymentViewState try { await _pollStatus(); if (!mounted) return; - if (!_isTerminal && _flowState != _PaymentFlowState.loggingPayment) { + if (!_isTerminal && _flowState != _PaymentFlowState.finalizing) { unawaited( showFloatingFlushBar( type: FlushBarType.info, @@ -274,10 +251,11 @@ class _ShopInBitCarResearchPaymentViewState setState(() { _status = resp.value!; _statusString = _status!["status"]?.toString() ?? _statusString; + _additional = _status!["additional"]?.toString(); }); if (_isTerminal) { _pollTimer?.cancel(); - await _processPaymentAndRequest(); + await _finalizePayment(); } } catch (e) { if (mounted) { @@ -292,223 +270,77 @@ class _ShopInBitCarResearchPaymentViewState } } - Future _processPaymentAndRequest() async { - // Guard: only one entry allowed - if (_flowState == _PaymentFlowState.loggingPayment || - _flowState == _PaymentFlowState.creatingRequest || + Future _finalizePayment() async { + if (_flowState == _PaymentFlowState.finalizing || _flowState == _PaymentFlowState.complete || _flowState == _PaymentFlowState.error) { return; } - // Skip logCarResearchPayment if the fee was already logged. - final existingFeeTicket = widget.model.feeTicketNumber; - if (existingFeeTicket != null) { - if (!widget.model.needsCreateRequest) { - // Both steps already done: navigate to success directly. - if (!mounted) return; - setState(() => _flowState = _PaymentFlowState.complete); - - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); - - return; - } - // Fee logged; skip to createRequest. - setState(() => _flowState = _PaymentFlowState.creatingRequest); - _pollTimer?.cancel(); - try { - final customerKey = await ref - .read(pShopinBitService) - .ensureCustomerKey(); - final comment = - "${widget.model.requestDescription}\n\n" - "The Client paid the car research fee (#$existingFeeTicket)"; - final reqResp = await ref - .read(pShopinBitService) - .client - .createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); - if (reqResp.hasError || reqResp.value == null) { - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => StackDialog( - title: "Request Failed", - message: - "Payment was confirmed but we couldn't submit your car " - "research request. You can retry from My Requests.\n\n" - "Error: ${reqResp.exception?.message ?? 'Unknown error'}", - leftButton: SecondaryButton( - label: "Retry Now", - onPressed: () { - Navigator.of(ctx).pop(); - _retryCreateRequest(existingFeeTicket, customerKey); - }, - ), - rightButton: PrimaryButton( - label: "My Requests", - onPressed: () { - Navigator.of(ctx).pop(); - _popToTickets(); - }, - ), - ), - ); - } - return; - } - final requestRef = reqResp.value!; - final prevTicketId = widget.model.ticketId; - widget.model.apiTicketId = requestRef.id; - widget.model.ticketId = requestRef.number; - widget.model.status = ShopInBitOrderStatus.pending; - widget.model.isPendingPayment = false; - widget.model.needsCreateRequest = false; - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(widget.model.toCompanion()); - // Remove the sentinel record. - if (prevTicketId != null && prevTicketId != widget.model.ticketId) { - await (db.delete( - db.shopInBitTickets, - )..where((t) => t.ticketId.equals(prevTicketId))).go(); - } - if (!mounted) return; - setState(() => _flowState = _PaymentFlowState.complete); - - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); - } catch (e) { - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Failed to submit car research request", - maxWidth: Util.isDesktop ? 500 : null, - message: e.toString(), - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - } - return; - } - - setState(() => _flowState = _PaymentFlowState.loggingPayment); + setState(() => _flowState = _PaymentFlowState.finalizing); _pollTimer?.cancel(); + final db = ref.read(pSharedDrift); + final client = ref.read(pShopinBitService).client; + try { - final logResp = await ref - .read(pShopinBitService) - .client - .logCarResearchPayment(widget.invoice.btcpayInvoice); + // Best-effort: the BTCPay webhook is the failsafe that finalizes the fee + // and creates the receipt and real car ticket even if this call fails. + final logResp = await client.logCarResearchPayment( + widget.invoice.btcpayInvoice, + ); + if (logResp.hasError || logResp.value == null) { + // Payment is confirmed but we could not log it. The webhook will + // finalize it server side, so send the user to their requests where + // the finalized ticket will appear, and leave the pending record so + // they can resume if needed. if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); await showDialog( context: context, useRootNavigator: Util.isDesktop, builder: (context) => StackOkDialog( - title: "Failed to log car research payment", + title: "Payment received", maxWidth: Util.isDesktop ? 500 : null, - message: logResp.exception?.message, + message: + "We're finalizing your car research request. It will " + "appear in My Requests shortly.", desktopPopRootNavigator: Util.isDesktop, ), ); } + if (mounted) _popToTickets(); return; } - final feeResult = logResp.value!; - - // Persist feeTicketNumber on the existing model (a new DB row creates a - // spurious list entry). - widget.model.feeTicketNumber = feeResult.ticketNumber; - widget.model.needsCreateRequest = true; - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(widget.model.toCompanion()); - - if (!mounted) return; - setState(() => _flowState = _PaymentFlowState.creatingRequest); - - final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); - final comment = - "${widget.model.requestDescription}\n\n" - "The Client paid the car research fee (#${feeResult.ticketNumber})"; - - final reqResp = await ref - .read(pShopinBitService) - .client - .createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); + final result = logResp.value!; + widget.model.feeTicketNumber = result.ticketNumber; - if (reqResp.hasError || reqResp.value == null) { - // createRequest failed: fee receipt already persisted, show retry - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => StackDialog( - title: "Request Failed", - message: - "Payment was confirmed but we couldn't submit your car " - "research request. You can retry from My Requests.\n\n" - "Error: ${reqResp.exception?.message ?? 'Unknown error'}", - leftButton: SecondaryButton( - label: "Retry Now", - onPressed: () { - Navigator.of(ctx).pop(); - _retryCreateRequest(feeResult.ticketNumber, customerKey); - }, - ), - rightButton: PrimaryButton( - label: "My Requests", - onPressed: () { - Navigator.of(ctx).pop(); - _popToTickets(); - }, - ), - ), - ); - } - return; - } + // log-payment returns the partner-scoped fee receipt, which the customer + // key cannot poll. Adopt the customer-facing car research ticket the + // backend created from the cached request so polling targets it instead. + final realTicket = await _resolveRealTicket(result.ticketId); - final requestRef = reqResp.value!; final prevTicketId = widget.model.ticketId; - widget.model.apiTicketId = requestRef.id; - widget.model.ticketId = requestRef.number; + if (realTicket != null) { + widget.model.apiTicketId = realTicket.id; + widget.model.ticketId = realTicket.number; + } else { + // Backend has not surfaced the ticket yet. Show the receipt number and + // leave polling disabled so we don't hammer the inaccessible receipt; + // the requests list refresh will pick up the real ticket later. + widget.model.apiTicketId = 0; + widget.model.ticketId = result.ticketNumber; + } widget.model.status = ShopInBitOrderStatus.pending; widget.model.isPendingPayment = false; widget.model.needsCreateRequest = false; + await db .into(db.shopInBitTickets) .insertOnConflictUpdate(widget.model.toCompanion()); + + // Drop the sentinel pending row now that we have a real ticket id. if (prevTicketId != null && prevTicketId != widget.model.ticketId) { await (db.delete( db.shopInBitTickets, @@ -540,88 +372,32 @@ class _ShopInBitCarResearchPaymentViewState } } - Future _retryCreateRequest( - String feeTicketNumber, - String customerKey, - ) async { - if (_flowState == _PaymentFlowState.creatingRequest) return; - setState(() => _flowState = _PaymentFlowState.creatingRequest); - + /// Find the customer-facing car research ticket the backend created from the + /// cached request, excluding the partner-scoped fee receipt and any ticket we + /// already track. Returns the newest match, or null if none is visible yet. + Future _resolveRealTicket(int receiptTicketId) async { + final service = ref.read(pShopinBitService); + final db = ref.read(pSharedDrift); try { - final comment = - "${widget.model.requestDescription}\n\n" - "The Client paid the car research fee (#$feeTicketNumber)"; - - final reqResp = await ref - .read(pShopinBitService) - .client - .createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); - - if (reqResp.hasError || reqResp.value == null) { - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Retry failed", - maxWidth: Util.isDesktop ? 500 : null, - message: reqResp.exception?.message, - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - return; - } - - final requestRef = reqResp.value!; - widget.model.apiTicketId = requestRef.id; - widget.model.ticketId = requestRef.number; - widget.model.status = ShopInBitOrderStatus.pending; - // Flow complete: clear the resume flag before saving. - widget.model.isPendingPayment = false; - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(widget.model.toCompanion()); - - // Update fee receipt ticket - final feeTickets = await (db.select( - db.shopInBitTickets, - )..where((t) => t.ticketId.equals(feeTicketNumber))).get(); - if (feeTickets.isNotEmpty) { - final feeTicket = feeTickets.first.copyWith(needsCreateRequest: false); - await db.into(db.shopInBitTickets).insertOnConflictUpdate(feeTicket); - } - - if (!mounted) return; - setState(() => _flowState = _PaymentFlowState.complete); - - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); - } catch (e) { - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Retry failed", - maxWidth: Util.isDesktop ? 500 : null, - message: e.toString(), - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } + final customerKey = await service.ensureCustomerKey(); + final resp = await service.client.getTicketsByCustomer(customerKey); + if (resp.hasError || resp.value == null) return null; + + final knownApiIds = (await db.select(db.shopInBitTickets).get()) + .map((t) => t.apiTicketId) + .toSet(); + + final candidates = + resp.value! + .where( + (t) => t.id != receiptTicketId && !knownApiIds.contains(t.id), + ) + .toList() + ..sort((a, b) => b.id.compareTo(a.id)); + + return candidates.isEmpty ? null : candidates.first; + } catch (_) { + return null; } } @@ -835,8 +611,7 @@ class _ShopInBitCarResearchPaymentViewState PrimaryButton( label: _flowState == _PaymentFlowState.polling ? "Checking..." - : (_flowState == _PaymentFlowState.loggingPayment || - _flowState == _PaymentFlowState.creatingRequest) + : _flowState == _PaymentFlowState.finalizing ? "Processing..." : (hasWallets ? "PAY NOW" : "CHECK FOR PAYMENT"), enabled: _payNowEnabled, diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 1313d6032d..773c0f478b 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -162,5 +162,9 @@ class TicketFull { int _toInt(dynamic value) { if (value is int) return value; - return int.parse(value.toString()); + if (value is num) return value.toInt(); + // Un-priced offers come back with empty/missing numeric fields; returning 0 + // is safe as it's validated downstream and 0s result in an error dialog + // that pricing's unavailable. + return int.tryParse(value.toString()) ?? 0; } From 992d17e4501d148eef0f71a89dc6aaa0af2893a3 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:55:00 -0500 Subject: [PATCH 34/90] feat(shopinbit): resume car research from server-side current invoices --- .../shopinbit/shopinbit_tickets_view.dart | 83 +++++++++++++++---- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index 8226f81bc4..b267e38908 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -9,9 +9,11 @@ import "../../db/drift/shared_db/shared_database.dart"; import "../../models/shopinbit/shopinbit_order_model.dart"; import "../../providers/db/drift_provider.dart"; import "../../providers/global/shopin_bit_orders_provider.dart"; +import "../../providers/global/shopin_bit_service_provider.dart"; import "../../services/shopinbit/src/models/car_research.dart"; import "../../themes/stack_colors.dart"; import "../../utilities/assets.dart"; +import "../../utilities/show_loading.dart"; import "../../utilities/text_styles.dart"; import "../../utilities/util.dart"; import "../../widgets/background.dart"; @@ -74,34 +76,81 @@ class _ShopInBitTicketsViewState extends ConsumerState { } } - void _resumeFlow(ShopInBitTicket pending) { + Future _resumeFlow(ShopInBitTicket pending) async { final model = ShopInBitOrderModel.fromDriftRow(pending); + + // Recover the live invoice from the server first so resume works even if + // local invoice state was lost. + final response = await showLoading( + context: context, + rootNavigator: true, + message: "Checking your car research payment", + whileFuture: ref + .read(pShopinBitService) + .client + .getCurrentCarResearchInvoices(), + delay: const Duration(seconds: 1), + ); + if (!mounted) return; + + final invoice = _liveInvoiceFrom(response?.value, pending); + + if (invoice != null) { + await Navigator.of(context).pushNamed( + ShopInBitCarResearchPaymentView.routeName, + arguments: (model, invoice), + ); + } else { + // No recoverable invoice anywhere: re-create one from the fee view. + await Navigator.of( + context, + ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model); + } + } + + /// Pick a still-payable invoice, preferring the server's current invoices + /// and falling back to locally stored invoice state. + CarResearchInvoice? _liveInvoiceFrom( + List? current, + ShopInBitTicket pending, + ) { + if (current != null && current.isNotEmpty) { + final match = current.firstWhere( + (i) => i.invoiceId == pending.carResearchInvoiceId, + orElse: () => current.first, + ); + final payable = + match.expiresAt != null && + match.paymentLinks.isNotEmpty && + (match.expiresAt!.isAfter(DateTime.now()) || + carResearchIsFinalized(match.status, match.additional)); + if (payable) { + return CarResearchInvoice( + btcpayInvoice: match.invoiceId, + expiresAt: match.expiresAt!, + paymentLinks: match.paymentLinks, + ); + } + } + final expiresAt = pending.carResearchExpiresAt; final linksJson = pending.carResearchPaymentLinks; - + final invoiceId = pending.carResearchInvoiceId; if (expiresAt != null && expiresAt.isAfter(DateTime.now()) && - linksJson != null) { - // Invoice still live: navigate directly to payment view. + linksJson != null && + invoiceId != null) { final links = (jsonDecode(linksJson) as Map).map( (k, v) => MapEntry(k, v as String), ); - final invoice = CarResearchInvoice( - btcpayInvoice: pending.carResearchInvoiceId!, + return CarResearchInvoice( + btcpayInvoice: invoiceId, expiresAt: expiresAt, paymentLinks: links, ); - - Navigator.of(context).pushNamed( - ShopInBitCarResearchPaymentView.routeName, - arguments: (model, invoice), - ); - } else { - // Invoice expired: navigate to fee view. - Navigator.of( - context, - ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model); } + + return null; } static String _categoryLabel(ShopInBitCategory? category) => @@ -137,7 +186,7 @@ class _ShopInBitTicketsViewState extends ConsumerState { children.add( RoundedContainer( color: Theme.of(context).extension()!.popupBG, - onPressed: () => _resumeFlow(pending), + onPressed: () => unawaited(_resumeFlow(pending)), child: _RequestRow( title: "Car Research (In Progress)", subtitle: "Tap to continue your car research payment", From 1c503d992c494ceadd1ca15649e1221e572a4106 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 13:56:36 -0500 Subject: [PATCH 35/90] refactor(shopinbit): retire manual car research request retry --- .../shopinbit/shopinbit_ticket_detail.dart | 110 +++--------------- 1 file changed, 13 insertions(+), 97 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 597c8bbc52..e2eebf84f9 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -7,7 +7,6 @@ import 'package:flutter_svg/svg.dart'; import 'package:intl/intl.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../notifications/show_flush_bar.dart'; import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_orders_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; @@ -25,7 +24,6 @@ import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/refresh_control.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; -import '../../widgets/stack_dialog.dart'; import 'shopinbit_offer_view.dart'; class ShopInBitTicketDetail extends ConsumerStatefulWidget { @@ -47,7 +45,6 @@ class _ShopInBitTicketDetailState extends ConsumerState { bool _polling = false; bool _sending = false; - bool _retrying = false; @override void initState() { @@ -115,92 +112,6 @@ class _ShopInBitTicketDetailState extends ConsumerState { } } - Future _retryCreateRequest() async { - if (_retrying) return; - setState(() => _retrying = true); - - try { - final model = _model; - final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); - final comment = - "${model.requestDescription}\n\n" - "The Client paid the car research fee (#${model.feeTicketNumber})"; - - final reqResp = await ref - .read(pShopinBitService) - .client - .createRequest( - customerPseudonym: model.displayName, - externalCustomerKey: customerKey, - serviceType: "car_research", - comment: comment, - deliveryCountry: model.deliveryCountry, - ); - - if (reqResp.hasError || reqResp.value == null) { - if (mounted) { - setState(() => _retrying = false); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Failed to create request", - maxWidth: Util.isDesktop ? 500 : null, - message: reqResp.exception?.message, - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - return; - } - - final requestRef = reqResp.value!; - final requestModel = ShopInBitOrderModel() - ..ticketId = requestRef.number - ..apiTicketId = requestRef.id - ..category = ShopInBitCategory.car - ..status = ShopInBitOrderStatus.pending - ..displayName = model.displayName - ..requestDescription = model.requestDescription - ..deliveryCountry = model.deliveryCountry; - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(requestModel.toCompanion()); - - model.needsCreateRequest = false; - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(model.toCompanion()); - - if (!mounted) return; - setState(() => _retrying = false); - - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Car research request submitted successfully!", - context: context, - ), - ); - Navigator.of(context).pop(); - } catch (e) { - if (mounted) { - setState(() => _retrying = false); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Failed to create request", - maxWidth: Util.isDesktop ? 500 : null, - message: e.toString(), - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - } - } - String _formatTime(DateTime dt) { final local = dt.toLocal(); final hour = local.hour.toString().padLeft(2, '0'); @@ -563,16 +474,21 @@ class _ShopInBitTicketDetailState extends ConsumerState { ) : const SizedBox.shrink(); - final retryButton = + // After the fee is paid the backend creates the real car ticket from the + // cached request, so we surface a finalizing note instead of asking the + // client to create the request itself. + final finalizingNote = model.needsCreateRequest && model.category == ShopInBitCategory.car ? Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: PrimaryButton( - label: _retrying ? "Submitting..." : "Complete Request", - enabled: !_retrying, - onPressed: _retrying - ? null - : () => unawaited(_retryCreateRequest()), + child: RoundedWhiteContainer( + child: Text( + "We're finalizing your car research request. Pull to refresh " + "if it doesn't appear shortly.", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), ), ) : const SizedBox.shrink(); @@ -582,7 +498,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { crossAxisAlignment: .stretch, children: [ statusBar, - retryButton, + finalizingNote, offerBanner, requestDetailsSection, chatArea, From 2d5c5b4fcb07f5a4b45b26292905b0a92c1b0141 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 14:05:44 -0500 Subject: [PATCH 36/90] fix(desktop settings): clamp selected menu index to prevent RangeError --- .../settings/desktop_settings_view.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pages_desktop_specific/settings/desktop_settings_view.dart b/lib/pages_desktop_specific/settings/desktop_settings_view.dart index 65569890d1..a83bccbc40 100644 --- a/lib/pages_desktop_specific/settings/desktop_settings_view.dart +++ b/lib/pages_desktop_specific/settings/desktop_settings_view.dart @@ -119,10 +119,10 @@ class _DesktopSettingsViewState extends ConsumerState { ), ), Expanded( - child: - contentViews[ref - .watch(selectedSettingsMenuItemStateProvider.state) - .state], + child: contentViews[ + (ref.watch(selectedSettingsMenuItemStateProvider.state).state) + .clamp(0, contentViews.length - 1) + ], ), ], ), From 28cc575c7ad58c781bc4b47aab66da164f77ac7b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Fri, 29 May 2026 22:47:37 -0500 Subject: [PATCH 37/90] refactor(shopinbit): resume car research with inline row spinner --- .../shopinbit/shopinbit_tickets_view.dart | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index b267e38908..0f02a30f2e 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -13,7 +13,6 @@ import "../../providers/global/shopin_bit_service_provider.dart"; import "../../services/shopinbit/src/models/car_research.dart"; import "../../themes/stack_colors.dart"; import "../../utilities/assets.dart"; -import "../../utilities/show_loading.dart"; import "../../utilities/text_styles.dart"; import "../../utilities/util.dart"; import "../../widgets/background.dart"; @@ -21,6 +20,7 @@ import "../../widgets/conditional_parent.dart"; import "../../widgets/custom_buttons/app_bar_icon_button.dart"; import "../../widgets/desktop/desktop_dialog_close_button.dart"; import "../../widgets/dialogs/s_dialog.dart"; +import "../../widgets/loading_indicator.dart"; import "../../widgets/refresh_control.dart"; import "../../widgets/rounded_container.dart"; import "shopinbit_car_fee_view.dart"; @@ -42,6 +42,7 @@ class _ShopInBitTicketsViewState extends ConsumerState { ShopInBitTicket? _pendingTicket; StreamSubscription>? _ticketsSub; bool _refreshing = false; + bool _resuming = false; @override void initState() { @@ -77,23 +78,27 @@ class _ShopInBitTicketsViewState extends ConsumerState { } Future _resumeFlow(ShopInBitTicket pending) async { + if (_resuming) return; final model = ShopInBitOrderModel.fromDriftRow(pending); // Recover the live invoice from the server first so resume works even if // local invoice state was lost. - final response = await showLoading( - context: context, - rootNavigator: true, - message: "Checking your car research payment", - whileFuture: ref - .read(pShopinBitService) - .client - .getCurrentCarResearchInvoices(), - delay: const Duration(seconds: 1), - ); + setState(() => _resuming = true); + List? current; + try { + current = (await ref + .read(pShopinBitService) + .client + .getCurrentCarResearchInvoices()) + .value; + } catch (_) { + // Fall back to locally stored invoice state below. + } finally { + if (mounted) setState(() => _resuming = false); + } if (!mounted) return; - final invoice = _liveInvoiceFrom(response?.value, pending); + final invoice = _liveInvoiceFrom(current, pending); if (invoice != null) { await Navigator.of(context).pushNamed( @@ -186,14 +191,17 @@ class _ShopInBitTicketsViewState extends ConsumerState { children.add( RoundedContainer( color: Theme.of(context).extension()!.popupBG, - onPressed: () => unawaited(_resumeFlow(pending)), + onPressed: _resuming ? null : () => unawaited(_resumeFlow(pending)), child: _RequestRow( title: "Car Research (In Progress)", - subtitle: "Tap to continue your car research payment", + subtitle: _resuming + ? "Checking your car research payment..." + : "Tap to continue your car research payment", badgeText: "Resume", badgeColor: Theme.of( context, ).extension()!.accentColorYellow, + loading: _resuming, ), ), ); @@ -328,12 +336,14 @@ class _RequestRow extends StatelessWidget { required this.subtitle, required this.badgeText, required this.badgeColor, + this.loading = false, }); final String title; final String subtitle; final String badgeText; final Color badgeColor; + final bool loading; @override Widget build(BuildContext context) { @@ -374,12 +384,21 @@ class _RequestRow extends StatelessWidget { ), ), SizedBox(width: isDesktop ? 16 : 8), - SvgPicture.asset( - Assets.svg.chevronRight, - width: 20, - height: 20, - colorFilter: ColorFilter.mode(stackColors.textSubtitle1, .srcIn), - ), + loading + ? const SizedBox( + width: 20, + height: 20, + child: LoadingIndicator(), + ) + : SvgPicture.asset( + Assets.svg.chevronRight, + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + stackColors.textSubtitle1, + .srcIn, + ), + ), ], ); } From 0132c180e443856319b0184a544ef46e4f758c73 Mon Sep 17 00:00:00 2001 From: julian Date: Sat, 30 May 2026 11:03:44 -0600 Subject: [PATCH 38/90] Revert "fix(desktop settings): clamp selected menu index to prevent RangeError" This reverts commit 2d5c5b4fcb07f5a4b45b26292905b0a92c1b0141. --- .../settings/desktop_settings_view.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pages_desktop_specific/settings/desktop_settings_view.dart b/lib/pages_desktop_specific/settings/desktop_settings_view.dart index a83bccbc40..65569890d1 100644 --- a/lib/pages_desktop_specific/settings/desktop_settings_view.dart +++ b/lib/pages_desktop_specific/settings/desktop_settings_view.dart @@ -119,10 +119,10 @@ class _DesktopSettingsViewState extends ConsumerState { ), ), Expanded( - child: contentViews[ - (ref.watch(selectedSettingsMenuItemStateProvider.state).state) - .clamp(0, contentViews.length - 1) - ], + child: + contentViews[ref + .watch(selectedSettingsMenuItemStateProvider.state) + .state], ), ], ), From cfb37fe1c7dfc7d39bdbaecc23e54e91f93a9411 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 28 May 2026 09:34:50 -0600 Subject: [PATCH 39/90] pre loading example combined with required args in widget/view --- lib/pages/shopinbit/shopinbit_offer_view.dart | 56 ++++- .../shopinbit/shopinbit_shipping_view.dart | 236 +++--------------- lib/route_generator.dart | 11 +- ...sted_navigator_dialog_route_generator.dart | 11 +- 4 files changed, 104 insertions(+), 210 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart index 64e90d1a7b..b1594930ba 100644 --- a/lib/pages/shopinbit/shopinbit_offer_view.dart +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -14,6 +15,7 @@ import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; import 'shopinbit_shipping_view.dart'; class ShopInBitOfferView extends ConsumerStatefulWidget { @@ -145,13 +147,59 @@ class _ShopInBitOfferViewState extends ConsumerState { label: "Accept offer", buttonHeight: Util.isDesktop ? ButtonHeight.l : null, enabled: !_loading, - onPressed: () { + onPressed: () async { // TODO verify this is ok to stay set to accepted if the next route pops back and then decline is tapped model.status = ShopInBitOrderStatus.accepted; - Navigator.of( - context, - ).pushNamed(ShopInBitShippingView.routeName, arguments: model); + final shopinBitApi = ref.read(pShopinBitService).client; + final response = await showLoading( + context: context, + rootNavigator: true, + message: "Updating available countries", + whileFuture: shopinBitApi.getCountries(), + delay: const Duration( + seconds: 1, + ), // at least 1 sec to prevent ui flashing + ); + + if (!context.mounted) return; + + String? errorMessage; + + if (response?.value == null) { + errorMessage = + response?.exception?.toString() ?? + "Failed to fetch countries data"; + } else if (response!.value! + .where((c) => c['iso'] == model.deliveryCountry) + .length != + 1) { + errorMessage = + "Delivery country code \"" + "${model.deliveryCountry}" + "\" is invalid"; + } + + if (errorMessage != null) { + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "ShopinBit API error", + maxWidth: Util.isDesktop ? 500 : null, + message: errorMessage, + desktopPopRootNavigator: Util.isDesktop, + ), + ); + return; + } + + if (context.mounted) { + await Navigator.of(context).pushNamed( + ShopInBitShippingView.routeName, + arguments: (model: model, countries: response!.value!), + ); + } }, ), SecondaryButton( diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 88be18d18e..adb0892404 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -16,9 +16,9 @@ import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../widgets/detail_item.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/detail_item.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/stack_dialog.dart'; import '../../widgets/textfields/adaptive_text_field.dart'; @@ -26,11 +26,16 @@ import 'shopinbit_payment_shared.dart'; import 'shopinbit_payment_view.dart'; class ShopInBitShippingView extends ConsumerStatefulWidget { - const ShopInBitShippingView({super.key, required this.model}); + const ShopInBitShippingView({ + super.key, + required this.model, + required this.countries, + }); static const String routeName = "/shopInBitShipping"; final ShopInBitOrderModel model; + final List> countries; @override ConsumerState createState() => @@ -64,13 +69,8 @@ class _ShopInBitShippingViewState extends ConsumerState { String? _billingSelectedCountryIso; bool _differentBilling = false; - List> _countries = []; - String? _selectedCountryIso; - bool _loadingCountries = false; - // True when we arrived with a pre-set delivery country (the normal new-order - // path). Restored-from-API orders land here with no country, so we unlock - // the dropdown only in that case. - late final bool _countryLocked; + late final String _selectedCountryIso; + late final String _deliveryCountryLabel; bool _submitting = false; @@ -80,8 +80,7 @@ class _ShopInBitShippingViewState extends ConsumerState { _nameController.text.trim().isNotEmpty && _streetController.text.trim().isNotEmpty && _cityController.text.trim().isNotEmpty && - _postalCodeController.text.trim().isNotEmpty && - _selectedCountryIso != null; + _postalCodeController.text.trim().isNotEmpty; if (!shippingValid) return false; if (_differentBilling) { return _billingNameController.text.trim().isNotEmpty && @@ -114,10 +113,16 @@ class _ShopInBitShippingViewState extends ConsumerState { _billingCityFocusNode = FocusNode(); _billingPostalCodeFocusNode = FocusNode(); - _selectedCountryIso = widget.model.deliveryCountry.isNotEmpty - ? widget.model.deliveryCountry - : null; - _countryLocked = _selectedCountryIso != null; + _selectedCountryIso = widget.model.deliveryCountry; + + // firstWhere should never fail here as the caller of this widget must + // check that countries contains the expected value. Failure here should be + // considered unrecoverable/fatal as it indicates a bug elsewhere + _deliveryCountryLabel = + widget.countries.firstWhere( + (e) => e["iso"] == _selectedCountryIso, + )["label"] + as String; for (final node in [ _nameFocusNode, @@ -131,8 +136,6 @@ class _ShopInBitShippingViewState extends ConsumerState { ]) { node.addListener(() => setState(() {})); } - - _fetchCountries(); } @override @@ -158,29 +161,12 @@ class _ShopInBitShippingViewState extends ConsumerState { super.dispose(); } - Future _fetchCountries() async { - setState(() => _loadingCountries = true); - try { - final resp = await ref.read(pShopinBitService).client.getCountries(); - if (resp.hasError || resp.value == null) return; - _countries = resp.value!; - if (_selectedCountryIso != null && - !_countries.any((c) => c['iso'] == _selectedCountryIso)) { - _selectedCountryIso = null; - } - } catch (_) { - // leave list empty; user will see no items - } finally { - if (mounted) setState(() => _loadingCountries = false); - } - } - Future _continue() async { final name = _nameController.text.trim(); final street = _streetController.text.trim(); final city = _cityController.text.trim(); final postalCode = _postalCodeController.text.trim(); - final country = _selectedCountryIso!; + final country = _selectedCountryIso; widget.model.setShippingAddress( name: name, @@ -189,11 +175,6 @@ class _ShopInBitShippingViewState extends ConsumerState { postalCode: postalCode, country: country, ); - // Keep deliveryCountry authoritative and in sync with the shipping - // country. No-op when it was already set (the normal flow); fills the gap - // for restored orders, where deliveryCountry came back empty from the API - // and the user picked one here. - widget.model.deliveryCountry = country; // The payment view needs a live invoice, so load it here and only navigate // once we have usable payment links. @@ -294,139 +275,6 @@ class _ShopInBitShippingViewState extends ConsumerState { ); } - // Read-only display of the locked delivery country: it was fixed when the - // offer was priced and can't change here. - Widget _buildLockedCountryField() { - final label = - _countries - .where((c) => c['iso'] == _selectedCountryIso) - .map((c) => c['label'] as String) - .firstOrNull ?? - (_selectedCountryIso ?? ""); - - return DetailItem( - title: "Country", - detail: label, - disableSelectableText: true, - ); - } - - // Editable, searchable country dropdown. Only shown when the delivery country - // wasn't pre-set (restored-from-API orders). - Widget _buildCountryDropdown( - BuildContext context, { - required bool isDesktop, - }) { - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: _selectedCountryIso, - items: _countries - .map( - (c) => DropdownMenuItem( - value: c['iso'] as String, - child: Text( - c['label'] as String, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ), - ) - .toList(), - onMenuStateChange: (isOpen) { - if (!isOpen) { - _countrySearchController.clear(); - } - }, - onChanged: _loadingCountries - ? null - : (value) => setState(() => _selectedCountryIso = value), - hint: Text( - _loadingCountries ? "Loading countries..." : "Country", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - maxHeight: 300, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - dropdownSearchData: DropdownSearchData( - searchController: _countrySearchController, - searchInnerWidgetHeight: 48, - searchInnerWidget: TextFormField( - controller: _countrySearchController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - hintText: "Search...", - hintStyle: STextStyles.fieldLabel(context), - border: InputBorder.none, - ), - ), - searchMatchFn: (item, searchValue) { - final label = _countries - .where((c) => c['iso'] == item.value) - .map((c) => c['label'] as String) - .firstOrNull; - return label?.toLowerCase().contains(searchValue.toLowerCase()) ?? - false; - }, - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; @@ -494,25 +342,11 @@ class _ShopInBitShippingViewState extends ConsumerState { ], ), spacing, - // The delivery country was chosen when the offer was requested and the - // price (incl. shipping + VAT) was calculated from it, so it can't be - // changed here. Restored-from-API orders are the exception: they come - // back with no country, so we let the user supply one (and warn that it - // may not match what the offer was priced for). - if (_countryLocked) - _buildLockedCountryField() - else ...[ - _buildCountryDropdown(context, isDesktop: isDesktop), - SizedBox(height: isDesktop ? 8 : 6), - Text( - "This order was started on another device. Choosing a country " - "here may not match the delivery destination the offer was " - "priced for.", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - ], + DetailItem( + title: "Country", + detail: _deliveryCountryLabel, + disableSelectableText: true, + ), spacing, // Billing address toggle. GestureDetector( @@ -627,7 +461,7 @@ class _ShopInBitShippingViewState extends ConsumerState { child: DropdownButtonHideUnderline( child: DropdownButton2( value: _billingSelectedCountryIso, - items: _countries + items: widget.countries .map( (c) => DropdownMenuItem( value: c['iso'] as String, @@ -651,15 +485,13 @@ class _ShopInBitShippingViewState extends ConsumerState { _billingCountrySearchController.clear(); } }, - onChanged: _loadingCountries - ? null - : (value) { - setState(() { - _billingSelectedCountryIso = value; - }); - }, + onChanged: (value) { + setState(() { + _billingSelectedCountryIso = value; + }); + }, hint: Text( - _loadingCountries ? "Loading countries..." : "Country", + "Country", style: isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( color: Theme.of(context) @@ -722,7 +554,7 @@ class _ShopInBitShippingViewState extends ConsumerState { ), ), searchMatchFn: (item, searchValue) { - final label = _countries + final label = widget.countries .where((c) => c['iso'] == item.value) .map((c) => c['label'] as String) .firstOrNull; diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 9c98d6fb17..42a6c0ebab 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1226,10 +1226,17 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitShippingView.routeName: - if (args is ShopInBitOrderModel) { + if (args + is ({ + ShopInBitOrderModel model, + List> countries, + })) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitShippingView(model: args), + builder: (_) => ShopInBitShippingView( + model: args.model, + countries: args.countries, + ), settings: RouteSettings(name: settings.name), ); } diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart index f97e6f2b27..045a289eb5 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -183,9 +183,16 @@ abstract final class NestedNavigatorDialogRouteGenerator { ); case ShopInBitShippingView.routeName: - if (args is ShopInBitOrderModel) { + if (args + is ({ + ShopInBitOrderModel model, + List> countries, + })) { return getRoute( - builder: (_) => ShopInBitShippingView(model: args), + builder: (_) => ShopInBitShippingView( + model: args.model, + countries: args.countries, + ), settings: RouteSettings(name: settings.name), ); } From 0042ca98a6edfe443293d88a2019c148a44b1d6c Mon Sep 17 00:00:00 2001 From: julian Date: Sun, 31 May 2026 12:52:44 -0600 Subject: [PATCH 40/90] re enable shopinbit --- scripts/app_config/configure_stack_duo.sh | 1 + scripts/app_config/configure_stack_wallet.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/scripts/app_config/configure_stack_duo.sh b/scripts/app_config/configure_stack_duo.sh index 24f9f95a77..7d6bae012d 100755 --- a/scripts/app_config/configure_stack_duo.sh +++ b/scripts/app_config/configure_stack_duo.sh @@ -62,6 +62,7 @@ const Set _features = { AppFeature.themeSelection, AppFeature.buy, AppFeature.tor, + AppFeature.shopinBit, AppFeature.cakePay, AppFeature.swap }; diff --git a/scripts/app_config/configure_stack_wallet.sh b/scripts/app_config/configure_stack_wallet.sh index 6c8607609e..bf3d6c6621 100755 --- a/scripts/app_config/configure_stack_wallet.sh +++ b/scripts/app_config/configure_stack_wallet.sh @@ -90,6 +90,7 @@ const Set _features = { AppFeature.themeSelection, AppFeature.buy, AppFeature.tor, + AppFeature.shopinBit, AppFeature.cakePay, AppFeature.swap }; From 1a804a50067bea824d784597b931517b70c09c01 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 1 Jun 2026 08:58:54 -0600 Subject: [PATCH 41/90] shopinbit refactor wip --- lib/db/drift/shared_db/shared_database.dart | 204 +- lib/db/drift/shared_db/shared_database.g.dart | 2679 ++++++++--------- .../shared_db/tables/shopin_bit_settings.dart | 29 +- .../shared_db/tables/shopin_bit_tickets.dart | 114 +- lib/models/shopinbit/shopinbit_enums.dart | 82 + .../shopinbit/shopinbit_order_model.dart | 382 --- .../shopinbit/shopinbit_request_draft.dart | 25 + lib/pages/more_view/services_view.dart | 79 +- .../helpers/restore_create_backup.dart | 134 +- .../stack_restore_progress_view.dart | 601 ++-- .../shopinbit/shopinbit_car_fee_view.dart | 55 +- .../shopinbit_car_research_payment_view.dart | 83 +- .../shopinbit_confirm_send_view.dart | 31 +- lib/pages/shopinbit/shopinbit_offer_view.dart | 47 +- .../shopinbit/shopinbit_order_created.dart | 16 +- .../shopinbit/shopinbit_payment_shared.dart | 13 +- .../shopinbit/shopinbit_payment_view.dart | 18 +- .../shopinbit/shopinbit_send_from_view.dart | 21 +- .../shopinbit/shopinbit_settings_view.dart | 112 +- lib/pages/shopinbit/shopinbit_setup_view.dart | 59 +- .../shopinbit/shopinbit_shipping_view.dart | 42 +- lib/pages/shopinbit/shopinbit_step_1.dart | 167 - lib/pages/shopinbit/shopinbit_step_2.dart | 45 +- lib/pages/shopinbit/shopinbit_step_3.dart | 34 +- lib/pages/shopinbit/shopinbit_step_4.dart | 16 +- .../shopinbit/shopinbit_ticket_detail.dart | 182 +- .../shopinbit/shopinbit_tickets_view.dart | 198 +- .../shopinbit_car_research_form.dart | 73 +- .../shopinbit_concierge_form.dart | 45 +- .../shopinbit_generic_form.dart | 123 - .../shopinbit_step4_submit.dart | 55 +- .../shopinbit_travel_form.dart | 23 +- .../shopin_bit/desktop_shopinbit_view.dart | 93 +- .../desktop_shopin_bit_first_run.dart | 12 +- .../global/shopin_bit_orders_provider.dart | 9 - .../global/shopin_bit_service_provider.dart | 38 +- lib/route_generator.dart | 91 +- .../shopinbit/shopinbit_orders_service.dart | 197 -- lib/services/shopinbit/shopinbit_service.dart | 473 +-- lib/services/shopinbit/src/client.dart | 10 +- .../shopinbit/src/models/message.dart | 9 + lib/services/shopinbit/src/models/ticket.dart | 40 +- ...sted_navigator_dialog_route_generator.dart | 117 +- test/price_test.mocks.dart | 28 + .../change_now/change_now_test.mocks.dart | 28 + .../paynym/paynym_is_api_test.mocks.dart | 28 + .../car_research_persistence_test.dart | 97 - 47 files changed, 2909 insertions(+), 4148 deletions(-) create mode 100644 lib/models/shopinbit/shopinbit_enums.dart delete mode 100644 lib/models/shopinbit/shopinbit_order_model.dart create mode 100644 lib/models/shopinbit/shopinbit_request_draft.dart delete mode 100644 lib/pages/shopinbit/shopinbit_step_1.dart delete mode 100644 lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart delete mode 100644 lib/providers/global/shopin_bit_orders_provider.dart delete mode 100644 lib/services/shopinbit/shopinbit_orders_service.dart delete mode 100644 test/shopinbit/car_research_persistence_test.dart diff --git a/lib/db/drift/shared_db/shared_database.dart b/lib/db/drift/shared_db/shared_database.dart index fa6f94e53e..ec39151fd2 100644 --- a/lib/db/drift/shared_db/shared_database.dart +++ b/lib/db/drift/shared_db/shared_database.dart @@ -2,8 +2,8 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:path/path.dart' as path; -import '../../../models/shopinbit/shopinbit_order_model.dart' - show ShopInBitCategory, ShopInBitOrderStatus; +import "../../../models/shopinbit/shopinbit_enums.dart"; +import "../../../services/shopinbit/src/models/message.dart"; import '../../../utilities/stack_file_system.dart'; import 'tables/cakepay_orders.dart'; import 'tables/shopin_bit_settings.dart'; @@ -27,8 +27,8 @@ abstract final class SharedDrift { } @DriftDatabase( - tables: [CakepayOrders, ShopinBitSettings, ShopInBitTickets], - daos: [ShopinBitSettingsDao], + tables: [CakepayOrders, ShopInBitSettings, ShopInBitTickets], + daos: [ShopInBitSettingsDao, ShopInBitTicketsDao], ) final class SharedDatabase extends _$SharedDatabase { SharedDatabase._([QueryExecutor? executor]) @@ -41,7 +41,7 @@ final class SharedDatabase extends _$SharedDatabase { MigrationStrategy get migration => MigrationStrategy( onUpgrade: (m, from, to) async { if (from == 1 && to == 2) { - await m.createTable(shopinBitSettings); + await m.createTable(shopInBitSettings); await m.createTable(shopInBitTickets); } }, @@ -61,35 +61,183 @@ final class SharedDatabase extends _$SharedDatabase { } } -@DriftAccessor(tables: [ShopinBitSettings]) -class ShopinBitSettingsDao extends DatabaseAccessor - with _$ShopinBitSettingsDaoMixin { - ShopinBitSettingsDao(super.db); +@DriftAccessor(tables: [ShopInBitTickets]) +class ShopInBitTicketsDao extends DatabaseAccessor + with _$ShopInBitTicketsDaoMixin { + ShopInBitTicketsDao(super.db); - Future getSettings() async { - final ShopinBitSetting? row = await (select( - shopinBitSettings, - )..where((t) => t.id.equals(0))).getSingleOrNull(); - if (row != null) return row; + // -- Reads -- - return into( - shopinBitSettings, - ).insertReturning(ShopinBitSettingsCompanion.insert(id: const Value(0))); + Future getByApiId(int apiTicketId) { + return (select( + shopInBitTickets, + )..where((t) => t.apiTicketId.equals(apiTicketId))).getSingleOrNull(); } - Future setGuidelinesAccepted(bool accepted) => - _update(ShopinBitSettingsCompanion(guidelinesAccepted: Value(accepted))); + Stream watchByApiId(int apiTicketId) { + return (select( + shopInBitTickets, + )..where((t) => t.apiTicketId.equals(apiTicketId))).watchSingleOrNull(); + } + + Future> getByCustomerKey(String customerKey) { + return (select(shopInBitTickets) + ..where((t) => t.customerKey.equals(customerKey)) + ..orderBy([(t) => OrderingTerm.desc(t.createdAt)])) + .get(); + } + + /// All tickets for the active customer key, newest first. + Stream> watchByCustomerKey(String customerKey) { + return (select(shopInBitTickets) + ..where((t) => t.customerKey.equals(customerKey)) + ..orderBy([(t) => OrderingTerm.desc(t.createdAt)])) + .watch(); + } + + // -- Writes -- + + /// Insert a brand-new ticket. Caller must supply every required field; + /// pass nullable fields through the companion's `Value(...)` wrappers. + Future insertTicket(ShopInBitTicketsCompanion companion) async { + await into(shopInBitTickets).insert(companion); + } + + /// Patch an existing ticket. Use `Value.absent()` (the companion default) + /// for fields you don't want to touch. Returns true if a row was updated. + Future updateTicket( + int apiTicketId, + ShopInBitTicketsCompanion patch, + ) async { + final int rows = await (update( + shopInBitTickets, + )..where((t) => t.apiTicketId.equals(apiTicketId))).write(patch); + return rows > 0; + } + + Future deleteByApiId(int apiTicketId) { + return (delete( + shopInBitTickets, + )..where((t) => t.apiTicketId.equals(apiTicketId))).go(); + } + + Future deleteByCustomerKey(String customerKey) { + return (delete( + shopInBitTickets, + )..where((t) => t.customerKey.equals(customerKey))).go(); + } +} + +@DriftAccessor(tables: [ShopInBitSettings]) +class ShopInBitSettingsDao extends DatabaseAccessor + with _$ShopInBitSettingsDaoMixin { + ShopInBitSettingsDao(super.db); + + // -- "Current" (= most-recently-used) row -- + + /// Returns the settings row for the most-recently-used customer key, + /// or null if the user has never generated/recovered one. + Future getCurrentSettings() { + return (select(shopInBitSettings) + ..orderBy([(t) => OrderingTerm.desc(t.lastUsedAt)]) + ..limit(1)) + .getSingleOrNull(); + } + + Stream watchCurrentSettings() { + return (select(shopInBitSettings) + ..orderBy([(t) => OrderingTerm.desc(t.lastUsedAt)]) + ..limit(1)) + .watchSingleOrNull(); + } + + // -- Specific row by customer key -- + + Future getByKey(String customerKey) { + return (select( + shopInBitSettings, + )..where((t) => t.customerKey.equals(customerKey))).getSingleOrNull(); + } + + Stream watchByKey(String customerKey) { + return (select( + shopInBitSettings, + )..where((t) => t.customerKey.equals(customerKey))).watchSingleOrNull(); + } - Future setSetupComplete(bool complete) => - _update(ShopinBitSettingsCompanion(setupComplete: Value(complete))); + Stream> watchAll() { + return (select( + shopInBitSettings, + )..orderBy([(t) => OrderingTerm.desc(t.lastUsedAt)])).watch(); + } - Future setDisplayName(String name) => - _update(ShopinBitSettingsCompanion(displayName: Value(name))); + // -- Writes -- - Future _update(ShopinBitSettingsCompanion changes) async { - await getSettings(); // ensure row exists - await (update( - shopinBitSettings, - )..where((t) => t.id.equals(0))).write(changes); + /// Insert if missing, otherwise bump [lastUsedAt]. Returns the row. + Future upsert(String customerKey) { + final DateTime now = DateTime.now(); + return into(shopInBitSettings).insertReturning( + ShopInBitSettingsCompanion.insert( + customerKey: customerKey, + createdAt: Value(now), + lastUsedAt: Value(now), + ), + onConflict: DoUpdate( + (_) => ShopInBitSettingsCompanion(lastUsedAt: Value(now)), + target: [shopInBitSettings.customerKey], + ), + ); } + + Future touch(String customerKey) => _write( + customerKey, + ShopInBitSettingsCompanion(lastUsedAt: Value(DateTime.now())), + ); + + Future setPrivacyAccepted(String customerKey, bool value) => _write( + customerKey, + ShopInBitSettingsCompanion(privacyAccepted: Value(value)), + ); + + Future setGuidelinesAccepted( + String customerKey, + ShopInBitCategory category, + bool value, + ) { + final ShopInBitSettingsCompanion patch = switch (category) { + .concierge => ShopInBitSettingsCompanion( + conciergeGuidelinesAccepted: Value(value), + ), + .travel => ShopInBitSettingsCompanion( + travelGuidelinesAccepted: Value(value), + ), + .car => ShopInBitSettingsCompanion(carGuidelinesAccepted: Value(value)), + }; + return _write(customerKey, patch); + } + + Future setSetupComplete(String customerKey, bool value) => _write( + customerKey, + ShopInBitSettingsCompanion(setupComplete: Value(value)), + ); + + Future deleteByKey(String customerKey) { + return (delete( + shopInBitSettings, + )..where((t) => t.customerKey.equals(customerKey))).go(); + } + + Future _write(String customerKey, ShopInBitSettingsCompanion changes) { + return (update( + shopInBitSettings, + )..where((t) => t.customerKey.equals(customerKey))).write(changes); + } +} + +extension ShopInBitSettingGuidelines on ShopInBitSetting { + bool guidelinesAcceptedFor(ShopInBitCategory category) => switch (category) { + .concierge => conciergeGuidelinesAccepted, + .travel => travelGuidelinesAccepted, + .car => carGuidelinesAccepted, + }; } diff --git a/lib/db/drift/shared_db/shared_database.g.dart b/lib/db/drift/shared_db/shared_database.g.dart index 28c4c39910..67c9b7af63 100644 --- a/lib/db/drift/shared_db/shared_database.g.dart +++ b/lib/db/drift/shared_db/shared_database.g.dart @@ -165,36 +165,83 @@ class CakepayOrdersCompanion extends UpdateCompanion { } } -class $ShopinBitSettingsTable extends ShopinBitSettings - with TableInfo<$ShopinBitSettingsTable, ShopinBitSetting> { +class $ShopInBitSettingsTable extends ShopInBitSettings + with TableInfo<$ShopInBitSettingsTable, ShopInBitSetting> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $ShopinBitSettingsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); + $ShopInBitSettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _customerKeyMeta = const VerificationMeta( + 'customerKey', + ); @override - late final GeneratedColumn id = GeneratedColumn( - 'id', + late final GeneratedColumn customerKey = GeneratedColumn( + 'customer_key', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _privacyAcceptedMeta = const VerificationMeta( + 'privacyAccepted', ); - static const VerificationMeta _guidelinesAcceptedMeta = - const VerificationMeta('guidelinesAccepted'); @override - late final GeneratedColumn guidelinesAccepted = GeneratedColumn( - 'guidelines_accepted', + late final GeneratedColumn privacyAccepted = GeneratedColumn( + 'privacy_accepted', aliasedName, false, type: DriftSqlType.bool, requiredDuringInsert: false, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("guidelines_accepted" IN (0, 1))', + 'CHECK ("privacy_accepted" IN (0, 1))', ), defaultValue: const Constant(false), ); + static const VerificationMeta _conciergeGuidelinesAcceptedMeta = + const VerificationMeta('conciergeGuidelinesAccepted'); + @override + late final GeneratedColumn conciergeGuidelinesAccepted = + GeneratedColumn( + 'concierge_guidelines_accepted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("concierge_guidelines_accepted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _travelGuidelinesAcceptedMeta = + const VerificationMeta('travelGuidelinesAccepted'); + @override + late final GeneratedColumn travelGuidelinesAccepted = + GeneratedColumn( + 'travel_guidelines_accepted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("travel_guidelines_accepted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _carGuidelinesAcceptedMeta = + const VerificationMeta('carGuidelinesAccepted'); + @override + late final GeneratedColumn carGuidelinesAccepted = + GeneratedColumn( + 'car_guidelines_accepted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("car_guidelines_accepted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); static const VerificationMeta _setupCompleteMeta = const VerificationMeta( 'setupComplete', ); @@ -210,45 +257,97 @@ class $ShopinBitSettingsTable extends ShopinBitSettings ), defaultValue: const Constant(false), ); - static const VerificationMeta _displayNameMeta = const VerificationMeta( - 'displayName', + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', ); @override - late final GeneratedColumn displayName = GeneratedColumn( - 'display_name', + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, - true, - type: DriftSqlType.string, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _lastUsedAtMeta = const VerificationMeta( + 'lastUsedAt', + ); + @override + late final GeneratedColumn lastUsedAt = GeneratedColumn( + 'last_used_at', + aliasedName, + false, + type: DriftSqlType.dateTime, requiredDuringInsert: false, + defaultValue: currentDateAndTime, ); @override List get $columns => [ - id, - guidelinesAccepted, + customerKey, + privacyAccepted, + conciergeGuidelinesAccepted, + travelGuidelinesAccepted, + carGuidelinesAccepted, setupComplete, - displayName, + createdAt, + lastUsedAt, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'shopin_bit_settings'; + static const String $name = 'shop_in_bit_settings'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + if (data.containsKey('customer_key')) { + context.handle( + _customerKeyMeta, + customerKey.isAcceptableOrUnknown( + data['customer_key']!, + _customerKeyMeta, + ), + ); + } else if (isInserting) { + context.missing(_customerKeyMeta); + } + if (data.containsKey('privacy_accepted')) { + context.handle( + _privacyAcceptedMeta, + privacyAccepted.isAcceptableOrUnknown( + data['privacy_accepted']!, + _privacyAcceptedMeta, + ), + ); } - if (data.containsKey('guidelines_accepted')) { + if (data.containsKey('concierge_guidelines_accepted')) { context.handle( - _guidelinesAcceptedMeta, - guidelinesAccepted.isAcceptableOrUnknown( - data['guidelines_accepted']!, - _guidelinesAcceptedMeta, + _conciergeGuidelinesAcceptedMeta, + conciergeGuidelinesAccepted.isAcceptableOrUnknown( + data['concierge_guidelines_accepted']!, + _conciergeGuidelinesAcceptedMeta, + ), + ); + } + if (data.containsKey('travel_guidelines_accepted')) { + context.handle( + _travelGuidelinesAcceptedMeta, + travelGuidelinesAccepted.isAcceptableOrUnknown( + data['travel_guidelines_accepted']!, + _travelGuidelinesAcceptedMeta, + ), + ); + } + if (data.containsKey('car_guidelines_accepted')) { + context.handle( + _carGuidelinesAcceptedMeta, + carGuidelinesAccepted.isAcceptableOrUnknown( + data['car_guidelines_accepted']!, + _carGuidelinesAcceptedMeta, ), ); } @@ -261,12 +360,18 @@ class $ShopinBitSettingsTable extends ShopinBitSettings ), ); } - if (data.containsKey('display_name')) { + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } + if (data.containsKey('last_used_at')) { context.handle( - _displayNameMeta, - displayName.isAcceptableOrUnknown( - data['display_name']!, - _displayNameMeta, + _lastUsedAtMeta, + lastUsedAt.isAcceptableOrUnknown( + data['last_used_at']!, + _lastUsedAtMeta, ), ); } @@ -274,214 +379,362 @@ class $ShopinBitSettingsTable extends ShopinBitSettings } @override - Set get $primaryKey => {id}; + Set get $primaryKey => {customerKey}; @override - ShopinBitSetting map(Map data, {String? tablePrefix}) { + ShopInBitSetting map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return ShopinBitSetting( - id: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}id'], + return ShopInBitSetting( + customerKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}customer_key'], + )!, + privacyAccepted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}privacy_accepted'], )!, - guidelinesAccepted: attachedDatabase.typeMapping.read( + conciergeGuidelinesAccepted: attachedDatabase.typeMapping.read( DriftSqlType.bool, - data['${effectivePrefix}guidelines_accepted'], + data['${effectivePrefix}concierge_guidelines_accepted'], + )!, + travelGuidelinesAccepted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}travel_guidelines_accepted'], + )!, + carGuidelinesAccepted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}car_guidelines_accepted'], )!, setupComplete: attachedDatabase.typeMapping.read( DriftSqlType.bool, data['${effectivePrefix}setup_complete'], )!, - displayName: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}display_name'], - ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + lastUsedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_used_at'], + )!, ); } @override - $ShopinBitSettingsTable createAlias(String alias) { - return $ShopinBitSettingsTable(attachedDatabase, alias); + $ShopInBitSettingsTable createAlias(String alias) { + return $ShopInBitSettingsTable(attachedDatabase, alias); } + + @override + bool get withoutRowId => true; } -class ShopinBitSetting extends DataClass - implements Insertable { - final int id; - final bool guidelinesAccepted; +class ShopInBitSetting extends DataClass + implements Insertable { + final String customerKey; + final bool privacyAccepted; + final bool conciergeGuidelinesAccepted; + final bool travelGuidelinesAccepted; + final bool carGuidelinesAccepted; final bool setupComplete; - final String? displayName; - const ShopinBitSetting({ - required this.id, - required this.guidelinesAccepted, + final DateTime createdAt; + final DateTime lastUsedAt; + const ShopInBitSetting({ + required this.customerKey, + required this.privacyAccepted, + required this.conciergeGuidelinesAccepted, + required this.travelGuidelinesAccepted, + required this.carGuidelinesAccepted, required this.setupComplete, - this.displayName, + required this.createdAt, + required this.lastUsedAt, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['id'] = Variable(id); - map['guidelines_accepted'] = Variable(guidelinesAccepted); + map['customer_key'] = Variable(customerKey); + map['privacy_accepted'] = Variable(privacyAccepted); + map['concierge_guidelines_accepted'] = Variable( + conciergeGuidelinesAccepted, + ); + map['travel_guidelines_accepted'] = Variable( + travelGuidelinesAccepted, + ); + map['car_guidelines_accepted'] = Variable(carGuidelinesAccepted); map['setup_complete'] = Variable(setupComplete); - if (!nullToAbsent || displayName != null) { - map['display_name'] = Variable(displayName); - } + map['created_at'] = Variable(createdAt); + map['last_used_at'] = Variable(lastUsedAt); return map; } - ShopinBitSettingsCompanion toCompanion(bool nullToAbsent) { - return ShopinBitSettingsCompanion( - id: Value(id), - guidelinesAccepted: Value(guidelinesAccepted), + ShopInBitSettingsCompanion toCompanion(bool nullToAbsent) { + return ShopInBitSettingsCompanion( + customerKey: Value(customerKey), + privacyAccepted: Value(privacyAccepted), + conciergeGuidelinesAccepted: Value(conciergeGuidelinesAccepted), + travelGuidelinesAccepted: Value(travelGuidelinesAccepted), + carGuidelinesAccepted: Value(carGuidelinesAccepted), setupComplete: Value(setupComplete), - displayName: displayName == null && nullToAbsent - ? const Value.absent() - : Value(displayName), + createdAt: Value(createdAt), + lastUsedAt: Value(lastUsedAt), ); } - factory ShopinBitSetting.fromJson( + factory ShopInBitSetting.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return ShopinBitSetting( - id: serializer.fromJson(json['id']), - guidelinesAccepted: serializer.fromJson(json['guidelinesAccepted']), + return ShopInBitSetting( + customerKey: serializer.fromJson(json['customerKey']), + privacyAccepted: serializer.fromJson(json['privacyAccepted']), + conciergeGuidelinesAccepted: serializer.fromJson( + json['conciergeGuidelinesAccepted'], + ), + travelGuidelinesAccepted: serializer.fromJson( + json['travelGuidelinesAccepted'], + ), + carGuidelinesAccepted: serializer.fromJson( + json['carGuidelinesAccepted'], + ), setupComplete: serializer.fromJson(json['setupComplete']), - displayName: serializer.fromJson(json['displayName']), + createdAt: serializer.fromJson(json['createdAt']), + lastUsedAt: serializer.fromJson(json['lastUsedAt']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'id': serializer.toJson(id), - 'guidelinesAccepted': serializer.toJson(guidelinesAccepted), + 'customerKey': serializer.toJson(customerKey), + 'privacyAccepted': serializer.toJson(privacyAccepted), + 'conciergeGuidelinesAccepted': serializer.toJson( + conciergeGuidelinesAccepted, + ), + 'travelGuidelinesAccepted': serializer.toJson( + travelGuidelinesAccepted, + ), + 'carGuidelinesAccepted': serializer.toJson(carGuidelinesAccepted), 'setupComplete': serializer.toJson(setupComplete), - 'displayName': serializer.toJson(displayName), + 'createdAt': serializer.toJson(createdAt), + 'lastUsedAt': serializer.toJson(lastUsedAt), }; } - ShopinBitSetting copyWith({ - int? id, - bool? guidelinesAccepted, + ShopInBitSetting copyWith({ + String? customerKey, + bool? privacyAccepted, + bool? conciergeGuidelinesAccepted, + bool? travelGuidelinesAccepted, + bool? carGuidelinesAccepted, bool? setupComplete, - Value displayName = const Value.absent(), - }) => ShopinBitSetting( - id: id ?? this.id, - guidelinesAccepted: guidelinesAccepted ?? this.guidelinesAccepted, + DateTime? createdAt, + DateTime? lastUsedAt, + }) => ShopInBitSetting( + customerKey: customerKey ?? this.customerKey, + privacyAccepted: privacyAccepted ?? this.privacyAccepted, + conciergeGuidelinesAccepted: + conciergeGuidelinesAccepted ?? this.conciergeGuidelinesAccepted, + travelGuidelinesAccepted: + travelGuidelinesAccepted ?? this.travelGuidelinesAccepted, + carGuidelinesAccepted: carGuidelinesAccepted ?? this.carGuidelinesAccepted, setupComplete: setupComplete ?? this.setupComplete, - displayName: displayName.present ? displayName.value : this.displayName, - ); - ShopinBitSetting copyWithCompanion(ShopinBitSettingsCompanion data) { - return ShopinBitSetting( - id: data.id.present ? data.id.value : this.id, - guidelinesAccepted: data.guidelinesAccepted.present - ? data.guidelinesAccepted.value - : this.guidelinesAccepted, + createdAt: createdAt ?? this.createdAt, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, + ); + ShopInBitSetting copyWithCompanion(ShopInBitSettingsCompanion data) { + return ShopInBitSetting( + customerKey: data.customerKey.present + ? data.customerKey.value + : this.customerKey, + privacyAccepted: data.privacyAccepted.present + ? data.privacyAccepted.value + : this.privacyAccepted, + conciergeGuidelinesAccepted: data.conciergeGuidelinesAccepted.present + ? data.conciergeGuidelinesAccepted.value + : this.conciergeGuidelinesAccepted, + travelGuidelinesAccepted: data.travelGuidelinesAccepted.present + ? data.travelGuidelinesAccepted.value + : this.travelGuidelinesAccepted, + carGuidelinesAccepted: data.carGuidelinesAccepted.present + ? data.carGuidelinesAccepted.value + : this.carGuidelinesAccepted, setupComplete: data.setupComplete.present ? data.setupComplete.value : this.setupComplete, - displayName: data.displayName.present - ? data.displayName.value - : this.displayName, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + lastUsedAt: data.lastUsedAt.present + ? data.lastUsedAt.value + : this.lastUsedAt, ); } @override String toString() { - return (StringBuffer('ShopinBitSetting(') - ..write('id: $id, ') - ..write('guidelinesAccepted: $guidelinesAccepted, ') + return (StringBuffer('ShopInBitSetting(') + ..write('customerKey: $customerKey, ') + ..write('privacyAccepted: $privacyAccepted, ') + ..write('conciergeGuidelinesAccepted: $conciergeGuidelinesAccepted, ') + ..write('travelGuidelinesAccepted: $travelGuidelinesAccepted, ') + ..write('carGuidelinesAccepted: $carGuidelinesAccepted, ') ..write('setupComplete: $setupComplete, ') - ..write('displayName: $displayName') + ..write('createdAt: $createdAt, ') + ..write('lastUsedAt: $lastUsedAt') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(id, guidelinesAccepted, setupComplete, displayName); + int get hashCode => Object.hash( + customerKey, + privacyAccepted, + conciergeGuidelinesAccepted, + travelGuidelinesAccepted, + carGuidelinesAccepted, + setupComplete, + createdAt, + lastUsedAt, + ); @override bool operator ==(Object other) => identical(this, other) || - (other is ShopinBitSetting && - other.id == this.id && - other.guidelinesAccepted == this.guidelinesAccepted && + (other is ShopInBitSetting && + other.customerKey == this.customerKey && + other.privacyAccepted == this.privacyAccepted && + other.conciergeGuidelinesAccepted == + this.conciergeGuidelinesAccepted && + other.travelGuidelinesAccepted == this.travelGuidelinesAccepted && + other.carGuidelinesAccepted == this.carGuidelinesAccepted && other.setupComplete == this.setupComplete && - other.displayName == this.displayName); + other.createdAt == this.createdAt && + other.lastUsedAt == this.lastUsedAt); } -class ShopinBitSettingsCompanion extends UpdateCompanion { - final Value id; - final Value guidelinesAccepted; +class ShopInBitSettingsCompanion extends UpdateCompanion { + final Value customerKey; + final Value privacyAccepted; + final Value conciergeGuidelinesAccepted; + final Value travelGuidelinesAccepted; + final Value carGuidelinesAccepted; final Value setupComplete; - final Value displayName; - const ShopinBitSettingsCompanion({ - this.id = const Value.absent(), - this.guidelinesAccepted = const Value.absent(), + final Value createdAt; + final Value lastUsedAt; + const ShopInBitSettingsCompanion({ + this.customerKey = const Value.absent(), + this.privacyAccepted = const Value.absent(), + this.conciergeGuidelinesAccepted = const Value.absent(), + this.travelGuidelinesAccepted = const Value.absent(), + this.carGuidelinesAccepted = const Value.absent(), this.setupComplete = const Value.absent(), - this.displayName = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastUsedAt = const Value.absent(), }); - ShopinBitSettingsCompanion.insert({ - this.id = const Value.absent(), - this.guidelinesAccepted = const Value.absent(), + ShopInBitSettingsCompanion.insert({ + required String customerKey, + this.privacyAccepted = const Value.absent(), + this.conciergeGuidelinesAccepted = const Value.absent(), + this.travelGuidelinesAccepted = const Value.absent(), + this.carGuidelinesAccepted = const Value.absent(), this.setupComplete = const Value.absent(), - this.displayName = const Value.absent(), - }); - static Insertable custom({ - Expression? id, - Expression? guidelinesAccepted, + this.createdAt = const Value.absent(), + this.lastUsedAt = const Value.absent(), + }) : customerKey = Value(customerKey); + static Insertable custom({ + Expression? customerKey, + Expression? privacyAccepted, + Expression? conciergeGuidelinesAccepted, + Expression? travelGuidelinesAccepted, + Expression? carGuidelinesAccepted, Expression? setupComplete, - Expression? displayName, + Expression? createdAt, + Expression? lastUsedAt, }) { return RawValuesInsertable({ - if (id != null) 'id': id, - if (guidelinesAccepted != null) 'guidelines_accepted': guidelinesAccepted, + if (customerKey != null) 'customer_key': customerKey, + if (privacyAccepted != null) 'privacy_accepted': privacyAccepted, + if (conciergeGuidelinesAccepted != null) + 'concierge_guidelines_accepted': conciergeGuidelinesAccepted, + if (travelGuidelinesAccepted != null) + 'travel_guidelines_accepted': travelGuidelinesAccepted, + if (carGuidelinesAccepted != null) + 'car_guidelines_accepted': carGuidelinesAccepted, if (setupComplete != null) 'setup_complete': setupComplete, - if (displayName != null) 'display_name': displayName, + if (createdAt != null) 'created_at': createdAt, + if (lastUsedAt != null) 'last_used_at': lastUsedAt, }); } - ShopinBitSettingsCompanion copyWith({ - Value? id, - Value? guidelinesAccepted, + ShopInBitSettingsCompanion copyWith({ + Value? customerKey, + Value? privacyAccepted, + Value? conciergeGuidelinesAccepted, + Value? travelGuidelinesAccepted, + Value? carGuidelinesAccepted, Value? setupComplete, - Value? displayName, + Value? createdAt, + Value? lastUsedAt, }) { - return ShopinBitSettingsCompanion( - id: id ?? this.id, - guidelinesAccepted: guidelinesAccepted ?? this.guidelinesAccepted, + return ShopInBitSettingsCompanion( + customerKey: customerKey ?? this.customerKey, + privacyAccepted: privacyAccepted ?? this.privacyAccepted, + conciergeGuidelinesAccepted: + conciergeGuidelinesAccepted ?? this.conciergeGuidelinesAccepted, + travelGuidelinesAccepted: + travelGuidelinesAccepted ?? this.travelGuidelinesAccepted, + carGuidelinesAccepted: + carGuidelinesAccepted ?? this.carGuidelinesAccepted, setupComplete: setupComplete ?? this.setupComplete, - displayName: displayName ?? this.displayName, + createdAt: createdAt ?? this.createdAt, + lastUsedAt: lastUsedAt ?? this.lastUsedAt, ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (id.present) { - map['id'] = Variable(id.value); + if (customerKey.present) { + map['customer_key'] = Variable(customerKey.value); + } + if (privacyAccepted.present) { + map['privacy_accepted'] = Variable(privacyAccepted.value); + } + if (conciergeGuidelinesAccepted.present) { + map['concierge_guidelines_accepted'] = Variable( + conciergeGuidelinesAccepted.value, + ); + } + if (travelGuidelinesAccepted.present) { + map['travel_guidelines_accepted'] = Variable( + travelGuidelinesAccepted.value, + ); } - if (guidelinesAccepted.present) { - map['guidelines_accepted'] = Variable(guidelinesAccepted.value); + if (carGuidelinesAccepted.present) { + map['car_guidelines_accepted'] = Variable( + carGuidelinesAccepted.value, + ); } if (setupComplete.present) { map['setup_complete'] = Variable(setupComplete.value); } - if (displayName.present) { - map['display_name'] = Variable(displayName.value); + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (lastUsedAt.present) { + map['last_used_at'] = Variable(lastUsedAt.value); } return map; } @override String toString() { - return (StringBuffer('ShopinBitSettingsCompanion(') - ..write('id: $id, ') - ..write('guidelinesAccepted: $guidelinesAccepted, ') + return (StringBuffer('ShopInBitSettingsCompanion(') + ..write('customerKey: $customerKey, ') + ..write('privacyAccepted: $privacyAccepted, ') + ..write('conciergeGuidelinesAccepted: $conciergeGuidelinesAccepted, ') + ..write('travelGuidelinesAccepted: $travelGuidelinesAccepted, ') + ..write('carGuidelinesAccepted: $carGuidelinesAccepted, ') ..write('setupComplete: $setupComplete, ') - ..write('displayName: $displayName') + ..write('createdAt: $createdAt, ') + ..write('lastUsedAt: $lastUsedAt') ..write(')')) .toString(); } @@ -493,62 +746,48 @@ class $ShopInBitTicketsTable extends ShopInBitTickets final GeneratedDatabase attachedDatabase; final String? _alias; $ShopInBitTicketsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _ticketIdMeta = const VerificationMeta( - 'ticketId', + static const VerificationMeta _apiTicketIdMeta = const VerificationMeta( + 'apiTicketId', ); @override - late final GeneratedColumn ticketId = GeneratedColumn( - 'ticket_id', + late final GeneratedColumn apiTicketId = GeneratedColumn( + 'api_ticket_id', aliasedName, false, - type: DriftSqlType.string, + type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _displayNameMeta = const VerificationMeta( - 'displayName', + static const VerificationMeta _customerKeyMeta = const VerificationMeta( + 'customerKey', ); @override - late final GeneratedColumn displayName = GeneratedColumn( - 'display_name', + late final GeneratedColumn customerKey = GeneratedColumn( + 'customer_key', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - @override - late final GeneratedColumnWithTypeConverter category = - GeneratedColumn( - 'category', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ).withConverter( - $ShopInBitTicketsTable.$convertercategory, - ); - @override - late final GeneratedColumnWithTypeConverter - status = - GeneratedColumn( - 'status', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ).withConverter( - $ShopInBitTicketsTable.$converterstatus, - ); - static const VerificationMeta _statusRawMeta = const VerificationMeta( - 'statusRaw', + static const VerificationMeta _ticketNumberMeta = const VerificationMeta( + 'ticketNumber', ); @override - late final GeneratedColumn statusRaw = GeneratedColumn( - 'status_raw', + late final GeneratedColumn ticketNumber = GeneratedColumn( + 'ticket_number', aliasedName, - true, + false, type: DriftSqlType.string, - requiredDuringInsert: false, + requiredDuringInsert: true, ); + @override + late final GeneratedColumnWithTypeConverter + category = GeneratedColumn( + 'category', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($ShopInBitTicketsTable.$convertercategory); static const VerificationMeta _requestDescriptionMeta = const VerificationMeta('requestDescription'); @override @@ -571,6 +810,29 @@ class $ShopInBitTicketsTable extends ShopInBitTickets type: DriftSqlType.string, requiredDuringInsert: true, ); + @override + late final GeneratedColumnWithTypeConverter + status = + GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter( + $ShopInBitTicketsTable.$converterstatus, + ); + static const VerificationMeta _statusRawMeta = const VerificationMeta( + 'statusRaw', + ); + @override + late final GeneratedColumn statusRaw = GeneratedColumn( + 'status_raw', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); static const VerificationMeta _offerProductNameMeta = const VerificationMeta( 'offerProductName', ); @@ -593,85 +855,61 @@ class $ShopInBitTicketsTable extends ShopInBitTickets type: DriftSqlType.string, requiredDuringInsert: false, ); - static const VerificationMeta _shippingNameMeta = const VerificationMeta( - 'shippingName', - ); - @override - late final GeneratedColumn shippingName = GeneratedColumn( - 'shipping_name', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _shippingStreetMeta = const VerificationMeta( - 'shippingStreet', - ); - @override - late final GeneratedColumn shippingStreet = GeneratedColumn( - 'shipping_street', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _shippingCityMeta = const VerificationMeta( - 'shippingCity', - ); - @override - late final GeneratedColumn shippingCity = GeneratedColumn( - 'shipping_city', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _shippingPostalCodeMeta = - const VerificationMeta('shippingPostalCode'); + static const VerificationMeta _paymentInvoiceStatusMeta = + const VerificationMeta('paymentInvoiceStatus'); @override - late final GeneratedColumn shippingPostalCode = + late final GeneratedColumn paymentInvoiceStatus = GeneratedColumn( - 'shipping_postal_code', + 'payment_invoice_status', aliasedName, - false, + true, type: DriftSqlType.string, - requiredDuringInsert: true, + requiredDuringInsert: false, ); - static const VerificationMeta _shippingCountryMeta = const VerificationMeta( - 'shippingCountry', + static const VerificationMeta _trackingLinkMeta = const VerificationMeta( + 'trackingLink', ); @override - late final GeneratedColumn shippingCountry = GeneratedColumn( - 'shipping_country', + late final GeneratedColumn trackingLink = GeneratedColumn( + 'tracking_link', aliasedName, - false, + true, type: DriftSqlType.string, - requiredDuringInsert: true, + requiredDuringInsert: false, ); - static const VerificationMeta _paymentMethodMeta = const VerificationMeta( - 'paymentMethod', + static const VerificationMeta _lastAgentMessageAtMeta = + const VerificationMeta('lastAgentMessageAt'); + @override + late final GeneratedColumn lastAgentMessageAt = + GeneratedColumn( + 'last_agent_message_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _feeTicketNumberMeta = const VerificationMeta( + 'feeTicketNumber', ); @override - late final GeneratedColumn paymentMethod = GeneratedColumn( - 'payment_method', + late final GeneratedColumn feeTicketNumber = GeneratedColumn( + 'fee_ticket_number', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, ); @override - late final GeneratedColumnWithTypeConverter< - List, - String - > + late final GeneratedColumnWithTypeConverter, String> messages = GeneratedColumn( 'messages', aliasedName, false, type: DriftSqlType.string, - requiredDuringInsert: true, - ).withConverter>( + requiredDuringInsert: false, + defaultValue: const Constant("[]"), + ).withConverter>( $ShopInBitTicketsTable.$convertermessages, ); static const VerificationMeta _createdAtMeta = const VerificationMeta( @@ -683,116 +921,40 @@ class $ShopInBitTicketsTable extends ShopInBitTickets aliasedName, false, type: DriftSqlType.dateTime, - requiredDuringInsert: true, - ); - static const VerificationMeta _apiTicketIdMeta = const VerificationMeta( - 'apiTicketId', - ); - @override - late final GeneratedColumn apiTicketId = GeneratedColumn( - 'api_ticket_id', - aliasedName, - false, - type: DriftSqlType.int, - requiredDuringInsert: true, - ); - static const VerificationMeta _carResearchInvoiceIdMeta = - const VerificationMeta('carResearchInvoiceId'); - @override - late final GeneratedColumn carResearchInvoiceId = - GeneratedColumn( - 'car_research_invoice_id', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); - static const VerificationMeta _feeTicketNumberMeta = const VerificationMeta( - 'feeTicketNumber', - ); - @override - late final GeneratedColumn feeTicketNumber = GeneratedColumn( - 'fee_ticket_number', - aliasedName, - true, - type: DriftSqlType.string, requiredDuringInsert: false, + defaultValue: currentDateAndTime, ); - static const VerificationMeta _needsCreateRequestMeta = - const VerificationMeta('needsCreateRequest'); - @override - late final GeneratedColumn needsCreateRequest = GeneratedColumn( - 'needs_create_request', - aliasedName, - false, - type: DriftSqlType.bool, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("needs_create_request" IN (0, 1))', - ), - ); - static const VerificationMeta _isPendingPaymentMeta = const VerificationMeta( - 'isPendingPayment', + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', ); @override - late final GeneratedColumn isPendingPayment = GeneratedColumn( - 'is_pending_payment', + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("is_pending_payment" IN (0, 1))', - ), + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, ); - static const VerificationMeta _carResearchExpiresAtMeta = - const VerificationMeta('carResearchExpiresAt'); - @override - late final GeneratedColumn carResearchExpiresAt = - GeneratedColumn( - 'car_research_expires_at', - aliasedName, - true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - ); - static const VerificationMeta _carResearchPaymentLinksMeta = - const VerificationMeta('carResearchPaymentLinks'); - @override - late final GeneratedColumn carResearchPaymentLinks = - GeneratedColumn( - 'car_research_payment_links', - aliasedName, - true, - type: DriftSqlType.string, - requiredDuringInsert: false, - ); @override List get $columns => [ - ticketId, - displayName, + apiTicketId, + customerKey, + ticketNumber, category, - status, - statusRaw, requestDescription, deliveryCountry, + status, + statusRaw, offerProductName, offerPrice, - shippingName, - shippingStreet, - shippingCity, - shippingPostalCode, - shippingCountry, - paymentMethod, + paymentInvoiceStatus, + trackingLink, + lastAgentMessageAt, + feeTicketNumber, messages, createdAt, - apiTicketId, - carResearchInvoiceId, - feeTicketNumber, - needsCreateRequest, - isPendingPayment, - carResearchExpiresAt, - carResearchPaymentLinks, + updatedAt, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -806,30 +968,38 @@ class $ShopInBitTicketsTable extends ShopInBitTickets }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('ticket_id')) { + if (data.containsKey('api_ticket_id')) { context.handle( - _ticketIdMeta, - ticketId.isAcceptableOrUnknown(data['ticket_id']!, _ticketIdMeta), + _apiTicketIdMeta, + apiTicketId.isAcceptableOrUnknown( + data['api_ticket_id']!, + _apiTicketIdMeta, + ), ); } else if (isInserting) { - context.missing(_ticketIdMeta); + context.missing(_apiTicketIdMeta); } - if (data.containsKey('display_name')) { + if (data.containsKey('customer_key')) { context.handle( - _displayNameMeta, - displayName.isAcceptableOrUnknown( - data['display_name']!, - _displayNameMeta, + _customerKeyMeta, + customerKey.isAcceptableOrUnknown( + data['customer_key']!, + _customerKeyMeta, ), ); } else if (isInserting) { - context.missing(_displayNameMeta); + context.missing(_customerKeyMeta); } - if (data.containsKey('status_raw')) { + if (data.containsKey('ticket_number')) { context.handle( - _statusRawMeta, - statusRaw.isAcceptableOrUnknown(data['status_raw']!, _statusRawMeta), + _ticketNumberMeta, + ticketNumber.isAcceptableOrUnknown( + data['ticket_number']!, + _ticketNumberMeta, + ), ); + } else if (isInserting) { + context.missing(_ticketNumberMeta); } if (data.containsKey('request_description')) { context.handle( @@ -853,6 +1023,14 @@ class $ShopInBitTicketsTable extends ShopInBitTickets } else if (isInserting) { context.missing(_deliveryCountryMeta); } + if (data.containsKey('status_raw')) { + context.handle( + _statusRawMeta, + statusRaw.isAcceptableOrUnknown(data['status_raw']!, _statusRawMeta), + ); + } else if (isInserting) { + context.missing(_statusRawMeta); + } if (data.containsKey('offer_product_name')) { context.handle( _offerProductNameMeta, @@ -868,95 +1046,30 @@ class $ShopInBitTicketsTable extends ShopInBitTickets offerPrice.isAcceptableOrUnknown(data['offer_price']!, _offerPriceMeta), ); } - if (data.containsKey('shipping_name')) { + if (data.containsKey('payment_invoice_status')) { context.handle( - _shippingNameMeta, - shippingName.isAcceptableOrUnknown( - data['shipping_name']!, - _shippingNameMeta, + _paymentInvoiceStatusMeta, + paymentInvoiceStatus.isAcceptableOrUnknown( + data['payment_invoice_status']!, + _paymentInvoiceStatusMeta, ), ); - } else if (isInserting) { - context.missing(_shippingNameMeta); - } - if (data.containsKey('shipping_street')) { - context.handle( - _shippingStreetMeta, - shippingStreet.isAcceptableOrUnknown( - data['shipping_street']!, - _shippingStreetMeta, - ), - ); - } else if (isInserting) { - context.missing(_shippingStreetMeta); - } - if (data.containsKey('shipping_city')) { - context.handle( - _shippingCityMeta, - shippingCity.isAcceptableOrUnknown( - data['shipping_city']!, - _shippingCityMeta, - ), - ); - } else if (isInserting) { - context.missing(_shippingCityMeta); - } - if (data.containsKey('shipping_postal_code')) { - context.handle( - _shippingPostalCodeMeta, - shippingPostalCode.isAcceptableOrUnknown( - data['shipping_postal_code']!, - _shippingPostalCodeMeta, - ), - ); - } else if (isInserting) { - context.missing(_shippingPostalCodeMeta); - } - if (data.containsKey('shipping_country')) { - context.handle( - _shippingCountryMeta, - shippingCountry.isAcceptableOrUnknown( - data['shipping_country']!, - _shippingCountryMeta, - ), - ); - } else if (isInserting) { - context.missing(_shippingCountryMeta); - } - if (data.containsKey('payment_method')) { - context.handle( - _paymentMethodMeta, - paymentMethod.isAcceptableOrUnknown( - data['payment_method']!, - _paymentMethodMeta, - ), - ); - } - if (data.containsKey('created_at')) { - context.handle( - _createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), - ); - } else if (isInserting) { - context.missing(_createdAtMeta); } - if (data.containsKey('api_ticket_id')) { + if (data.containsKey('tracking_link')) { context.handle( - _apiTicketIdMeta, - apiTicketId.isAcceptableOrUnknown( - data['api_ticket_id']!, - _apiTicketIdMeta, + _trackingLinkMeta, + trackingLink.isAcceptableOrUnknown( + data['tracking_link']!, + _trackingLinkMeta, ), ); - } else if (isInserting) { - context.missing(_apiTicketIdMeta); } - if (data.containsKey('car_research_invoice_id')) { + if (data.containsKey('last_agent_message_at')) { context.handle( - _carResearchInvoiceIdMeta, - carResearchInvoiceId.isAcceptableOrUnknown( - data['car_research_invoice_id']!, - _carResearchInvoiceIdMeta, + _lastAgentMessageAtMeta, + lastAgentMessageAt.isAcceptableOrUnknown( + data['last_agent_message_at']!, + _lastAgentMessageAtMeta, ), ); } @@ -969,86 +1082,62 @@ class $ShopInBitTicketsTable extends ShopInBitTickets ), ); } - if (data.containsKey('needs_create_request')) { - context.handle( - _needsCreateRequestMeta, - needsCreateRequest.isAcceptableOrUnknown( - data['needs_create_request']!, - _needsCreateRequestMeta, - ), - ); - } else if (isInserting) { - context.missing(_needsCreateRequestMeta); - } - if (data.containsKey('is_pending_payment')) { - context.handle( - _isPendingPaymentMeta, - isPendingPayment.isAcceptableOrUnknown( - data['is_pending_payment']!, - _isPendingPaymentMeta, - ), - ); - } else if (isInserting) { - context.missing(_isPendingPaymentMeta); - } - if (data.containsKey('car_research_expires_at')) { + if (data.containsKey('created_at')) { context.handle( - _carResearchExpiresAtMeta, - carResearchExpiresAt.isAcceptableOrUnknown( - data['car_research_expires_at']!, - _carResearchExpiresAtMeta, - ), + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), ); } - if (data.containsKey('car_research_payment_links')) { + if (data.containsKey('updated_at')) { context.handle( - _carResearchPaymentLinksMeta, - carResearchPaymentLinks.isAcceptableOrUnknown( - data['car_research_payment_links']!, - _carResearchPaymentLinksMeta, - ), + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), ); } return context; } @override - Set get $primaryKey => {ticketId}; + Set get $primaryKey => {apiTicketId}; @override ShopInBitTicket map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return ShopInBitTicket( - ticketId: attachedDatabase.typeMapping.read( + apiTicketId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}api_ticket_id'], + )!, + customerKey: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}ticket_id'], + data['${effectivePrefix}customer_key'], )!, - displayName: attachedDatabase.typeMapping.read( + ticketNumber: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}display_name'], + data['${effectivePrefix}ticket_number'], )!, category: $ShopInBitTicketsTable.$convertercategory.fromSql( attachedDatabase.typeMapping.read( - DriftSqlType.int, + DriftSqlType.string, data['${effectivePrefix}category'], )!, ), - status: $ShopInBitTicketsTable.$converterstatus.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}status'], - )!, - ), - statusRaw: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}status_raw'], - ), requestDescription: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}request_description'], )!, deliveryCountry: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}delivery_country'], + data['${effectivePrefix}delivery_country'], + )!, + status: $ShopInBitTicketsTable.$converterstatus.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + ), + statusRaw: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status_raw'], )!, offerProductName: attachedDatabase.typeMapping.read( DriftSqlType.string, @@ -1058,29 +1147,21 @@ class $ShopInBitTicketsTable extends ShopInBitTickets DriftSqlType.string, data['${effectivePrefix}offer_price'], ), - shippingName: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}shipping_name'], - )!, - shippingStreet: attachedDatabase.typeMapping.read( + paymentInvoiceStatus: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}shipping_street'], - )!, - shippingCity: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}shipping_city'], - )!, - shippingPostalCode: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}shipping_postal_code'], - )!, - shippingCountry: attachedDatabase.typeMapping.read( + data['${effectivePrefix}payment_invoice_status'], + ), + trackingLink: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}shipping_country'], - )!, - paymentMethod: attachedDatabase.typeMapping.read( + data['${effectivePrefix}tracking_link'], + ), + lastAgentMessageAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_agent_message_at'], + ), + feeTicketNumber: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}payment_method'], + data['${effectivePrefix}fee_ticket_number'], ), messages: $ShopInBitTicketsTable.$convertermessages.fromSql( attachedDatabase.typeMapping.read( @@ -1092,34 +1173,10 @@ class $ShopInBitTicketsTable extends ShopInBitTickets DriftSqlType.dateTime, data['${effectivePrefix}created_at'], )!, - apiTicketId: attachedDatabase.typeMapping.read( - DriftSqlType.int, - data['${effectivePrefix}api_ticket_id'], - )!, - carResearchInvoiceId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}car_research_invoice_id'], - ), - feeTicketNumber: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}fee_ticket_number'], - ), - needsCreateRequest: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}needs_create_request'], - )!, - isPendingPayment: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}is_pending_payment'], - )!, - carResearchExpiresAt: attachedDatabase.typeMapping.read( + updatedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, - data['${effectivePrefix}car_research_expires_at'], - ), - carResearchPaymentLinks: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}car_research_payment_links'], - ), + data['${effectivePrefix}updated_at'], + )!, ); } @@ -1128,169 +1185,135 @@ class $ShopInBitTicketsTable extends ShopInBitTickets return $ShopInBitTicketsTable(attachedDatabase, alias); } - static JsonTypeConverter2 $convertercategory = - const EnumIndexConverter(ShopInBitCategory.values); - static JsonTypeConverter2 $converterstatus = - const EnumIndexConverter( - ShopInBitOrderStatus.values, - ); - static JsonTypeConverter2, String, List> - $convertermessages = const ShopInBitTicketMessagesConverter(); + static JsonTypeConverter2 + $convertercategory = const EnumNameConverter( + ShopInBitCategory.values, + ); + static JsonTypeConverter2 + $converterstatus = const EnumNameConverter( + ShopInBitOrderStatus.values, + ); + static TypeConverter, String> $convertermessages = + const MessagesConverter(); + @override + bool get withoutRowId => true; } class ShopInBitTicket extends DataClass implements Insertable { - final String ticketId; - final String displayName; + final int apiTicketId; + final String customerKey; + final String ticketNumber; final ShopInBitCategory category; - final ShopInBitOrderStatus status; - final String? statusRaw; final String requestDescription; final String deliveryCountry; + final ShopInBitOrderStatus status; + final String statusRaw; final String? offerProductName; final String? offerPrice; - final String shippingName; - final String shippingStreet; - final String shippingCity; - final String shippingPostalCode; - final String shippingCountry; - final String? paymentMethod; - final List messages; - final DateTime createdAt; - final int apiTicketId; - final String? carResearchInvoiceId; + final String? paymentInvoiceStatus; + final String? trackingLink; + final DateTime? lastAgentMessageAt; final String? feeTicketNumber; - final bool needsCreateRequest; - final bool isPendingPayment; - final DateTime? carResearchExpiresAt; - final String? carResearchPaymentLinks; + final List messages; + final DateTime createdAt; + final DateTime updatedAt; const ShopInBitTicket({ - required this.ticketId, - required this.displayName, + required this.apiTicketId, + required this.customerKey, + required this.ticketNumber, required this.category, - required this.status, - this.statusRaw, required this.requestDescription, required this.deliveryCountry, + required this.status, + required this.statusRaw, this.offerProductName, this.offerPrice, - required this.shippingName, - required this.shippingStreet, - required this.shippingCity, - required this.shippingPostalCode, - required this.shippingCountry, - this.paymentMethod, + this.paymentInvoiceStatus, + this.trackingLink, + this.lastAgentMessageAt, + this.feeTicketNumber, required this.messages, required this.createdAt, - required this.apiTicketId, - this.carResearchInvoiceId, - this.feeTicketNumber, - required this.needsCreateRequest, - required this.isPendingPayment, - this.carResearchExpiresAt, - this.carResearchPaymentLinks, + required this.updatedAt, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['ticket_id'] = Variable(ticketId); - map['display_name'] = Variable(displayName); + map['api_ticket_id'] = Variable(apiTicketId); + map['customer_key'] = Variable(customerKey); + map['ticket_number'] = Variable(ticketNumber); { - map['category'] = Variable( + map['category'] = Variable( $ShopInBitTicketsTable.$convertercategory.toSql(category), ); } + map['request_description'] = Variable(requestDescription); + map['delivery_country'] = Variable(deliveryCountry); { - map['status'] = Variable( + map['status'] = Variable( $ShopInBitTicketsTable.$converterstatus.toSql(status), ); } - if (!nullToAbsent || statusRaw != null) { - map['status_raw'] = Variable(statusRaw); - } - map['request_description'] = Variable(requestDescription); - map['delivery_country'] = Variable(deliveryCountry); + map['status_raw'] = Variable(statusRaw); if (!nullToAbsent || offerProductName != null) { map['offer_product_name'] = Variable(offerProductName); } if (!nullToAbsent || offerPrice != null) { map['offer_price'] = Variable(offerPrice); } - map['shipping_name'] = Variable(shippingName); - map['shipping_street'] = Variable(shippingStreet); - map['shipping_city'] = Variable(shippingCity); - map['shipping_postal_code'] = Variable(shippingPostalCode); - map['shipping_country'] = Variable(shippingCountry); - if (!nullToAbsent || paymentMethod != null) { - map['payment_method'] = Variable(paymentMethod); + if (!nullToAbsent || paymentInvoiceStatus != null) { + map['payment_invoice_status'] = Variable(paymentInvoiceStatus); } - { - map['messages'] = Variable( - $ShopInBitTicketsTable.$convertermessages.toSql(messages), - ); + if (!nullToAbsent || trackingLink != null) { + map['tracking_link'] = Variable(trackingLink); } - map['created_at'] = Variable(createdAt); - map['api_ticket_id'] = Variable(apiTicketId); - if (!nullToAbsent || carResearchInvoiceId != null) { - map['car_research_invoice_id'] = Variable(carResearchInvoiceId); + if (!nullToAbsent || lastAgentMessageAt != null) { + map['last_agent_message_at'] = Variable(lastAgentMessageAt); } if (!nullToAbsent || feeTicketNumber != null) { map['fee_ticket_number'] = Variable(feeTicketNumber); } - map['needs_create_request'] = Variable(needsCreateRequest); - map['is_pending_payment'] = Variable(isPendingPayment); - if (!nullToAbsent || carResearchExpiresAt != null) { - map['car_research_expires_at'] = Variable(carResearchExpiresAt); - } - if (!nullToAbsent || carResearchPaymentLinks != null) { - map['car_research_payment_links'] = Variable( - carResearchPaymentLinks, + { + map['messages'] = Variable( + $ShopInBitTicketsTable.$convertermessages.toSql(messages), ); } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); return map; } ShopInBitTicketsCompanion toCompanion(bool nullToAbsent) { return ShopInBitTicketsCompanion( - ticketId: Value(ticketId), - displayName: Value(displayName), + apiTicketId: Value(apiTicketId), + customerKey: Value(customerKey), + ticketNumber: Value(ticketNumber), category: Value(category), - status: Value(status), - statusRaw: statusRaw == null && nullToAbsent - ? const Value.absent() - : Value(statusRaw), requestDescription: Value(requestDescription), deliveryCountry: Value(deliveryCountry), + status: Value(status), + statusRaw: Value(statusRaw), offerProductName: offerProductName == null && nullToAbsent ? const Value.absent() : Value(offerProductName), offerPrice: offerPrice == null && nullToAbsent ? const Value.absent() : Value(offerPrice), - shippingName: Value(shippingName), - shippingStreet: Value(shippingStreet), - shippingCity: Value(shippingCity), - shippingPostalCode: Value(shippingPostalCode), - shippingCountry: Value(shippingCountry), - paymentMethod: paymentMethod == null && nullToAbsent + paymentInvoiceStatus: paymentInvoiceStatus == null && nullToAbsent ? const Value.absent() - : Value(paymentMethod), - messages: Value(messages), - createdAt: Value(createdAt), - apiTicketId: Value(apiTicketId), - carResearchInvoiceId: carResearchInvoiceId == null && nullToAbsent + : Value(paymentInvoiceStatus), + trackingLink: trackingLink == null && nullToAbsent ? const Value.absent() - : Value(carResearchInvoiceId), + : Value(trackingLink), + lastAgentMessageAt: lastAgentMessageAt == null && nullToAbsent + ? const Value.absent() + : Value(lastAgentMessageAt), feeTicketNumber: feeTicketNumber == null && nullToAbsent ? const Value.absent() : Value(feeTicketNumber), - needsCreateRequest: Value(needsCreateRequest), - isPendingPayment: Value(isPendingPayment), - carResearchExpiresAt: carResearchExpiresAt == null && nullToAbsent - ? const Value.absent() - : Value(carResearchExpiresAt), - carResearchPaymentLinks: carResearchPaymentLinks == null && nullToAbsent - ? const Value.absent() - : Value(carResearchPaymentLinks), + messages: Value(messages), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), ); } @@ -1300,569 +1323,416 @@ class ShopInBitTicket extends DataClass implements Insertable { }) { serializer ??= driftRuntimeOptions.defaultSerializer; return ShopInBitTicket( - ticketId: serializer.fromJson(json['ticketId']), - displayName: serializer.fromJson(json['displayName']), + apiTicketId: serializer.fromJson(json['apiTicketId']), + customerKey: serializer.fromJson(json['customerKey']), + ticketNumber: serializer.fromJson(json['ticketNumber']), category: $ShopInBitTicketsTable.$convertercategory.fromJson( - serializer.fromJson(json['category']), + serializer.fromJson(json['category']), ), - status: $ShopInBitTicketsTable.$converterstatus.fromJson( - serializer.fromJson(json['status']), - ), - statusRaw: serializer.fromJson(json['statusRaw']), requestDescription: serializer.fromJson( json['requestDescription'], ), deliveryCountry: serializer.fromJson(json['deliveryCountry']), + status: $ShopInBitTicketsTable.$converterstatus.fromJson( + serializer.fromJson(json['status']), + ), + statusRaw: serializer.fromJson(json['statusRaw']), offerProductName: serializer.fromJson(json['offerProductName']), offerPrice: serializer.fromJson(json['offerPrice']), - shippingName: serializer.fromJson(json['shippingName']), - shippingStreet: serializer.fromJson(json['shippingStreet']), - shippingCity: serializer.fromJson(json['shippingCity']), - shippingPostalCode: serializer.fromJson( - json['shippingPostalCode'], + paymentInvoiceStatus: serializer.fromJson( + json['paymentInvoiceStatus'], ), - shippingCountry: serializer.fromJson(json['shippingCountry']), - paymentMethod: serializer.fromJson(json['paymentMethod']), - messages: $ShopInBitTicketsTable.$convertermessages.fromJson( - serializer.fromJson>(json['messages']), - ), - createdAt: serializer.fromJson(json['createdAt']), - apiTicketId: serializer.fromJson(json['apiTicketId']), - carResearchInvoiceId: serializer.fromJson( - json['carResearchInvoiceId'], + trackingLink: serializer.fromJson(json['trackingLink']), + lastAgentMessageAt: serializer.fromJson( + json['lastAgentMessageAt'], ), feeTicketNumber: serializer.fromJson(json['feeTicketNumber']), - needsCreateRequest: serializer.fromJson(json['needsCreateRequest']), - isPendingPayment: serializer.fromJson(json['isPendingPayment']), - carResearchExpiresAt: serializer.fromJson( - json['carResearchExpiresAt'], - ), - carResearchPaymentLinks: serializer.fromJson( - json['carResearchPaymentLinks'], - ), + messages: serializer.fromJson>(json['messages']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'ticketId': serializer.toJson(ticketId), - 'displayName': serializer.toJson(displayName), - 'category': serializer.toJson( + 'apiTicketId': serializer.toJson(apiTicketId), + 'customerKey': serializer.toJson(customerKey), + 'ticketNumber': serializer.toJson(ticketNumber), + 'category': serializer.toJson( $ShopInBitTicketsTable.$convertercategory.toJson(category), ), - 'status': serializer.toJson( - $ShopInBitTicketsTable.$converterstatus.toJson(status), - ), - 'statusRaw': serializer.toJson(statusRaw), 'requestDescription': serializer.toJson(requestDescription), 'deliveryCountry': serializer.toJson(deliveryCountry), + 'status': serializer.toJson( + $ShopInBitTicketsTable.$converterstatus.toJson(status), + ), + 'statusRaw': serializer.toJson(statusRaw), 'offerProductName': serializer.toJson(offerProductName), 'offerPrice': serializer.toJson(offerPrice), - 'shippingName': serializer.toJson(shippingName), - 'shippingStreet': serializer.toJson(shippingStreet), - 'shippingCity': serializer.toJson(shippingCity), - 'shippingPostalCode': serializer.toJson(shippingPostalCode), - 'shippingCountry': serializer.toJson(shippingCountry), - 'paymentMethod': serializer.toJson(paymentMethod), - 'messages': serializer.toJson>( - $ShopInBitTicketsTable.$convertermessages.toJson(messages), - ), - 'createdAt': serializer.toJson(createdAt), - 'apiTicketId': serializer.toJson(apiTicketId), - 'carResearchInvoiceId': serializer.toJson(carResearchInvoiceId), + 'paymentInvoiceStatus': serializer.toJson(paymentInvoiceStatus), + 'trackingLink': serializer.toJson(trackingLink), + 'lastAgentMessageAt': serializer.toJson(lastAgentMessageAt), 'feeTicketNumber': serializer.toJson(feeTicketNumber), - 'needsCreateRequest': serializer.toJson(needsCreateRequest), - 'isPendingPayment': serializer.toJson(isPendingPayment), - 'carResearchExpiresAt': serializer.toJson( - carResearchExpiresAt, - ), - 'carResearchPaymentLinks': serializer.toJson( - carResearchPaymentLinks, - ), + 'messages': serializer.toJson>(messages), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), }; } ShopInBitTicket copyWith({ - String? ticketId, - String? displayName, + int? apiTicketId, + String? customerKey, + String? ticketNumber, ShopInBitCategory? category, - ShopInBitOrderStatus? status, - Value statusRaw = const Value.absent(), String? requestDescription, String? deliveryCountry, + ShopInBitOrderStatus? status, + String? statusRaw, Value offerProductName = const Value.absent(), Value offerPrice = const Value.absent(), - String? shippingName, - String? shippingStreet, - String? shippingCity, - String? shippingPostalCode, - String? shippingCountry, - Value paymentMethod = const Value.absent(), - List? messages, - DateTime? createdAt, - int? apiTicketId, - Value carResearchInvoiceId = const Value.absent(), + Value paymentInvoiceStatus = const Value.absent(), + Value trackingLink = const Value.absent(), + Value lastAgentMessageAt = const Value.absent(), Value feeTicketNumber = const Value.absent(), - bool? needsCreateRequest, - bool? isPendingPayment, - Value carResearchExpiresAt = const Value.absent(), - Value carResearchPaymentLinks = const Value.absent(), + List? messages, + DateTime? createdAt, + DateTime? updatedAt, }) => ShopInBitTicket( - ticketId: ticketId ?? this.ticketId, - displayName: displayName ?? this.displayName, + apiTicketId: apiTicketId ?? this.apiTicketId, + customerKey: customerKey ?? this.customerKey, + ticketNumber: ticketNumber ?? this.ticketNumber, category: category ?? this.category, - status: status ?? this.status, - statusRaw: statusRaw.present ? statusRaw.value : this.statusRaw, requestDescription: requestDescription ?? this.requestDescription, deliveryCountry: deliveryCountry ?? this.deliveryCountry, + status: status ?? this.status, + statusRaw: statusRaw ?? this.statusRaw, offerProductName: offerProductName.present ? offerProductName.value : this.offerProductName, offerPrice: offerPrice.present ? offerPrice.value : this.offerPrice, - shippingName: shippingName ?? this.shippingName, - shippingStreet: shippingStreet ?? this.shippingStreet, - shippingCity: shippingCity ?? this.shippingCity, - shippingPostalCode: shippingPostalCode ?? this.shippingPostalCode, - shippingCountry: shippingCountry ?? this.shippingCountry, - paymentMethod: paymentMethod.present - ? paymentMethod.value - : this.paymentMethod, - messages: messages ?? this.messages, - createdAt: createdAt ?? this.createdAt, - apiTicketId: apiTicketId ?? this.apiTicketId, - carResearchInvoiceId: carResearchInvoiceId.present - ? carResearchInvoiceId.value - : this.carResearchInvoiceId, + paymentInvoiceStatus: paymentInvoiceStatus.present + ? paymentInvoiceStatus.value + : this.paymentInvoiceStatus, + trackingLink: trackingLink.present ? trackingLink.value : this.trackingLink, + lastAgentMessageAt: lastAgentMessageAt.present + ? lastAgentMessageAt.value + : this.lastAgentMessageAt, feeTicketNumber: feeTicketNumber.present ? feeTicketNumber.value : this.feeTicketNumber, - needsCreateRequest: needsCreateRequest ?? this.needsCreateRequest, - isPendingPayment: isPendingPayment ?? this.isPendingPayment, - carResearchExpiresAt: carResearchExpiresAt.present - ? carResearchExpiresAt.value - : this.carResearchExpiresAt, - carResearchPaymentLinks: carResearchPaymentLinks.present - ? carResearchPaymentLinks.value - : this.carResearchPaymentLinks, + messages: messages ?? this.messages, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, ); ShopInBitTicket copyWithCompanion(ShopInBitTicketsCompanion data) { return ShopInBitTicket( - ticketId: data.ticketId.present ? data.ticketId.value : this.ticketId, - displayName: data.displayName.present - ? data.displayName.value - : this.displayName, + apiTicketId: data.apiTicketId.present + ? data.apiTicketId.value + : this.apiTicketId, + customerKey: data.customerKey.present + ? data.customerKey.value + : this.customerKey, + ticketNumber: data.ticketNumber.present + ? data.ticketNumber.value + : this.ticketNumber, category: data.category.present ? data.category.value : this.category, - status: data.status.present ? data.status.value : this.status, - statusRaw: data.statusRaw.present ? data.statusRaw.value : this.statusRaw, requestDescription: data.requestDescription.present ? data.requestDescription.value : this.requestDescription, deliveryCountry: data.deliveryCountry.present ? data.deliveryCountry.value : this.deliveryCountry, + status: data.status.present ? data.status.value : this.status, + statusRaw: data.statusRaw.present ? data.statusRaw.value : this.statusRaw, offerProductName: data.offerProductName.present ? data.offerProductName.value : this.offerProductName, offerPrice: data.offerPrice.present ? data.offerPrice.value : this.offerPrice, - shippingName: data.shippingName.present - ? data.shippingName.value - : this.shippingName, - shippingStreet: data.shippingStreet.present - ? data.shippingStreet.value - : this.shippingStreet, - shippingCity: data.shippingCity.present - ? data.shippingCity.value - : this.shippingCity, - shippingPostalCode: data.shippingPostalCode.present - ? data.shippingPostalCode.value - : this.shippingPostalCode, - shippingCountry: data.shippingCountry.present - ? data.shippingCountry.value - : this.shippingCountry, - paymentMethod: data.paymentMethod.present - ? data.paymentMethod.value - : this.paymentMethod, - messages: data.messages.present ? data.messages.value : this.messages, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - apiTicketId: data.apiTicketId.present - ? data.apiTicketId.value - : this.apiTicketId, - carResearchInvoiceId: data.carResearchInvoiceId.present - ? data.carResearchInvoiceId.value - : this.carResearchInvoiceId, + paymentInvoiceStatus: data.paymentInvoiceStatus.present + ? data.paymentInvoiceStatus.value + : this.paymentInvoiceStatus, + trackingLink: data.trackingLink.present + ? data.trackingLink.value + : this.trackingLink, + lastAgentMessageAt: data.lastAgentMessageAt.present + ? data.lastAgentMessageAt.value + : this.lastAgentMessageAt, feeTicketNumber: data.feeTicketNumber.present ? data.feeTicketNumber.value : this.feeTicketNumber, - needsCreateRequest: data.needsCreateRequest.present - ? data.needsCreateRequest.value - : this.needsCreateRequest, - isPendingPayment: data.isPendingPayment.present - ? data.isPendingPayment.value - : this.isPendingPayment, - carResearchExpiresAt: data.carResearchExpiresAt.present - ? data.carResearchExpiresAt.value - : this.carResearchExpiresAt, - carResearchPaymentLinks: data.carResearchPaymentLinks.present - ? data.carResearchPaymentLinks.value - : this.carResearchPaymentLinks, + messages: data.messages.present ? data.messages.value : this.messages, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, ); } @override String toString() { return (StringBuffer('ShopInBitTicket(') - ..write('ticketId: $ticketId, ') - ..write('displayName: $displayName, ') + ..write('apiTicketId: $apiTicketId, ') + ..write('customerKey: $customerKey, ') + ..write('ticketNumber: $ticketNumber, ') ..write('category: $category, ') - ..write('status: $status, ') - ..write('statusRaw: $statusRaw, ') ..write('requestDescription: $requestDescription, ') ..write('deliveryCountry: $deliveryCountry, ') + ..write('status: $status, ') + ..write('statusRaw: $statusRaw, ') ..write('offerProductName: $offerProductName, ') ..write('offerPrice: $offerPrice, ') - ..write('shippingName: $shippingName, ') - ..write('shippingStreet: $shippingStreet, ') - ..write('shippingCity: $shippingCity, ') - ..write('shippingPostalCode: $shippingPostalCode, ') - ..write('shippingCountry: $shippingCountry, ') - ..write('paymentMethod: $paymentMethod, ') + ..write('paymentInvoiceStatus: $paymentInvoiceStatus, ') + ..write('trackingLink: $trackingLink, ') + ..write('lastAgentMessageAt: $lastAgentMessageAt, ') + ..write('feeTicketNumber: $feeTicketNumber, ') ..write('messages: $messages, ') ..write('createdAt: $createdAt, ') - ..write('apiTicketId: $apiTicketId, ') - ..write('carResearchInvoiceId: $carResearchInvoiceId, ') - ..write('feeTicketNumber: $feeTicketNumber, ') - ..write('needsCreateRequest: $needsCreateRequest, ') - ..write('isPendingPayment: $isPendingPayment, ') - ..write('carResearchExpiresAt: $carResearchExpiresAt, ') - ..write('carResearchPaymentLinks: $carResearchPaymentLinks') + ..write('updatedAt: $updatedAt') ..write(')')) .toString(); } @override - int get hashCode => Object.hashAll([ - ticketId, - displayName, + int get hashCode => Object.hash( + apiTicketId, + customerKey, + ticketNumber, category, - status, - statusRaw, requestDescription, deliveryCountry, + status, + statusRaw, offerProductName, offerPrice, - shippingName, - shippingStreet, - shippingCity, - shippingPostalCode, - shippingCountry, - paymentMethod, + paymentInvoiceStatus, + trackingLink, + lastAgentMessageAt, + feeTicketNumber, messages, createdAt, - apiTicketId, - carResearchInvoiceId, - feeTicketNumber, - needsCreateRequest, - isPendingPayment, - carResearchExpiresAt, - carResearchPaymentLinks, - ]); + updatedAt, + ); @override bool operator ==(Object other) => identical(this, other) || (other is ShopInBitTicket && - other.ticketId == this.ticketId && - other.displayName == this.displayName && + other.apiTicketId == this.apiTicketId && + other.customerKey == this.customerKey && + other.ticketNumber == this.ticketNumber && other.category == this.category && - other.status == this.status && - other.statusRaw == this.statusRaw && other.requestDescription == this.requestDescription && other.deliveryCountry == this.deliveryCountry && + other.status == this.status && + other.statusRaw == this.statusRaw && other.offerProductName == this.offerProductName && other.offerPrice == this.offerPrice && - other.shippingName == this.shippingName && - other.shippingStreet == this.shippingStreet && - other.shippingCity == this.shippingCity && - other.shippingPostalCode == this.shippingPostalCode && - other.shippingCountry == this.shippingCountry && - other.paymentMethod == this.paymentMethod && + other.paymentInvoiceStatus == this.paymentInvoiceStatus && + other.trackingLink == this.trackingLink && + other.lastAgentMessageAt == this.lastAgentMessageAt && + other.feeTicketNumber == this.feeTicketNumber && other.messages == this.messages && other.createdAt == this.createdAt && - other.apiTicketId == this.apiTicketId && - other.carResearchInvoiceId == this.carResearchInvoiceId && - other.feeTicketNumber == this.feeTicketNumber && - other.needsCreateRequest == this.needsCreateRequest && - other.isPendingPayment == this.isPendingPayment && - other.carResearchExpiresAt == this.carResearchExpiresAt && - other.carResearchPaymentLinks == this.carResearchPaymentLinks); + other.updatedAt == this.updatedAt); } class ShopInBitTicketsCompanion extends UpdateCompanion { - final Value ticketId; - final Value displayName; + final Value apiTicketId; + final Value customerKey; + final Value ticketNumber; final Value category; - final Value status; - final Value statusRaw; final Value requestDescription; final Value deliveryCountry; + final Value status; + final Value statusRaw; final Value offerProductName; final Value offerPrice; - final Value shippingName; - final Value shippingStreet; - final Value shippingCity; - final Value shippingPostalCode; - final Value shippingCountry; - final Value paymentMethod; - final Value> messages; - final Value createdAt; - final Value apiTicketId; - final Value carResearchInvoiceId; + final Value paymentInvoiceStatus; + final Value trackingLink; + final Value lastAgentMessageAt; final Value feeTicketNumber; - final Value needsCreateRequest; - final Value isPendingPayment; - final Value carResearchExpiresAt; - final Value carResearchPaymentLinks; - final Value rowid; + final Value> messages; + final Value createdAt; + final Value updatedAt; const ShopInBitTicketsCompanion({ - this.ticketId = const Value.absent(), - this.displayName = const Value.absent(), + this.apiTicketId = const Value.absent(), + this.customerKey = const Value.absent(), + this.ticketNumber = const Value.absent(), this.category = const Value.absent(), - this.status = const Value.absent(), - this.statusRaw = const Value.absent(), this.requestDescription = const Value.absent(), this.deliveryCountry = const Value.absent(), + this.status = const Value.absent(), + this.statusRaw = const Value.absent(), this.offerProductName = const Value.absent(), this.offerPrice = const Value.absent(), - this.shippingName = const Value.absent(), - this.shippingStreet = const Value.absent(), - this.shippingCity = const Value.absent(), - this.shippingPostalCode = const Value.absent(), - this.shippingCountry = const Value.absent(), - this.paymentMethod = const Value.absent(), + this.paymentInvoiceStatus = const Value.absent(), + this.trackingLink = const Value.absent(), + this.lastAgentMessageAt = const Value.absent(), + this.feeTicketNumber = const Value.absent(), this.messages = const Value.absent(), this.createdAt = const Value.absent(), - this.apiTicketId = const Value.absent(), - this.carResearchInvoiceId = const Value.absent(), - this.feeTicketNumber = const Value.absent(), - this.needsCreateRequest = const Value.absent(), - this.isPendingPayment = const Value.absent(), - this.carResearchExpiresAt = const Value.absent(), - this.carResearchPaymentLinks = const Value.absent(), - this.rowid = const Value.absent(), + this.updatedAt = const Value.absent(), }); ShopInBitTicketsCompanion.insert({ - required String ticketId, - required String displayName, + required int apiTicketId, + required String customerKey, + required String ticketNumber, required ShopInBitCategory category, - required ShopInBitOrderStatus status, - this.statusRaw = const Value.absent(), required String requestDescription, required String deliveryCountry, + required ShopInBitOrderStatus status, + required String statusRaw, this.offerProductName = const Value.absent(), this.offerPrice = const Value.absent(), - required String shippingName, - required String shippingStreet, - required String shippingCity, - required String shippingPostalCode, - required String shippingCountry, - this.paymentMethod = const Value.absent(), - required List messages, - required DateTime createdAt, - required int apiTicketId, - this.carResearchInvoiceId = const Value.absent(), + this.paymentInvoiceStatus = const Value.absent(), + this.trackingLink = const Value.absent(), + this.lastAgentMessageAt = const Value.absent(), this.feeTicketNumber = const Value.absent(), - required bool needsCreateRequest, - required bool isPendingPayment, - this.carResearchExpiresAt = const Value.absent(), - this.carResearchPaymentLinks = const Value.absent(), - this.rowid = const Value.absent(), - }) : ticketId = Value(ticketId), - displayName = Value(displayName), + this.messages = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + }) : apiTicketId = Value(apiTicketId), + customerKey = Value(customerKey), + ticketNumber = Value(ticketNumber), category = Value(category), - status = Value(status), requestDescription = Value(requestDescription), deliveryCountry = Value(deliveryCountry), - shippingName = Value(shippingName), - shippingStreet = Value(shippingStreet), - shippingCity = Value(shippingCity), - shippingPostalCode = Value(shippingPostalCode), - shippingCountry = Value(shippingCountry), - messages = Value(messages), - createdAt = Value(createdAt), - apiTicketId = Value(apiTicketId), - needsCreateRequest = Value(needsCreateRequest), - isPendingPayment = Value(isPendingPayment); + status = Value(status), + statusRaw = Value(statusRaw); static Insertable custom({ - Expression? ticketId, - Expression? displayName, - Expression? category, - Expression? status, - Expression? statusRaw, + Expression? apiTicketId, + Expression? customerKey, + Expression? ticketNumber, + Expression? category, Expression? requestDescription, Expression? deliveryCountry, + Expression? status, + Expression? statusRaw, Expression? offerProductName, Expression? offerPrice, - Expression? shippingName, - Expression? shippingStreet, - Expression? shippingCity, - Expression? shippingPostalCode, - Expression? shippingCountry, - Expression? paymentMethod, + Expression? paymentInvoiceStatus, + Expression? trackingLink, + Expression? lastAgentMessageAt, + Expression? feeTicketNumber, Expression? messages, Expression? createdAt, - Expression? apiTicketId, - Expression? carResearchInvoiceId, - Expression? feeTicketNumber, - Expression? needsCreateRequest, - Expression? isPendingPayment, - Expression? carResearchExpiresAt, - Expression? carResearchPaymentLinks, - Expression? rowid, + Expression? updatedAt, }) { return RawValuesInsertable({ - if (ticketId != null) 'ticket_id': ticketId, - if (displayName != null) 'display_name': displayName, + if (apiTicketId != null) 'api_ticket_id': apiTicketId, + if (customerKey != null) 'customer_key': customerKey, + if (ticketNumber != null) 'ticket_number': ticketNumber, if (category != null) 'category': category, - if (status != null) 'status': status, - if (statusRaw != null) 'status_raw': statusRaw, if (requestDescription != null) 'request_description': requestDescription, if (deliveryCountry != null) 'delivery_country': deliveryCountry, + if (status != null) 'status': status, + if (statusRaw != null) 'status_raw': statusRaw, if (offerProductName != null) 'offer_product_name': offerProductName, if (offerPrice != null) 'offer_price': offerPrice, - if (shippingName != null) 'shipping_name': shippingName, - if (shippingStreet != null) 'shipping_street': shippingStreet, - if (shippingCity != null) 'shipping_city': shippingCity, - if (shippingPostalCode != null) - 'shipping_postal_code': shippingPostalCode, - if (shippingCountry != null) 'shipping_country': shippingCountry, - if (paymentMethod != null) 'payment_method': paymentMethod, + if (paymentInvoiceStatus != null) + 'payment_invoice_status': paymentInvoiceStatus, + if (trackingLink != null) 'tracking_link': trackingLink, + if (lastAgentMessageAt != null) + 'last_agent_message_at': lastAgentMessageAt, + if (feeTicketNumber != null) 'fee_ticket_number': feeTicketNumber, if (messages != null) 'messages': messages, if (createdAt != null) 'created_at': createdAt, - if (apiTicketId != null) 'api_ticket_id': apiTicketId, - if (carResearchInvoiceId != null) - 'car_research_invoice_id': carResearchInvoiceId, - if (feeTicketNumber != null) 'fee_ticket_number': feeTicketNumber, - if (needsCreateRequest != null) - 'needs_create_request': needsCreateRequest, - if (isPendingPayment != null) 'is_pending_payment': isPendingPayment, - if (carResearchExpiresAt != null) - 'car_research_expires_at': carResearchExpiresAt, - if (carResearchPaymentLinks != null) - 'car_research_payment_links': carResearchPaymentLinks, - if (rowid != null) 'rowid': rowid, + if (updatedAt != null) 'updated_at': updatedAt, }); } ShopInBitTicketsCompanion copyWith({ - Value? ticketId, - Value? displayName, + Value? apiTicketId, + Value? customerKey, + Value? ticketNumber, Value? category, - Value? status, - Value? statusRaw, Value? requestDescription, Value? deliveryCountry, + Value? status, + Value? statusRaw, Value? offerProductName, Value? offerPrice, - Value? shippingName, - Value? shippingStreet, - Value? shippingCity, - Value? shippingPostalCode, - Value? shippingCountry, - Value? paymentMethod, - Value>? messages, - Value? createdAt, - Value? apiTicketId, - Value? carResearchInvoiceId, + Value? paymentInvoiceStatus, + Value? trackingLink, + Value? lastAgentMessageAt, Value? feeTicketNumber, - Value? needsCreateRequest, - Value? isPendingPayment, - Value? carResearchExpiresAt, - Value? carResearchPaymentLinks, - Value? rowid, + Value>? messages, + Value? createdAt, + Value? updatedAt, }) { return ShopInBitTicketsCompanion( - ticketId: ticketId ?? this.ticketId, - displayName: displayName ?? this.displayName, + apiTicketId: apiTicketId ?? this.apiTicketId, + customerKey: customerKey ?? this.customerKey, + ticketNumber: ticketNumber ?? this.ticketNumber, category: category ?? this.category, - status: status ?? this.status, - statusRaw: statusRaw ?? this.statusRaw, requestDescription: requestDescription ?? this.requestDescription, deliveryCountry: deliveryCountry ?? this.deliveryCountry, + status: status ?? this.status, + statusRaw: statusRaw ?? this.statusRaw, offerProductName: offerProductName ?? this.offerProductName, offerPrice: offerPrice ?? this.offerPrice, - shippingName: shippingName ?? this.shippingName, - shippingStreet: shippingStreet ?? this.shippingStreet, - shippingCity: shippingCity ?? this.shippingCity, - shippingPostalCode: shippingPostalCode ?? this.shippingPostalCode, - shippingCountry: shippingCountry ?? this.shippingCountry, - paymentMethod: paymentMethod ?? this.paymentMethod, + paymentInvoiceStatus: paymentInvoiceStatus ?? this.paymentInvoiceStatus, + trackingLink: trackingLink ?? this.trackingLink, + lastAgentMessageAt: lastAgentMessageAt ?? this.lastAgentMessageAt, + feeTicketNumber: feeTicketNumber ?? this.feeTicketNumber, messages: messages ?? this.messages, createdAt: createdAt ?? this.createdAt, - apiTicketId: apiTicketId ?? this.apiTicketId, - carResearchInvoiceId: carResearchInvoiceId ?? this.carResearchInvoiceId, - feeTicketNumber: feeTicketNumber ?? this.feeTicketNumber, - needsCreateRequest: needsCreateRequest ?? this.needsCreateRequest, - isPendingPayment: isPendingPayment ?? this.isPendingPayment, - carResearchExpiresAt: carResearchExpiresAt ?? this.carResearchExpiresAt, - carResearchPaymentLinks: - carResearchPaymentLinks ?? this.carResearchPaymentLinks, - rowid: rowid ?? this.rowid, + updatedAt: updatedAt ?? this.updatedAt, ); } @override Map toColumns(bool nullToAbsent) { final map = {}; - if (ticketId.present) { - map['ticket_id'] = Variable(ticketId.value); + if (apiTicketId.present) { + map['api_ticket_id'] = Variable(apiTicketId.value); + } + if (customerKey.present) { + map['customer_key'] = Variable(customerKey.value); } - if (displayName.present) { - map['display_name'] = Variable(displayName.value); + if (ticketNumber.present) { + map['ticket_number'] = Variable(ticketNumber.value); } if (category.present) { - map['category'] = Variable( + map['category'] = Variable( $ShopInBitTicketsTable.$convertercategory.toSql(category.value), ); } + if (requestDescription.present) { + map['request_description'] = Variable(requestDescription.value); + } + if (deliveryCountry.present) { + map['delivery_country'] = Variable(deliveryCountry.value); + } if (status.present) { - map['status'] = Variable( + map['status'] = Variable( $ShopInBitTicketsTable.$converterstatus.toSql(status.value), ); } if (statusRaw.present) { map['status_raw'] = Variable(statusRaw.value); } - if (requestDescription.present) { - map['request_description'] = Variable(requestDescription.value); - } - if (deliveryCountry.present) { - map['delivery_country'] = Variable(deliveryCountry.value); - } if (offerProductName.present) { map['offer_product_name'] = Variable(offerProductName.value); } if (offerPrice.present) { map['offer_price'] = Variable(offerPrice.value); } - if (shippingName.present) { - map['shipping_name'] = Variable(shippingName.value); - } - if (shippingStreet.present) { - map['shipping_street'] = Variable(shippingStreet.value); - } - if (shippingCity.present) { - map['shipping_city'] = Variable(shippingCity.value); + if (paymentInvoiceStatus.present) { + map['payment_invoice_status'] = Variable( + paymentInvoiceStatus.value, + ); } - if (shippingPostalCode.present) { - map['shipping_postal_code'] = Variable(shippingPostalCode.value); + if (trackingLink.present) { + map['tracking_link'] = Variable(trackingLink.value); } - if (shippingCountry.present) { - map['shipping_country'] = Variable(shippingCountry.value); + if (lastAgentMessageAt.present) { + map['last_agent_message_at'] = Variable( + lastAgentMessageAt.value, + ); } - if (paymentMethod.present) { - map['payment_method'] = Variable(paymentMethod.value); + if (feeTicketNumber.present) { + map['fee_ticket_number'] = Variable(feeTicketNumber.value); } if (messages.present) { map['messages'] = Variable( @@ -1872,35 +1742,8 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } - if (apiTicketId.present) { - map['api_ticket_id'] = Variable(apiTicketId.value); - } - if (carResearchInvoiceId.present) { - map['car_research_invoice_id'] = Variable( - carResearchInvoiceId.value, - ); - } - if (feeTicketNumber.present) { - map['fee_ticket_number'] = Variable(feeTicketNumber.value); - } - if (needsCreateRequest.present) { - map['needs_create_request'] = Variable(needsCreateRequest.value); - } - if (isPendingPayment.present) { - map['is_pending_payment'] = Variable(isPendingPayment.value); - } - if (carResearchExpiresAt.present) { - map['car_research_expires_at'] = Variable( - carResearchExpiresAt.value, - ); - } - if (carResearchPaymentLinks.present) { - map['car_research_payment_links'] = Variable( - carResearchPaymentLinks.value, - ); - } - if (rowid.present) { - map['rowid'] = Variable(rowid.value); + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); } return map; } @@ -1908,31 +1751,23 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { @override String toString() { return (StringBuffer('ShopInBitTicketsCompanion(') - ..write('ticketId: $ticketId, ') - ..write('displayName: $displayName, ') - ..write('category: $category, ') - ..write('status: $status, ') - ..write('statusRaw: $statusRaw, ') + ..write('apiTicketId: $apiTicketId, ') + ..write('customerKey: $customerKey, ') + ..write('ticketNumber: $ticketNumber, ') + ..write('category: $category, ') ..write('requestDescription: $requestDescription, ') ..write('deliveryCountry: $deliveryCountry, ') + ..write('status: $status, ') + ..write('statusRaw: $statusRaw, ') ..write('offerProductName: $offerProductName, ') ..write('offerPrice: $offerPrice, ') - ..write('shippingName: $shippingName, ') - ..write('shippingStreet: $shippingStreet, ') - ..write('shippingCity: $shippingCity, ') - ..write('shippingPostalCode: $shippingPostalCode, ') - ..write('shippingCountry: $shippingCountry, ') - ..write('paymentMethod: $paymentMethod, ') + ..write('paymentInvoiceStatus: $paymentInvoiceStatus, ') + ..write('trackingLink: $trackingLink, ') + ..write('lastAgentMessageAt: $lastAgentMessageAt, ') + ..write('feeTicketNumber: $feeTicketNumber, ') ..write('messages: $messages, ') ..write('createdAt: $createdAt, ') - ..write('apiTicketId: $apiTicketId, ') - ..write('carResearchInvoiceId: $carResearchInvoiceId, ') - ..write('feeTicketNumber: $feeTicketNumber, ') - ..write('needsCreateRequest: $needsCreateRequest, ') - ..write('isPendingPayment: $isPendingPayment, ') - ..write('carResearchExpiresAt: $carResearchExpiresAt, ') - ..write('carResearchPaymentLinks: $carResearchPaymentLinks, ') - ..write('rowid: $rowid') + ..write('updatedAt: $updatedAt') ..write(')')) .toString(); } @@ -1942,12 +1777,15 @@ abstract class _$SharedDatabase extends GeneratedDatabase { _$SharedDatabase(QueryExecutor e) : super(e); $SharedDatabaseManager get managers => $SharedDatabaseManager(this); late final $CakepayOrdersTable cakepayOrders = $CakepayOrdersTable(this); - late final $ShopinBitSettingsTable shopinBitSettings = - $ShopinBitSettingsTable(this); + late final $ShopInBitSettingsTable shopInBitSettings = + $ShopInBitSettingsTable(this); late final $ShopInBitTicketsTable shopInBitTickets = $ShopInBitTicketsTable( this, ); - late final ShopinBitSettingsDao shopinBitSettingsDao = ShopinBitSettingsDao( + late final ShopInBitSettingsDao shopInBitSettingsDao = ShopInBitSettingsDao( + this as SharedDatabase, + ); + late final ShopInBitTicketsDao shopInBitTicketsDao = ShopInBitTicketsDao( this as SharedDatabase, ); @override @@ -1956,7 +1794,7 @@ abstract class _$SharedDatabase extends GeneratedDatabase { @override List get allSchemaEntities => [ cakepayOrders, - shopinBitSettings, + shopInBitSettings, shopInBitTickets, ]; } @@ -2079,37 +1917,60 @@ typedef $$CakepayOrdersTableProcessedTableManager = CakepayOrder, PrefetchHooks Function() >; -typedef $$ShopinBitSettingsTableCreateCompanionBuilder = - ShopinBitSettingsCompanion Function({ - Value id, - Value guidelinesAccepted, +typedef $$ShopInBitSettingsTableCreateCompanionBuilder = + ShopInBitSettingsCompanion Function({ + required String customerKey, + Value privacyAccepted, + Value conciergeGuidelinesAccepted, + Value travelGuidelinesAccepted, + Value carGuidelinesAccepted, Value setupComplete, - Value displayName, + Value createdAt, + Value lastUsedAt, }); -typedef $$ShopinBitSettingsTableUpdateCompanionBuilder = - ShopinBitSettingsCompanion Function({ - Value id, - Value guidelinesAccepted, +typedef $$ShopInBitSettingsTableUpdateCompanionBuilder = + ShopInBitSettingsCompanion Function({ + Value customerKey, + Value privacyAccepted, + Value conciergeGuidelinesAccepted, + Value travelGuidelinesAccepted, + Value carGuidelinesAccepted, Value setupComplete, - Value displayName, + Value createdAt, + Value lastUsedAt, }); -class $$ShopinBitSettingsTableFilterComposer - extends Composer<_$SharedDatabase, $ShopinBitSettingsTable> { - $$ShopinBitSettingsTableFilterComposer({ +class $$ShopInBitSettingsTableFilterComposer + extends Composer<_$SharedDatabase, $ShopInBitSettingsTable> { + $$ShopInBitSettingsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, + ColumnFilters get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get privacyAccepted => $composableBuilder( + column: $table.privacyAccepted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get conciergeGuidelinesAccepted => $composableBuilder( + column: $table.conciergeGuidelinesAccepted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get travelGuidelinesAccepted => $composableBuilder( + column: $table.travelGuidelinesAccepted, builder: (column) => ColumnFilters(column), ); - ColumnFilters get guidelinesAccepted => $composableBuilder( - column: $table.guidelinesAccepted, + ColumnFilters get carGuidelinesAccepted => $composableBuilder( + column: $table.carGuidelinesAccepted, builder: (column) => ColumnFilters(column), ); @@ -2118,28 +1979,48 @@ class $$ShopinBitSettingsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get displayName => $composableBuilder( - column: $table.displayName, + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, builder: (column) => ColumnFilters(column), ); } -class $$ShopinBitSettingsTableOrderingComposer - extends Composer<_$SharedDatabase, $ShopinBitSettingsTable> { - $$ShopinBitSettingsTableOrderingComposer({ +class $$ShopInBitSettingsTableOrderingComposer + extends Composer<_$SharedDatabase, $ShopInBitSettingsTable> { + $$ShopInBitSettingsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, + ColumnOrderings get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get privacyAccepted => $composableBuilder( + column: $table.privacyAccepted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get conciergeGuidelinesAccepted => $composableBuilder( + column: $table.conciergeGuidelinesAccepted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get travelGuidelinesAccepted => $composableBuilder( + column: $table.travelGuidelinesAccepted, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get guidelinesAccepted => $composableBuilder( - column: $table.guidelinesAccepted, + ColumnOrderings get carGuidelinesAccepted => $composableBuilder( + column: $table.carGuidelinesAccepted, builder: (column) => ColumnOrderings(column), ); @@ -2148,26 +2029,48 @@ class $$ShopinBitSettingsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get displayName => $composableBuilder( - column: $table.displayName, + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, builder: (column) => ColumnOrderings(column), ); } -class $$ShopinBitSettingsTableAnnotationComposer - extends Composer<_$SharedDatabase, $ShopinBitSettingsTable> { - $$ShopinBitSettingsTableAnnotationComposer({ +class $$ShopInBitSettingsTableAnnotationComposer + extends Composer<_$SharedDatabase, $ShopInBitSettingsTable> { + $$ShopInBitSettingsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => column, + ); + + GeneratedColumn get privacyAccepted => $composableBuilder( + column: $table.privacyAccepted, + builder: (column) => column, + ); + + GeneratedColumn get conciergeGuidelinesAccepted => $composableBuilder( + column: $table.conciergeGuidelinesAccepted, + builder: (column) => column, + ); + + GeneratedColumn get travelGuidelinesAccepted => $composableBuilder( + column: $table.travelGuidelinesAccepted, + builder: (column) => column, + ); - GeneratedColumn get guidelinesAccepted => $composableBuilder( - column: $table.guidelinesAccepted, + GeneratedColumn get carGuidelinesAccepted => $composableBuilder( + column: $table.carGuidelinesAccepted, builder: (column) => column, ); @@ -2176,73 +2079,92 @@ class $$ShopinBitSettingsTableAnnotationComposer builder: (column) => column, ); - GeneratedColumn get displayName => $composableBuilder( - column: $table.displayName, + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get lastUsedAt => $composableBuilder( + column: $table.lastUsedAt, builder: (column) => column, ); } -class $$ShopinBitSettingsTableTableManager +class $$ShopInBitSettingsTableTableManager extends RootTableManager< _$SharedDatabase, - $ShopinBitSettingsTable, - ShopinBitSetting, - $$ShopinBitSettingsTableFilterComposer, - $$ShopinBitSettingsTableOrderingComposer, - $$ShopinBitSettingsTableAnnotationComposer, - $$ShopinBitSettingsTableCreateCompanionBuilder, - $$ShopinBitSettingsTableUpdateCompanionBuilder, + $ShopInBitSettingsTable, + ShopInBitSetting, + $$ShopInBitSettingsTableFilterComposer, + $$ShopInBitSettingsTableOrderingComposer, + $$ShopInBitSettingsTableAnnotationComposer, + $$ShopInBitSettingsTableCreateCompanionBuilder, + $$ShopInBitSettingsTableUpdateCompanionBuilder, ( - ShopinBitSetting, + ShopInBitSetting, BaseReferences< _$SharedDatabase, - $ShopinBitSettingsTable, - ShopinBitSetting + $ShopInBitSettingsTable, + ShopInBitSetting >, ), - ShopinBitSetting, + ShopInBitSetting, PrefetchHooks Function() > { - $$ShopinBitSettingsTableTableManager( + $$ShopInBitSettingsTableTableManager( _$SharedDatabase db, - $ShopinBitSettingsTable table, + $ShopInBitSettingsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$ShopinBitSettingsTableFilterComposer($db: db, $table: table), + $$ShopInBitSettingsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$ShopinBitSettingsTableOrderingComposer($db: db, $table: table), + $$ShopInBitSettingsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$ShopinBitSettingsTableAnnotationComposer( + $$ShopInBitSettingsTableAnnotationComposer( $db: db, $table: table, ), updateCompanionCallback: ({ - Value id = const Value.absent(), - Value guidelinesAccepted = const Value.absent(), + Value customerKey = const Value.absent(), + Value privacyAccepted = const Value.absent(), + Value conciergeGuidelinesAccepted = const Value.absent(), + Value travelGuidelinesAccepted = const Value.absent(), + Value carGuidelinesAccepted = const Value.absent(), Value setupComplete = const Value.absent(), - Value displayName = const Value.absent(), - }) => ShopinBitSettingsCompanion( - id: id, - guidelinesAccepted: guidelinesAccepted, + Value createdAt = const Value.absent(), + Value lastUsedAt = const Value.absent(), + }) => ShopInBitSettingsCompanion( + customerKey: customerKey, + privacyAccepted: privacyAccepted, + conciergeGuidelinesAccepted: conciergeGuidelinesAccepted, + travelGuidelinesAccepted: travelGuidelinesAccepted, + carGuidelinesAccepted: carGuidelinesAccepted, setupComplete: setupComplete, - displayName: displayName, + createdAt: createdAt, + lastUsedAt: lastUsedAt, ), createCompanionCallback: ({ - Value id = const Value.absent(), - Value guidelinesAccepted = const Value.absent(), + required String customerKey, + Value privacyAccepted = const Value.absent(), + Value conciergeGuidelinesAccepted = const Value.absent(), + Value travelGuidelinesAccepted = const Value.absent(), + Value carGuidelinesAccepted = const Value.absent(), Value setupComplete = const Value.absent(), - Value displayName = const Value.absent(), - }) => ShopinBitSettingsCompanion.insert( - id: id, - guidelinesAccepted: guidelinesAccepted, + Value createdAt = const Value.absent(), + Value lastUsedAt = const Value.absent(), + }) => ShopInBitSettingsCompanion.insert( + customerKey: customerKey, + privacyAccepted: privacyAccepted, + conciergeGuidelinesAccepted: conciergeGuidelinesAccepted, + travelGuidelinesAccepted: travelGuidelinesAccepted, + carGuidelinesAccepted: carGuidelinesAccepted, setupComplete: setupComplete, - displayName: displayName, + createdAt: createdAt, + lastUsedAt: lastUsedAt, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -2252,82 +2174,66 @@ class $$ShopinBitSettingsTableTableManager ); } -typedef $$ShopinBitSettingsTableProcessedTableManager = +typedef $$ShopInBitSettingsTableProcessedTableManager = ProcessedTableManager< _$SharedDatabase, - $ShopinBitSettingsTable, - ShopinBitSetting, - $$ShopinBitSettingsTableFilterComposer, - $$ShopinBitSettingsTableOrderingComposer, - $$ShopinBitSettingsTableAnnotationComposer, - $$ShopinBitSettingsTableCreateCompanionBuilder, - $$ShopinBitSettingsTableUpdateCompanionBuilder, + $ShopInBitSettingsTable, + ShopInBitSetting, + $$ShopInBitSettingsTableFilterComposer, + $$ShopInBitSettingsTableOrderingComposer, + $$ShopInBitSettingsTableAnnotationComposer, + $$ShopInBitSettingsTableCreateCompanionBuilder, + $$ShopInBitSettingsTableUpdateCompanionBuilder, ( - ShopinBitSetting, + ShopInBitSetting, BaseReferences< _$SharedDatabase, - $ShopinBitSettingsTable, - ShopinBitSetting + $ShopInBitSettingsTable, + ShopInBitSetting >, ), - ShopinBitSetting, + ShopInBitSetting, PrefetchHooks Function() >; typedef $$ShopInBitTicketsTableCreateCompanionBuilder = ShopInBitTicketsCompanion Function({ - required String ticketId, - required String displayName, + required int apiTicketId, + required String customerKey, + required String ticketNumber, required ShopInBitCategory category, - required ShopInBitOrderStatus status, - Value statusRaw, required String requestDescription, required String deliveryCountry, + required ShopInBitOrderStatus status, + required String statusRaw, Value offerProductName, Value offerPrice, - required String shippingName, - required String shippingStreet, - required String shippingCity, - required String shippingPostalCode, - required String shippingCountry, - Value paymentMethod, - required List messages, - required DateTime createdAt, - required int apiTicketId, - Value carResearchInvoiceId, + Value paymentInvoiceStatus, + Value trackingLink, + Value lastAgentMessageAt, Value feeTicketNumber, - required bool needsCreateRequest, - required bool isPendingPayment, - Value carResearchExpiresAt, - Value carResearchPaymentLinks, - Value rowid, + Value> messages, + Value createdAt, + Value updatedAt, }); typedef $$ShopInBitTicketsTableUpdateCompanionBuilder = ShopInBitTicketsCompanion Function({ - Value ticketId, - Value displayName, + Value apiTicketId, + Value customerKey, + Value ticketNumber, Value category, - Value status, - Value statusRaw, Value requestDescription, Value deliveryCountry, + Value status, + Value statusRaw, Value offerProductName, Value offerPrice, - Value shippingName, - Value shippingStreet, - Value shippingCity, - Value shippingPostalCode, - Value shippingCountry, - Value paymentMethod, - Value> messages, - Value createdAt, - Value apiTicketId, - Value carResearchInvoiceId, + Value paymentInvoiceStatus, + Value trackingLink, + Value lastAgentMessageAt, Value feeTicketNumber, - Value needsCreateRequest, - Value isPendingPayment, - Value carResearchExpiresAt, - Value carResearchPaymentLinks, - Value rowid, + Value> messages, + Value createdAt, + Value updatedAt, }); class $$ShopInBitTicketsTableFilterComposer @@ -2339,26 +2245,41 @@ class $$ShopInBitTicketsTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get ticketId => $composableBuilder( - column: $table.ticketId, + ColumnFilters get apiTicketId => $composableBuilder( + column: $table.apiTicketId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get displayName => $composableBuilder( - column: $table.displayName, + ColumnFilters get customerKey => $composableBuilder( + column: $table.customerKey, builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters + ColumnFilters get ticketNumber => $composableBuilder( + column: $table.ticketNumber, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get category => $composableBuilder( column: $table.category, builder: (column) => ColumnWithTypeConverterFilters(column), ); + ColumnFilters get requestDescription => $composableBuilder( + column: $table.requestDescription, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get deliveryCountry => $composableBuilder( + column: $table.deliveryCountry, + builder: (column) => ColumnFilters(column), + ); + ColumnWithTypeConverterFilters< ShopInBitOrderStatus, ShopInBitOrderStatus, - int + String > get status => $composableBuilder( column: $table.status, @@ -2370,16 +2291,6 @@ class $$ShopInBitTicketsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get requestDescription => $composableBuilder( - column: $table.requestDescription, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get deliveryCountry => $composableBuilder( - column: $table.deliveryCountry, - builder: (column) => ColumnFilters(column), - ); - ColumnFilters get offerProductName => $composableBuilder( column: $table.offerProductName, builder: (column) => ColumnFilters(column), @@ -2390,39 +2301,29 @@ class $$ShopInBitTicketsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get shippingName => $composableBuilder( - column: $table.shippingName, + ColumnFilters get paymentInvoiceStatus => $composableBuilder( + column: $table.paymentInvoiceStatus, builder: (column) => ColumnFilters(column), ); - ColumnFilters get shippingStreet => $composableBuilder( - column: $table.shippingStreet, + ColumnFilters get trackingLink => $composableBuilder( + column: $table.trackingLink, builder: (column) => ColumnFilters(column), ); - ColumnFilters get shippingCity => $composableBuilder( - column: $table.shippingCity, + ColumnFilters get lastAgentMessageAt => $composableBuilder( + column: $table.lastAgentMessageAt, builder: (column) => ColumnFilters(column), ); - ColumnFilters get shippingPostalCode => $composableBuilder( - column: $table.shippingPostalCode, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get shippingCountry => $composableBuilder( - column: $table.shippingCountry, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get paymentMethod => $composableBuilder( - column: $table.paymentMethod, + ColumnFilters get feeTicketNumber => $composableBuilder( + column: $table.feeTicketNumber, builder: (column) => ColumnFilters(column), ); ColumnWithTypeConverterFilters< - List, - List, + List, + List, String > get messages => $composableBuilder( @@ -2435,38 +2336,8 @@ class $$ShopInBitTicketsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get apiTicketId => $composableBuilder( - column: $table.apiTicketId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get carResearchInvoiceId => $composableBuilder( - column: $table.carResearchInvoiceId, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get feeTicketNumber => $composableBuilder( - column: $table.feeTicketNumber, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get needsCreateRequest => $composableBuilder( - column: $table.needsCreateRequest, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get isPendingPayment => $composableBuilder( - column: $table.isPendingPayment, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get carResearchExpiresAt => $composableBuilder( - column: $table.carResearchExpiresAt, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get carResearchPaymentLinks => $composableBuilder( - column: $table.carResearchPaymentLinks, + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnFilters(column), ); } @@ -2480,28 +2351,23 @@ class $$ShopInBitTicketsTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get ticketId => $composableBuilder( - column: $table.ticketId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get displayName => $composableBuilder( - column: $table.displayName, + ColumnOrderings get apiTicketId => $composableBuilder( + column: $table.apiTicketId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get category => $composableBuilder( - column: $table.category, + ColumnOrderings get customerKey => $composableBuilder( + column: $table.customerKey, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get status => $composableBuilder( - column: $table.status, + ColumnOrderings get ticketNumber => $composableBuilder( + column: $table.ticketNumber, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get statusRaw => $composableBuilder( - column: $table.statusRaw, + ColumnOrderings get category => $composableBuilder( + column: $table.category, builder: (column) => ColumnOrderings(column), ); @@ -2515,43 +2381,43 @@ class $$ShopInBitTicketsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get offerProductName => $composableBuilder( - column: $table.offerProductName, + ColumnOrderings get status => $composableBuilder( + column: $table.status, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get offerPrice => $composableBuilder( - column: $table.offerPrice, + ColumnOrderings get statusRaw => $composableBuilder( + column: $table.statusRaw, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get shippingName => $composableBuilder( - column: $table.shippingName, + ColumnOrderings get offerProductName => $composableBuilder( + column: $table.offerProductName, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get shippingStreet => $composableBuilder( - column: $table.shippingStreet, + ColumnOrderings get offerPrice => $composableBuilder( + column: $table.offerPrice, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get shippingCity => $composableBuilder( - column: $table.shippingCity, + ColumnOrderings get paymentInvoiceStatus => $composableBuilder( + column: $table.paymentInvoiceStatus, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get shippingPostalCode => $composableBuilder( - column: $table.shippingPostalCode, + ColumnOrderings get trackingLink => $composableBuilder( + column: $table.trackingLink, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get shippingCountry => $composableBuilder( - column: $table.shippingCountry, + ColumnOrderings get lastAgentMessageAt => $composableBuilder( + column: $table.lastAgentMessageAt, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get paymentMethod => $composableBuilder( - column: $table.paymentMethod, + ColumnOrderings get feeTicketNumber => $composableBuilder( + column: $table.feeTicketNumber, builder: (column) => ColumnOrderings(column), ); @@ -2565,38 +2431,8 @@ class $$ShopInBitTicketsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get apiTicketId => $composableBuilder( - column: $table.apiTicketId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get carResearchInvoiceId => $composableBuilder( - column: $table.carResearchInvoiceId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get feeTicketNumber => $composableBuilder( - column: $table.feeTicketNumber, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get needsCreateRequest => $composableBuilder( - column: $table.needsCreateRequest, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get isPendingPayment => $composableBuilder( - column: $table.isPendingPayment, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get carResearchExpiresAt => $composableBuilder( - column: $table.carResearchExpiresAt, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get carResearchPaymentLinks => $composableBuilder( - column: $table.carResearchPaymentLinks, + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => ColumnOrderings(column), ); } @@ -2610,22 +2446,23 @@ class $$ShopInBitTicketsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get ticketId => - $composableBuilder(column: $table.ticketId, builder: (column) => column); - - GeneratedColumn get displayName => $composableBuilder( - column: $table.displayName, + GeneratedColumn get apiTicketId => $composableBuilder( + column: $table.apiTicketId, builder: (column) => column, ); - GeneratedColumnWithTypeConverter get category => - $composableBuilder(column: $table.category, builder: (column) => column); + GeneratedColumn get customerKey => $composableBuilder( + column: $table.customerKey, + builder: (column) => column, + ); - GeneratedColumnWithTypeConverter get status => - $composableBuilder(column: $table.status, builder: (column) => column); + GeneratedColumn get ticketNumber => $composableBuilder( + column: $table.ticketNumber, + builder: (column) => column, + ); - GeneratedColumn get statusRaw => - $composableBuilder(column: $table.statusRaw, builder: (column) => column); + GeneratedColumnWithTypeConverter get category => + $composableBuilder(column: $table.category, builder: (column) => column); GeneratedColumn get requestDescription => $composableBuilder( column: $table.requestDescription, @@ -2637,6 +2474,12 @@ class $$ShopInBitTicketsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumnWithTypeConverter get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get statusRaw => + $composableBuilder(column: $table.statusRaw, builder: (column) => column); + GeneratedColumn get offerProductName => $composableBuilder( column: $table.offerProductName, builder: (column) => column, @@ -2647,77 +2490,34 @@ class $$ShopInBitTicketsTableAnnotationComposer builder: (column) => column, ); - GeneratedColumn get shippingName => $composableBuilder( - column: $table.shippingName, - builder: (column) => column, - ); - - GeneratedColumn get shippingStreet => $composableBuilder( - column: $table.shippingStreet, - builder: (column) => column, - ); - - GeneratedColumn get shippingCity => $composableBuilder( - column: $table.shippingCity, + GeneratedColumn get paymentInvoiceStatus => $composableBuilder( + column: $table.paymentInvoiceStatus, builder: (column) => column, ); - GeneratedColumn get shippingPostalCode => $composableBuilder( - column: $table.shippingPostalCode, + GeneratedColumn get trackingLink => $composableBuilder( + column: $table.trackingLink, builder: (column) => column, ); - GeneratedColumn get shippingCountry => $composableBuilder( - column: $table.shippingCountry, + GeneratedColumn get lastAgentMessageAt => $composableBuilder( + column: $table.lastAgentMessageAt, builder: (column) => column, ); - GeneratedColumn get paymentMethod => $composableBuilder( - column: $table.paymentMethod, + GeneratedColumn get feeTicketNumber => $composableBuilder( + column: $table.feeTicketNumber, builder: (column) => column, ); - GeneratedColumnWithTypeConverter, String> - get messages => + GeneratedColumnWithTypeConverter, String> get messages => $composableBuilder(column: $table.messages, builder: (column) => column); GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get apiTicketId => $composableBuilder( - column: $table.apiTicketId, - builder: (column) => column, - ); - - GeneratedColumn get carResearchInvoiceId => $composableBuilder( - column: $table.carResearchInvoiceId, - builder: (column) => column, - ); - - GeneratedColumn get feeTicketNumber => $composableBuilder( - column: $table.feeTicketNumber, - builder: (column) => column, - ); - - GeneratedColumn get needsCreateRequest => $composableBuilder( - column: $table.needsCreateRequest, - builder: (column) => column, - ); - - GeneratedColumn get isPendingPayment => $composableBuilder( - column: $table.isPendingPayment, - builder: (column) => column, - ); - - GeneratedColumn get carResearchExpiresAt => $composableBuilder( - column: $table.carResearchExpiresAt, - builder: (column) => column, - ); - - GeneratedColumn get carResearchPaymentLinks => $composableBuilder( - column: $table.carResearchPaymentLinks, - builder: (column) => column, - ); + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); } class $$ShopInBitTicketsTableTableManager @@ -2757,112 +2557,79 @@ class $$ShopInBitTicketsTableTableManager $$ShopInBitTicketsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value ticketId = const Value.absent(), - Value displayName = const Value.absent(), + Value apiTicketId = const Value.absent(), + Value customerKey = const Value.absent(), + Value ticketNumber = const Value.absent(), Value category = const Value.absent(), - Value status = const Value.absent(), - Value statusRaw = const Value.absent(), Value requestDescription = const Value.absent(), Value deliveryCountry = const Value.absent(), + Value status = const Value.absent(), + Value statusRaw = const Value.absent(), Value offerProductName = const Value.absent(), Value offerPrice = const Value.absent(), - Value shippingName = const Value.absent(), - Value shippingStreet = const Value.absent(), - Value shippingCity = const Value.absent(), - Value shippingPostalCode = const Value.absent(), - Value shippingCountry = const Value.absent(), - Value paymentMethod = const Value.absent(), - Value> messages = - const Value.absent(), - Value createdAt = const Value.absent(), - Value apiTicketId = const Value.absent(), - Value carResearchInvoiceId = const Value.absent(), + Value paymentInvoiceStatus = const Value.absent(), + Value trackingLink = const Value.absent(), + Value lastAgentMessageAt = const Value.absent(), Value feeTicketNumber = const Value.absent(), - Value needsCreateRequest = const Value.absent(), - Value isPendingPayment = const Value.absent(), - Value carResearchExpiresAt = const Value.absent(), - Value carResearchPaymentLinks = const Value.absent(), - Value rowid = const Value.absent(), + Value> messages = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), }) => ShopInBitTicketsCompanion( - ticketId: ticketId, - displayName: displayName, + apiTicketId: apiTicketId, + customerKey: customerKey, + ticketNumber: ticketNumber, category: category, - status: status, - statusRaw: statusRaw, requestDescription: requestDescription, deliveryCountry: deliveryCountry, + status: status, + statusRaw: statusRaw, offerProductName: offerProductName, offerPrice: offerPrice, - shippingName: shippingName, - shippingStreet: shippingStreet, - shippingCity: shippingCity, - shippingPostalCode: shippingPostalCode, - shippingCountry: shippingCountry, - paymentMethod: paymentMethod, + paymentInvoiceStatus: paymentInvoiceStatus, + trackingLink: trackingLink, + lastAgentMessageAt: lastAgentMessageAt, + feeTicketNumber: feeTicketNumber, messages: messages, createdAt: createdAt, - apiTicketId: apiTicketId, - carResearchInvoiceId: carResearchInvoiceId, - feeTicketNumber: feeTicketNumber, - needsCreateRequest: needsCreateRequest, - isPendingPayment: isPendingPayment, - carResearchExpiresAt: carResearchExpiresAt, - carResearchPaymentLinks: carResearchPaymentLinks, - rowid: rowid, + updatedAt: updatedAt, ), createCompanionCallback: ({ - required String ticketId, - required String displayName, + required int apiTicketId, + required String customerKey, + required String ticketNumber, required ShopInBitCategory category, - required ShopInBitOrderStatus status, - Value statusRaw = const Value.absent(), required String requestDescription, required String deliveryCountry, + required ShopInBitOrderStatus status, + required String statusRaw, Value offerProductName = const Value.absent(), Value offerPrice = const Value.absent(), - required String shippingName, - required String shippingStreet, - required String shippingCity, - required String shippingPostalCode, - required String shippingCountry, - Value paymentMethod = const Value.absent(), - required List messages, - required DateTime createdAt, - required int apiTicketId, - Value carResearchInvoiceId = const Value.absent(), + Value paymentInvoiceStatus = const Value.absent(), + Value trackingLink = const Value.absent(), + Value lastAgentMessageAt = const Value.absent(), Value feeTicketNumber = const Value.absent(), - required bool needsCreateRequest, - required bool isPendingPayment, - Value carResearchExpiresAt = const Value.absent(), - Value carResearchPaymentLinks = const Value.absent(), - Value rowid = const Value.absent(), + Value> messages = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), }) => ShopInBitTicketsCompanion.insert( - ticketId: ticketId, - displayName: displayName, + apiTicketId: apiTicketId, + customerKey: customerKey, + ticketNumber: ticketNumber, category: category, - status: status, - statusRaw: statusRaw, requestDescription: requestDescription, deliveryCountry: deliveryCountry, + status: status, + statusRaw: statusRaw, offerProductName: offerProductName, offerPrice: offerPrice, - shippingName: shippingName, - shippingStreet: shippingStreet, - shippingCity: shippingCity, - shippingPostalCode: shippingPostalCode, - shippingCountry: shippingCountry, - paymentMethod: paymentMethod, + paymentInvoiceStatus: paymentInvoiceStatus, + trackingLink: trackingLink, + lastAgentMessageAt: lastAgentMessageAt, + feeTicketNumber: feeTicketNumber, messages: messages, createdAt: createdAt, - apiTicketId: apiTicketId, - carResearchInvoiceId: carResearchInvoiceId, - feeTicketNumber: feeTicketNumber, - needsCreateRequest: needsCreateRequest, - isPendingPayment: isPendingPayment, - carResearchExpiresAt: carResearchExpiresAt, - carResearchPaymentLinks: carResearchPaymentLinks, - rowid: rowid, + updatedAt: updatedAt, ), withReferenceMapper: (p0) => p0 .map((e) => (e.readTable(table), BaseReferences(db, table, e))) @@ -2899,24 +2666,40 @@ class $SharedDatabaseManager { $SharedDatabaseManager(this._db); $$CakepayOrdersTableTableManager get cakepayOrders => $$CakepayOrdersTableTableManager(_db, _db.cakepayOrders); - $$ShopinBitSettingsTableTableManager get shopinBitSettings => - $$ShopinBitSettingsTableTableManager(_db, _db.shopinBitSettings); + $$ShopInBitSettingsTableTableManager get shopInBitSettings => + $$ShopInBitSettingsTableTableManager(_db, _db.shopInBitSettings); $$ShopInBitTicketsTableTableManager get shopInBitTickets => $$ShopInBitTicketsTableTableManager(_db, _db.shopInBitTickets); } -mixin _$ShopinBitSettingsDaoMixin on DatabaseAccessor { - $ShopinBitSettingsTable get shopinBitSettings => - attachedDatabase.shopinBitSettings; - ShopinBitSettingsDaoManager get managers => ShopinBitSettingsDaoManager(this); +mixin _$ShopInBitSettingsDaoMixin on DatabaseAccessor { + $ShopInBitSettingsTable get shopInBitSettings => + attachedDatabase.shopInBitSettings; + ShopInBitSettingsDaoManager get managers => ShopInBitSettingsDaoManager(this); +} + +class ShopInBitSettingsDaoManager { + final _$ShopInBitSettingsDaoMixin _db; + ShopInBitSettingsDaoManager(this._db); + $$ShopInBitSettingsTableTableManager get shopInBitSettings => + $$ShopInBitSettingsTableTableManager( + _db.attachedDatabase, + _db.shopInBitSettings, + ); +} + +mixin _$ShopInBitTicketsDaoMixin on DatabaseAccessor { + $ShopInBitTicketsTable get shopInBitTickets => + attachedDatabase.shopInBitTickets; + ShopInBitTicketsDaoManager get managers => ShopInBitTicketsDaoManager(this); } -class ShopinBitSettingsDaoManager { - final _$ShopinBitSettingsDaoMixin _db; - ShopinBitSettingsDaoManager(this._db); - $$ShopinBitSettingsTableTableManager get shopinBitSettings => - $$ShopinBitSettingsTableTableManager( +class ShopInBitTicketsDaoManager { + final _$ShopInBitTicketsDaoMixin _db; + ShopInBitTicketsDaoManager(this._db); + $$ShopInBitTicketsTableTableManager get shopInBitTickets => + $$ShopInBitTicketsTableTableManager( _db.attachedDatabase, - _db.shopinBitSettings, + _db.shopInBitTickets, ); } diff --git a/lib/db/drift/shared_db/tables/shopin_bit_settings.dart b/lib/db/drift/shared_db/tables/shopin_bit_settings.dart index e4c32532ed..4438f90199 100644 --- a/lib/db/drift/shared_db/tables/shopin_bit_settings.dart +++ b/lib/db/drift/shared_db/tables/shopin_bit_settings.dart @@ -1,15 +1,30 @@ -import 'package:drift/drift.dart'; +import "package:drift/drift.dart"; -class ShopinBitSettings extends Table { - // Single row table - always row 0 - IntColumn get id => integer().withDefault(const Constant(0))(); +/// One row per ShopinBit customer key the user has ever generated or +/// recovered. Whichever row has the most recent `lastUsedAt` is the +/// "current" key — see `ShopInBitSettingsDao.getCurrentSettings`. +class ShopInBitSettings extends Table { + TextColumn get customerKey => text()(); - BoolColumn get guidelinesAccepted => + BoolColumn get privacyAccepted => boolean().withDefault(const Constant(false))(); + + BoolColumn get conciergeGuidelinesAccepted => + boolean().withDefault(const Constant(false))(); + BoolColumn get travelGuidelinesAccepted => + boolean().withDefault(const Constant(false))(); + BoolColumn get carGuidelinesAccepted => + boolean().withDefault(const Constant(false))(); + BoolColumn get setupComplete => boolean().withDefault(const Constant(false))(); - TextColumn get displayName => text().nullable()(); + + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get lastUsedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set> get primaryKey => {customerKey}; @override - Set get primaryKey => {id}; + bool get withoutRowId => true; } diff --git a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart index 450053a20e..54d8bd5dcd 100644 --- a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart +++ b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart @@ -2,112 +2,58 @@ import "dart:convert"; import "package:drift/drift.dart"; -import '../../../../models/shopinbit/shopinbit_order_model.dart' - show ShopInBitCategory, ShopInBitOrderStatus; +import "../../../../models/shopinbit/shopinbit_enums.dart"; +import "../../../../services/shopinbit/src/models/message.dart"; class ShopInBitTickets extends Table { - TextColumn get ticketId => text()(); - - TextColumn get displayName => text()(); - - IntColumn get category => intEnum()(); - IntColumn get status => intEnum()(); - TextColumn get statusRaw => text().nullable()(); + IntColumn get apiTicketId => integer()(); + TextColumn get customerKey => text()(); + TextColumn get ticketNumber => text()(); + TextColumn get category => textEnum()(); TextColumn get requestDescription => text()(); TextColumn get deliveryCountry => text()(); + + TextColumn get status => textEnum()(); + TextColumn get statusRaw => text()(); + TextColumn get offerProductName => text().nullable()(); TextColumn get offerPrice => text().nullable()(); - TextColumn get shippingName => text()(); - TextColumn get shippingStreet => text()(); - TextColumn get shippingCity => text()(); - TextColumn get shippingPostalCode => text()(); - TextColumn get shippingCountry => text()(); + TextColumn get paymentInvoiceStatus => text().nullable()(); + TextColumn get trackingLink => text().nullable()(); + DateTimeColumn get lastAgentMessageAt => dateTime().nullable()(); - TextColumn get paymentMethod => text().nullable()(); + TextColumn get feeTicketNumber => text().nullable()(); TextColumn get messages => - text().map(const ShopInBitTicketMessagesConverter())(); + text().map(const MessagesConverter()).withDefault(const Constant("[]"))(); - DateTimeColumn get createdAt => dateTime()(); - IntColumn get apiTicketId => integer()(); - - // Car research retry support - TextColumn get carResearchInvoiceId => text().nullable()(); - TextColumn get feeTicketNumber => text().nullable()(); - BoolColumn get needsCreateRequest => boolean()(); - - // Car research resumable payment state - BoolColumn get isPendingPayment => boolean()(); - DateTimeColumn get carResearchExpiresAt => dateTime().nullable()(); - TextColumn get carResearchPaymentLinks => text().nullable()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); @override - Set> get primaryKey => {ticketId}; -} - -class ShopInBitTicketMessage { - final String text; - final DateTime timestamp; - final bool isFromUser; - - const ShopInBitTicketMessage({ - required this.text, - required this.timestamp, - required this.isFromUser, - }); - - factory ShopInBitTicketMessage.fromJson(Map json) { - return ShopInBitTicketMessage( - text: json["text"] as String, - timestamp: DateTime.parse(json["timestamp"] as String), - isFromUser: json["isFromUser"] as bool, - ); - } - - Map toMap() { - return { - "text": text, - "timestamp": timestamp.toIso8601String(), - "isFromUser": isFromUser, - }; - } + Set> get primaryKey => {apiTicketId}; @override - String toString() => toMap().toString(); + bool get withoutRowId => true; } -class ShopInBitTicketMessagesConverter - extends TypeConverter, String> - with - JsonTypeConverter2< - List, - String, - List - > { - const ShopInBitTicketMessagesConverter(); - - @override - List fromSql(String fromDb) { - final List decoded = jsonDecode(fromDb) as List; - return fromJson(decoded); - } - - @override - String toSql(List value) { - return jsonEncode(toJson(value)); - } +/// Drift TypeConverter so `messages` round-trips between a JSON column and +/// `List` on the generated data class. +class MessagesConverter extends TypeConverter, String> { + const MessagesConverter(); @override - List fromJson(List json) { - return json - .map((e) => ShopInBitTicketMessage.fromJson(e as Map)) - .toList(); + List fromSql(String fromDb) { + final List raw = jsonDecode(fromDb) as List; + return raw + .map((e) => TicketMessage.fromJson(e as Map)) + .toList(growable: false); } @override - List toJson(List value) { - return value.map((m) => m.toMap()).toList(); + String toSql(List value) { + return jsonEncode(value.map((m) => m.toMap()).toList()); } } diff --git a/lib/models/shopinbit/shopinbit_enums.dart b/lib/models/shopinbit/shopinbit_enums.dart new file mode 100644 index 0000000000..eca8d63b3b --- /dev/null +++ b/lib/models/shopinbit/shopinbit_enums.dart @@ -0,0 +1,82 @@ +import 'dart:ui'; + +import "../../services/shopinbit/src/models/ticket.dart"; +import '../../themes/stack_colors.dart'; + +// Stable string identifiers — these names are persisted in the DB via +// `textEnum()`. Renaming any value silently corrupts existing rows; +// add new values to the end instead. + +enum ShopInBitCategory { + concierge, + travel, + car; + + /// Value used for `service_type` in `POST /requests`. Matches the API + /// spec strings exactly; equivalent to [name] for the current set. + String get apiValue => name; + + String get label => switch (this) { + .concierge => "Concierge", + .travel => "Travel", + .car => "Car", + }; +} + +enum ShopInBitOrderStatus { + pending, + reviewing, + offerAvailable, + accepted, + paymentPending, + paid, + shipping, + delivered, + closed, + cancelled, + refunded; + + String get label => switch (this) { + .pending => "Pending", + .reviewing => "Under review", + .offerAvailable => "Offer available", + .accepted => "Accepted", + .paymentPending => "Awaiting payment", + .paid => "Paid", + .shipping => "Shipping", + .delivered => "Delivered", + .closed => "Closed", + .cancelled => "Cancelled", + .refunded => "Refunded", + }; + + /// Maps a raw API ticket state to a customer-facing status. Returns null + /// for unrecognized states so the caller can decide whether to skip the + /// row entirely or keep the previous value. + static ShopInBitOrderStatus? fromTicketState(TicketState state) => + switch (state) { + .newTicket => ShopInBitOrderStatus.pending, + .checking || + .inProgress || + .replyNeeded => ShopInBitOrderStatus.reviewing, + .offerAvailable => ShopInBitOrderStatus.offerAvailable, + .clearing => ShopInBitOrderStatus.accepted, + .pendingClose => ShopInBitOrderStatus.paymentPending, + .shipped => ShopInBitOrderStatus.shipping, + .fulfilled => ShopInBitOrderStatus.delivered, + .closed || .merged => ShopInBitOrderStatus.closed, + .closedCancelled => ShopInBitOrderStatus.cancelled, + .refunded => ShopInBitOrderStatus.refunded, + .unknown => null, + }; +} + +extension ShopinbitStatusStyleExt on ShopInBitOrderStatus { + Color getColor(StackColors colors) => switch (this) { + .delivered => colors.accentColorGreen, + .offerAvailable => colors.accentColorBlue, + .pending || .reviewing => colors.accentColorYellow, + .closed || .cancelled || .refunded => colors.textSubtitle1, + _ => colors.accentColorDark, + }; +} diff --git a/lib/models/shopinbit/shopinbit_order_model.dart b/lib/models/shopinbit/shopinbit_order_model.dart deleted file mode 100644 index 14b530475d..0000000000 --- a/lib/models/shopinbit/shopinbit_order_model.dart +++ /dev/null @@ -1,382 +0,0 @@ -import 'dart:ui'; - -import 'package:drift/drift.dart'; -import 'package:flutter/foundation.dart'; - -import '../../db/drift/shared_db/shared_database.dart'; -import '../../db/drift/shared_db/tables/shopin_bit_tickets.dart'; -import '../../services/shopinbit/src/models/ticket.dart'; -import '../../themes/stack_colors.dart'; - -// these enum indexes are stored in a db. Do not edit order -enum ShopInBitCategory { concierge, travel, car } - -// these enum indexes are stored in a db. Do not edit order -enum ShopInBitOrderStatus { - pending, - reviewing, - offerAvailable, - accepted, - paymentPending, - paid, - shipping, - delivered, - closed, - cancelled, - refunded; - - String get label => switch (this) { - .pending => "Pending", - .reviewing => "Under review", - .offerAvailable => "Offer available", - .accepted => "Accepted", - .paymentPending => "Awaiting payment", - .paid => "Paid", - .shipping => "Shipping", - .delivered => "Delivered", - .closed => "Closed", - .cancelled => "Cancelled", - .refunded => "Refunded", - }; - - Color getColor(StackColors colors) => switch (this) { - .delivered => colors.accentColorGreen, - .offerAvailable => colors.accentColorBlue, - .pending || .reviewing => colors.accentColorYellow, - .closed || .cancelled || .refunded => colors.textSubtitle1, - _ => colors.accentColorDark, - }; -} - -class ShopInBitMessage { - final String text; - final DateTime timestamp; - final bool isFromUser; - - const ShopInBitMessage({ - required this.text, - required this.timestamp, - required this.isFromUser, - }); -} - -class ShopInBitOrderModel extends ChangeNotifier { - String _displayName = ""; - String get displayName => _displayName; - set displayName(String value) { - if (_displayName != value) { - _displayName = value; - notifyListeners(); - } - } - - bool _privacyAccepted = false; - bool get privacyAccepted => _privacyAccepted; - set privacyAccepted(bool value) { - if (_privacyAccepted != value) { - _privacyAccepted = value; - notifyListeners(); - } - } - - ShopInBitCategory? _category; - ShopInBitCategory? get category => _category; - set category(ShopInBitCategory? value) { - if (_category != value) { - _category = value; - notifyListeners(); - } - } - - bool _guidelinesAccepted = false; - bool get guidelinesAccepted => _guidelinesAccepted; - set guidelinesAccepted(bool value) { - if (_guidelinesAccepted != value) { - _guidelinesAccepted = value; - notifyListeners(); - } - } - - String _requestDescription = ""; - String get requestDescription => _requestDescription; - set requestDescription(String value) { - if (_requestDescription != value) { - _requestDescription = value; - notifyListeners(); - } - } - - String _deliveryCountry = ""; - String get deliveryCountry => _deliveryCountry; - set deliveryCountry(String value) { - if (_deliveryCountry != value) { - _deliveryCountry = value; - notifyListeners(); - } - } - - int _apiTicketId = 0; - int get apiTicketId => _apiTicketId; - set apiTicketId(int value) { - if (_apiTicketId != value) { - _apiTicketId = value; - notifyListeners(); - } - } - - String? _ticketId; - String? get ticketId => _ticketId; - set ticketId(String? value) { - if (_ticketId != value) { - _ticketId = value; - notifyListeners(); - } - } - - ShopInBitOrderStatus _status = ShopInBitOrderStatus.pending; - ShopInBitOrderStatus get status => _status; - set status(ShopInBitOrderStatus value) { - if (_status != value) { - _status = value; - notifyListeners(); - } - } - - // The most recent raw API state string, persisted alongside _status so that - // we can recover from contract drift (renames / new states) without losing - // history. _status is the parsed/mapped value; _statusRaw is the source of - // truth straight from the API. - String? _statusRaw; - String? get statusRaw => _statusRaw; - set statusRaw(String? value) { - if (_statusRaw != value) { - _statusRaw = value; - notifyListeners(); - } - } - - String? _offerProductName; - String? get offerProductName => _offerProductName; - - String? _offerPrice; - String? get offerPrice => _offerPrice; - - void setOffer({required String productName, required String price}) { - _offerProductName = productName; - _offerPrice = price; - _status = ShopInBitOrderStatus.offerAvailable; - notifyListeners(); - } - - String _shippingName = ""; - String get shippingName => _shippingName; - - String _shippingStreet = ""; - String get shippingStreet => _shippingStreet; - - String _shippingCity = ""; - String get shippingCity => _shippingCity; - - String _shippingPostalCode = ""; - String get shippingPostalCode => _shippingPostalCode; - - String _shippingCountry = ""; - String get shippingCountry => _shippingCountry; - - void setShippingAddress({ - required String name, - required String street, - required String city, - required String postalCode, - required String country, - }) { - _shippingName = name; - _shippingStreet = street; - _shippingCity = city; - _shippingPostalCode = postalCode; - _shippingCountry = country; - notifyListeners(); - } - - String? _paymentMethod; - String? get paymentMethod => _paymentMethod; - set paymentMethod(String? value) { - if (_paymentMethod != value) { - _paymentMethod = value; - notifyListeners(); - } - } - - String? _carResearchInvoiceId; - String? get carResearchInvoiceId => _carResearchInvoiceId; - set carResearchInvoiceId(String? value) { - if (_carResearchInvoiceId != value) { - _carResearchInvoiceId = value; - notifyListeners(); - } - } - - String? _feeTicketNumber; - String? get feeTicketNumber => _feeTicketNumber; - set feeTicketNumber(String? value) { - if (_feeTicketNumber != value) { - _feeTicketNumber = value; - notifyListeners(); - } - } - - bool _needsCreateRequest = false; - bool get needsCreateRequest => _needsCreateRequest; - set needsCreateRequest(bool value) { - if (_needsCreateRequest != value) { - _needsCreateRequest = value; - notifyListeners(); - } - } - - bool _isPendingPayment = false; - bool get isPendingPayment => _isPendingPayment; - set isPendingPayment(bool value) { - if (_isPendingPayment != value) { - _isPendingPayment = value; - notifyListeners(); - } - } - - DateTime? _carResearchExpiresAt; - DateTime? get carResearchExpiresAt => _carResearchExpiresAt; - set carResearchExpiresAt(DateTime? value) { - if (_carResearchExpiresAt != value) { - _carResearchExpiresAt = value; - notifyListeners(); - } - } - - String? _carResearchPaymentLinks; - String? get carResearchPaymentLinks => _carResearchPaymentLinks; - set carResearchPaymentLinks(String? value) { - if (_carResearchPaymentLinks != value) { - _carResearchPaymentLinks = value; - notifyListeners(); - } - } - - List _messages = []; - List get messages => List.unmodifiable(_messages); - void addMessage(ShopInBitMessage message) { - _messages.add(message); - notifyListeners(); - } - - void clearMessages() { - _messages.clear(); - } - - ShopInBitTicketsCompanion toCompanion() { - assert(_ticketId != null, "ticketId must be set before persisting"); - - final List messages = _messages - .map( - (m) => ShopInBitTicketMessage( - text: m.text, - timestamp: m.timestamp, - isFromUser: m.isFromUser, - ), - ) - .toList(); - - return ShopInBitTicketsCompanion( - ticketId: Value(_ticketId!), - displayName: Value(_displayName), - category: Value(_category ?? ShopInBitCategory.concierge), - status: Value(_status), - statusRaw: Value(_statusRaw), - requestDescription: Value(_requestDescription), - deliveryCountry: Value(_deliveryCountry), - offerProductName: Value(_offerProductName), - offerPrice: Value(_offerPrice), - shippingName: Value(_shippingName), - shippingStreet: Value(_shippingStreet), - shippingCity: Value(_shippingCity), - shippingPostalCode: Value(_shippingPostalCode), - shippingCountry: Value(_shippingCountry), - paymentMethod: Value(_paymentMethod), - apiTicketId: Value(_apiTicketId), - carResearchInvoiceId: Value(_carResearchInvoiceId), - feeTicketNumber: Value(_feeTicketNumber), - needsCreateRequest: Value(_needsCreateRequest), - isPendingPayment: Value(_isPendingPayment), - carResearchExpiresAt: Value(_carResearchExpiresAt), - carResearchPaymentLinks: Value(_carResearchPaymentLinks), - messages: Value(messages), - createdAt: Value(DateTime.now()), - ); - } - - static ShopInBitOrderModel fromDriftRow(ShopInBitTicket ticket) { - final List messages = ticket.messages - .map( - (m) => ShopInBitMessage( - text: m.text, - timestamp: m.timestamp, - isFromUser: m.isFromUser, - ), - ) - .toList(); - - return ShopInBitOrderModel() - .._displayName = ticket.displayName - .._category = ticket.category - .._apiTicketId = ticket.apiTicketId - .._ticketId = ticket.ticketId - .._status = ticket.status - .._statusRaw = ticket.statusRaw - .._requestDescription = ticket.requestDescription - .._deliveryCountry = ticket.deliveryCountry - .._offerProductName = ticket.offerProductName - .._offerPrice = ticket.offerPrice - .._shippingName = ticket.shippingName - .._shippingStreet = ticket.shippingStreet - .._shippingCity = ticket.shippingCity - .._shippingPostalCode = ticket.shippingPostalCode - .._shippingCountry = ticket.shippingCountry - .._paymentMethod = ticket.paymentMethod - .._carResearchInvoiceId = ticket.carResearchInvoiceId - .._feeTicketNumber = ticket.feeTicketNumber - .._needsCreateRequest = ticket.needsCreateRequest - .._isPendingPayment = ticket.isPendingPayment - .._carResearchExpiresAt = ticket.carResearchExpiresAt - .._carResearchPaymentLinks = ticket.carResearchPaymentLinks - .._messages = messages; - } - - static ShopInBitOrderStatus? statusFromTicketState(TicketState state) { - switch (state) { - case TicketState.newTicket: - return ShopInBitOrderStatus.pending; - case TicketState.checking: - case TicketState.inProgress: - case TicketState.replyNeeded: - return ShopInBitOrderStatus.reviewing; - case TicketState.offerAvailable: - return ShopInBitOrderStatus.offerAvailable; - case TicketState.clearing: - return ShopInBitOrderStatus.accepted; - case TicketState.pendingClose: - return ShopInBitOrderStatus.paymentPending; - case TicketState.shipped: - return ShopInBitOrderStatus.shipping; - case TicketState.fulfilled: - return ShopInBitOrderStatus.delivered; - case TicketState.closed: - case TicketState.merged: - return ShopInBitOrderStatus.closed; - case TicketState.closedCancelled: - return ShopInBitOrderStatus.cancelled; - case TicketState.refunded: - return ShopInBitOrderStatus.refunded; - case TicketState.unknown: - return null; - } - } -} diff --git a/lib/models/shopinbit/shopinbit_request_draft.dart b/lib/models/shopinbit/shopinbit_request_draft.dart new file mode 100644 index 0000000000..49be9fa6c2 --- /dev/null +++ b/lib/models/shopinbit/shopinbit_request_draft.dart @@ -0,0 +1,25 @@ +import 'shopinbit_enums.dart'; + +class ShopinbitRequestDraft { + final ShopInBitCategory category; + final String requestDescription; + final String deliveryCountry; + final String? voucherCode; + + ShopinbitRequestDraft({ + required this.category, + required this.requestDescription, + required this.deliveryCountry, + required this.voucherCode, + }); + + Map toMap() => { + "category": category.apiValue, + "requestDescription": requestDescription, + "deliveryCountry": deliveryCountry, + "voucherCode": voucherCode, + }; + + @override + String toString() => toMap().toString(); +} diff --git a/lib/pages/more_view/services_view.dart b/lib/pages/more_view/services_view.dart index f99bfa92aa..0561b07bde 100644 --- a/lib/pages/more_view/services_view.dart +++ b/lib/pages/more_view/services_view.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../db/drift/shared_db/shared_database.dart'; import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -30,19 +30,19 @@ class ServicesView extends ConsumerStatefulWidget { } class _ServicesViewState extends ConsumerState { - void _showShopDialog() { - showDialog( + Future _showShopDialog() async { + final result = await showDialog<(ShopInBitSetting?, bool)>( context: context, barrierDismissible: true, - builder: (dialogContext) => StackDialogBase( + builder: (context) => StackDialogBase( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("ShopinBit", style: STextStyles.pageTitleH2(dialogContext)), + Text("ShopinBit", style: STextStyles.pageTitleH2(context)), const SizedBox(height: 8), RichText( text: TextSpan( - style: STextStyles.smallMed14(dialogContext), + style: STextStyles.smallMed14(context), children: [ const TextSpan( text: @@ -53,9 +53,7 @@ class _ServicesViewState extends ConsumerState { ), TextSpan( text: "Privacy Policy", - style: STextStyles.richLink( - dialogContext, - ).copyWith(fontSize: 16), + style: STextStyles.richLink(context).copyWith(fontSize: 16), recognizer: TapGestureRecognizer() ..onTap = () async { const url = @@ -75,59 +73,28 @@ class _ServicesViewState extends ConsumerState { Row( children: [ Expanded( - child: TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - }, - child: Text( - "Cancel", - style: STextStyles.button(dialogContext).copyWith( - color: Theme.of( - dialogContext, - ).extension()!.accentColorDark, - ), - ), + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, ), ), const SizedBox(width: 8), Expanded( child: TextButton( - style: Theme.of(dialogContext) + style: Theme.of(context) .extension()! - .getPrimaryEnabledButtonStyle(dialogContext), + .getPrimaryEnabledButtonStyle(context), onPressed: () async { - Navigator.of(dialogContext).pop(); - final model = ShopInBitOrderModel(); final settings = await ref .read(pSharedDrift) - .shopinBitSettingsDao - .getSettings(); + .shopInBitSettingsDao + .getCurrentSettings(); - if (!mounted) return; + if (!context.mounted) return; - if (settings.setupComplete) { - // Returning user: pre-load display name, - // skip Step 1, go to Step 2 - final savedName = settings.displayName; - if (savedName != null && savedName.isNotEmpty) { - model.displayName = savedName; - } - await Navigator.of( - context, - ).pushNamed(ShopInBitStep2.routeName, arguments: model); - } else { - // First-time user: show setup flow - await Navigator.of(context).pushNamed( - ShopInBitSetupView.routeName, - arguments: model, - ); - } - if (mounted) setState(() {}); + Navigator.of(context).pop((true, settings)); }, - child: Text( - "Continue", - style: STextStyles.button(dialogContext), - ), + child: Text("Continue", style: STextStyles.button(context)), ), ), ], @@ -136,6 +103,17 @@ class _ServicesViewState extends ConsumerState { ), ), ); + + if (mounted && result != null && result.$2 == true) { + final settings = result.$1; + if (settings != null && settings.setupComplete) { + // Returning user: straight to category selection. + await Navigator.of(context).pushNamed(ShopInBitStep2.routeName); + } else { + // First-time (or incomplete) setup: show the key-backup screen. + await Navigator.of(context).pushNamed(ShopInBitSetupView.routeName); + } + } } @override @@ -267,7 +245,6 @@ class _ServicesViewState extends ConsumerState { await Navigator.of( context, ).pushNamed(ShopInBitTicketsView.routeName); - if (mounted) setState(() {}); }, ), ], diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart index 675765340f..cb4ec42a6a 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/helpers/restore_create_backup.dart @@ -148,10 +148,11 @@ abstract class SWB { static bool _checkShouldCancel( PreRestoreState? revertToState, SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, ) { if (_shouldCancelRestore) { if (revertToState != null) { - _revert(revertToState, secureStorageInterface); + _revert(revertToState, secureStorageInterface, shopinbitService); } else { _cancelCompleter!.complete(); _shouldCancelRestore = false; @@ -245,21 +246,15 @@ abstract class SWB { Logging.instance.i("SWB backing up shopin bit info"); final sharedDB = SharedDrift.get(); - final shopinBitSettings = await sharedDB.shopinBitSettings.select().get(); - final shopinBitCustomerKey = - await (ShopInBitService()..ensureInitialized(_secureStore)) - .loadCustomerKey(); - final shopinBitOrders = await sharedDB.shopInBitTickets.select().get(); + final shopinBitCustomerKeys = + await (sharedDB.select(sharedDB.shopInBitSettings) + ..orderBy([(t) => OrderingTerm.desc(t.lastUsedAt)])) + .map((row) => row.customerKey) + .get(); backupJson["shopinBit"] = { - if (shopinBitCustomerKey != null) - "shopinBitCustomerKey": shopinBitCustomerKey, - if (shopinBitSettings.isNotEmpty) - "shopinBitSettings": shopinBitSettings.first.toJson(), - if (shopinBitOrders.isNotEmpty) - "shopinBitOrders": shopinBitOrders - .map((e) => e.toJson()) - .toList(growable: false), + if (shopinBitCustomerKeys.isNotEmpty) + "shopinBitCustomerKeys": shopinBitCustomerKeys, }; Logging.instance.d("SWB backing up prefs"); @@ -620,6 +615,7 @@ abstract class SWB { StackRestoringUIState? uiState, Map oldToNewWalletIdMap, SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, ) async { final Map prefs = validJSON["prefs"] as Map; @@ -636,7 +632,7 @@ abstract class SWB { uiState?.preferences = StackRestoringStatus.restoring; Logging.instance.d("SWB restoring cakepay order ids and shop in bit info"); - await _restoreCakepayAndShopinBitInfo(validJSON, secureStorageInterface); + await _restoreCakepayAndShopinBitInfo(validJSON, shopinbitService); Logging.instance.d("SWB restoring prefs"); await _restorePrefs(prefs); @@ -702,6 +698,7 @@ abstract class SWB { String jsonBackup, StackRestoringUIState? uiState, SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, ) async { if (!Platform.isLinux) await WakelockPlus.enable(); @@ -741,7 +738,7 @@ abstract class SWB { // basic cancel check here // no reverting required yet as nothing has been written to store - if (_checkShouldCancel(null, secureStorageInterface)) { + if (_checkShouldCancel(null, secureStorageInterface, shopinbitService)) { return false; } @@ -750,10 +747,15 @@ abstract class SWB { uiState, oldToNewWalletIdMap, secureStorageInterface, + shopinbitService, ); // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -770,7 +772,11 @@ abstract class SWB { for (final walletbackup in wallets) { // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -826,13 +832,21 @@ abstract class SWB { // final failovers = nodeService.failoverNodesFor(coin: coin); // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } managers.add(Tuple2(walletbackup, info)); // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -845,7 +859,11 @@ abstract class SWB { } // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -856,7 +874,11 @@ abstract class SWB { // start restoring wallets for (final tuple in managers) { // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } final bools = await _asyncRestore( @@ -870,13 +892,21 @@ abstract class SWB { } // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } for (final Future status in restoreStatuses) { // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } await status; @@ -884,7 +914,11 @@ abstract class SWB { if (!Platform.isLinux) await WakelockPlus.disable(); // check if cancel was requested and restore previous state - if (_checkShouldCancel(preRestoreState, secureStorageInterface)) { + if (_checkShouldCancel( + preRestoreState, + secureStorageInterface, + shopinbitService, + )) { return false; } @@ -902,6 +936,7 @@ abstract class SWB { static Future _revert( PreRestoreState revertToState, SecureStorageInterface secureStorageInterface, + ShopInBitService shopinbitService, ) async { final Map prefs = revertToState.validJSON["prefs"] as Map; @@ -918,7 +953,7 @@ abstract class SWB { // cakepay and shopinbit await _restoreCakepayAndShopinBitInfo( revertToState.validJSON, - secureStorageInterface, + shopinbitService, ); // prefs @@ -1122,7 +1157,7 @@ abstract class SWB { static Future _restoreCakepayAndShopinBitInfo( Map backupJson, - SecureStorageInterface _secureStore, + ShopInBitService shopinbitService, ) async { final cakepayOrderIds = (backupJson["cakepayOrderIds"] as List? ?? []) .cast(); @@ -1130,53 +1165,16 @@ abstract class SWB { await CakePayService.instance.addOrderId(orderId); } - final sharedDB = SharedDrift.get(); final json = backupJson["shopinBit"] as Map? ?? {}; if (json.isEmpty) return; - final shopinBitCustomerKey = json["shopinBitCustomerKey"] as String?; - if (shopinBitCustomerKey != null) { - final currentKey = - await (ShopInBitService()..ensureInitialized(_secureStore)) - .loadCustomerKey(); - - if (currentKey != null && currentKey != shopinBitCustomerKey) { - // TODO come back to this at some point - // for now - Logging.instance.w( - "SWB restore found mismatching shopinbit customer keys. " - "Ignoring the backup data in favor of the current data.", - ); - return; + final shopinBitCustomerKeys = json["shopinBitCustomerKeys"] as List?; + if (shopinBitCustomerKeys != null && shopinBitCustomerKeys.isNotEmpty) { + for (final key in shopinBitCustomerKeys.cast()) { + await shopinbitService.recoverCustomerKey(key); } } - - final shopinBitSettings = json["shopinBitSettings"] as Map?; - if (shopinBitSettings != null) { - final settings = ShopinBitSetting.fromJson(shopinBitSettings.cast()); - - await sharedDB.transaction(() async { - await sharedDB - .into(sharedDB.shopinBitSettings) - .insertOnConflictUpdate(settings.toCompanion(true)); - }); - } - - final shopinBitOrders = json["shopinBitOrders"] as List?; - if (shopinBitOrders != null) { - final orders = shopinBitOrders - .map((e) => ShopInBitTicket.fromJson((e as Map).cast())) - .map((e) => e.toCompanion(true)); - - await sharedDB.transaction(() async { - for (final order in orders) { - await sharedDB - .into(sharedDB.shopInBitTickets) - .insertOnConflictUpdate(order); - } - }); - } } static Future _restorePrefs(Map prefs) async { diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/stack_restore_progress_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/stack_restore_progress_view.dart index 7b71dc8c64..2062ef3f9b 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/stack_restore_progress_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/sub_views/stack_restore_progress_view.dart @@ -19,6 +19,7 @@ import '../../../../../pages_desktop_specific/desktop_home_view.dart'; import '../../../../../pages_desktop_specific/desktop_menu.dart'; import '../../../../../providers/desktop/current_desktop_menu_item.dart'; import '../../../../../providers/global/secure_store_provider.dart'; +import '../../../../../providers/global/shopin_bit_service_provider.dart'; import '../../../../../providers/providers.dart'; import '../../../../../providers/stack_restore/stack_restoring_ui_state_provider.dart'; import '../../../../../themes/stack_colors.dart'; @@ -69,34 +70,32 @@ class _StackRestoreProgressViewState showDialog( barrierDismissible: false, context: context, - builder: - (_) => WillPopScope( - onWillPop: () async { - return shouldPop; - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Material( - color: Colors.transparent, - child: Center( - child: Text( - "Cancelling restore. Please wait.", - style: STextStyles.pageTitleH2(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textWhite, - ), - ), + builder: (_) => WillPopScope( + onWillPop: () async { + return shouldPop; + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Material( + color: Colors.transparent, + child: Center( + child: Text( + "Cancelling restore. Please wait.", + style: STextStyles.pageTitleH2(context).copyWith( + color: Theme.of( + context, + ).extension()!.textWhite, ), ), - const SizedBox(height: 64), - const Center(child: LoadingIndicator(width: 100)), - ], + ), ), - ), + const SizedBox(height: 64), + const Center(child: LoadingIndicator(width: 100)), + ], + ), + ), ), ); @@ -108,12 +107,12 @@ class _StackRestoreProgressViewState if (mounted) { !isDesktop ? Navigator.of(context).popUntil( - ModalRoute.withName( - widget.fromFile - ? RestoreFromEncryptedStringView.routeName - : StackBackupView.routeName, - ), - ) + ModalRoute.withName( + widget.fromFile + ? RestoreFromEncryptedStringView.routeName + : StackBackupView.routeName, + ), + ) : Navigator.of(context).popUntil((_) => count++ >= 2); } } @@ -164,6 +163,7 @@ class _StackRestoreProgressViewState widget.jsonString, uiState, ref.read(secureStoreProvider), + ref.read(pShopinBitService), ); } catch (e, s) { Logging.instance.w("$e\n$s", error: e, stackTrace: s); @@ -199,8 +199,9 @@ class _StackRestoreProgressViewState case StackRestoringStatus.waiting: return SvgPicture.asset( Assets.svg.loader, - color: - Theme.of(context).extension()!.buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, ); case StackRestoringStatus.restoring: return SvgPicture.asset( @@ -248,8 +249,9 @@ class _StackRestoreProgressViewState return WillPopScope( onWillPop: _onWillPop, child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -302,69 +304,22 @@ class _StackRestoreProgressViewState ); return !isDesktop ? RestoringItemCard( - left: SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, - child: Center( - child: SvgPicture.asset( - Assets.svg.gear, - width: 16, - height: 16, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), - right: SizedBox( - width: 20, - height: 20, - child: _getIconForState(state), - ), - title: "Preferences", - subTitle: - state == StackRestoringStatus.failed - ? Text( - "Something went wrong", - style: STextStyles.errorSmall(context), - ) - : null, - ) - : RoundedContainer( - padding: EdgeInsets.zero, - color: - Theme.of(context).extension()!.popupBG, - borderColor: - Theme.of( - context, - ).extension()!.background, - child: RestoringItemCard( left: SizedBox( width: 32, height: 32, child: RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, child: Center( child: SvgPicture.asset( Assets.svg.gear, width: 16, height: 16, - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -375,15 +330,56 @@ class _StackRestoreProgressViewState child: _getIconForState(state), ), title: "Preferences", - subTitle: - state == StackRestoringStatus.failed - ? Text( + subTitle: state == StackRestoringStatus.failed + ? Text( + "Something went wrong", + style: STextStyles.errorSmall(context), + ) + : null, + ) + : RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of( + context, + ).extension()!.popupBG, + borderColor: Theme.of( + context, + ).extension()!.background, + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + child: Center( + child: SvgPicture.asset( + Assets.svg.gear, + width: 16, + height: 16, + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(state), + ), + title: "Preferences", + subTitle: state == StackRestoringStatus.failed + ? Text( "Something went wrong", style: STextStyles.errorSmall(context), ) - : null, - ), - ); + : null, + ), + ); }, ), const SizedBox(height: 12), @@ -396,67 +392,21 @@ class _StackRestoreProgressViewState ); return !isDesktop ? RestoringItemCard( - left: SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, - child: Center( - child: AddressBookIcon( - width: 16, - height: 16, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), - right: SizedBox( - width: 20, - height: 20, - child: _getIconForState(state), - ), - title: "Address book", - subTitle: - state == StackRestoringStatus.failed - ? Text( - "Something went wrong", - style: STextStyles.errorSmall(context), - ) - : null, - ) - : RoundedContainer( - padding: EdgeInsets.zero, - color: - Theme.of(context).extension()!.popupBG, - borderColor: - Theme.of( - context, - ).extension()!.background, - child: RestoringItemCard( left: SizedBox( width: 32, height: 32, child: RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, child: Center( child: AddressBookIcon( width: 16, height: 16, - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -467,15 +417,55 @@ class _StackRestoreProgressViewState child: _getIconForState(state), ), title: "Address book", - subTitle: - state == StackRestoringStatus.failed - ? Text( + subTitle: state == StackRestoringStatus.failed + ? Text( + "Something went wrong", + style: STextStyles.errorSmall(context), + ) + : null, + ) + : RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of( + context, + ).extension()!.popupBG, + borderColor: Theme.of( + context, + ).extension()!.background, + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + child: Center( + child: AddressBookIcon( + width: 16, + height: 16, + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(state), + ), + title: "Address book", + subTitle: state == StackRestoringStatus.failed + ? Text( "Something went wrong", style: STextStyles.errorSmall(context), ) - : null, - ), - ); + : null, + ), + ); }, ), const SizedBox(height: 12), @@ -488,69 +478,22 @@ class _StackRestoreProgressViewState ); return !isDesktop ? RestoringItemCard( - left: SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, - child: Center( - child: SvgPicture.asset( - Assets.svg.node, - width: 16, - height: 16, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), - right: SizedBox( - width: 20, - height: 20, - child: _getIconForState(state), - ), - title: "Nodes", - subTitle: - state == StackRestoringStatus.failed - ? Text( - "Something went wrong", - style: STextStyles.errorSmall(context), - ) - : null, - ) - : RoundedContainer( - padding: EdgeInsets.zero, - color: - Theme.of(context).extension()!.popupBG, - borderColor: - Theme.of( - context, - ).extension()!.background, - child: RestoringItemCard( left: SizedBox( width: 32, height: 32, child: RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, child: Center( child: SvgPicture.asset( Assets.svg.node, width: 16, height: 16, - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -561,15 +504,56 @@ class _StackRestoreProgressViewState child: _getIconForState(state), ), title: "Nodes", - subTitle: - state == StackRestoringStatus.failed - ? Text( + subTitle: state == StackRestoringStatus.failed + ? Text( + "Something went wrong", + style: STextStyles.errorSmall(context), + ) + : null, + ) + : RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of( + context, + ).extension()!.popupBG, + borderColor: Theme.of( + context, + ).extension()!.background, + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + child: Center( + child: SvgPicture.asset( + Assets.svg.node, + width: 16, + height: 16, + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(state), + ), + title: "Nodes", + subTitle: state == StackRestoringStatus.failed + ? Text( "Something went wrong", style: STextStyles.errorSmall(context), ) - : null, - ), - ); + : null, + ), + ); }, ), const SizedBox(height: 12), @@ -582,69 +566,22 @@ class _StackRestoreProgressViewState ); return !isDesktop ? RestoringItemCard( - left: SizedBox( - width: 32, - height: 32, - child: RoundedContainer( - padding: const EdgeInsets.all(0), - color: - Theme.of( - context, - ).extension()!.buttonBackSecondary, - child: Center( - child: SvgPicture.asset( - Assets.svg.arrowsTwoWay, - width: 16, - height: 16, - color: - Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), - right: SizedBox( - width: 20, - height: 20, - child: _getIconForState(state), - ), - title: "Exchange history", - subTitle: - state == StackRestoringStatus.failed - ? Text( - "Something went wrong", - style: STextStyles.errorSmall(context), - ) - : null, - ) - : RoundedContainer( - padding: EdgeInsets.zero, - color: - Theme.of(context).extension()!.popupBG, - borderColor: - Theme.of( - context, - ).extension()!.background, - child: RestoringItemCard( left: SizedBox( width: 32, height: 32, child: RoundedContainer( padding: const EdgeInsets.all(0), - color: - Theme.of(context) - .extension()! - .buttonBackSecondary, + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, child: Center( child: SvgPicture.asset( Assets.svg.arrowsTwoWay, width: 16, height: 16, - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), ), @@ -655,15 +592,56 @@ class _StackRestoreProgressViewState child: _getIconForState(state), ), title: "Exchange history", - subTitle: - state == StackRestoringStatus.failed - ? Text( + subTitle: state == StackRestoringStatus.failed + ? Text( + "Something went wrong", + style: STextStyles.errorSmall(context), + ) + : null, + ) + : RoundedContainer( + padding: EdgeInsets.zero, + color: Theme.of( + context, + ).extension()!.popupBG, + borderColor: Theme.of( + context, + ).extension()!.background, + child: RestoringItemCard( + left: SizedBox( + width: 32, + height: 32, + child: RoundedContainer( + padding: const EdgeInsets.all(0), + color: Theme.of( + context, + ).extension()!.buttonBackSecondary, + child: Center( + child: SvgPicture.asset( + Assets.svg.arrowsTwoWay, + width: 16, + height: 16, + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + right: SizedBox( + width: 20, + height: 20, + child: _getIconForState(state), + ), + title: "Exchange history", + subTitle: state == StackRestoringStatus.failed + ? Text( "Something went wrong", style: STextStyles.errorSmall(context), ) - : null, - ), - ); + : null, + ), + ); }, ), const SizedBox(height: 16), @@ -685,55 +663,54 @@ class _StackRestoreProgressViewState const SizedBox(height: 30), SizedBox( width: MediaQuery.of(context).size.width - 32, - child: - !isDesktop - ? TextButton( - onPressed: () async { - if (_success) { - if (widget.shouldPushToHome) { - Navigator.of(context).popUntil( - ModalRoute.withName(HomeView.routeName), - ); - } else { - Navigator.of(context).pop(); - } + child: !isDesktop + ? TextButton( + onPressed: () async { + if (_success) { + if (widget.shouldPushToHome) { + Navigator.of(context).popUntil( + ModalRoute.withName(HomeView.routeName), + ); } else { - if (await _requestCancel()) { - await _cancel(); - } + Navigator.of(context).pop(); } - }, - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - child: Text( - _success ? "OK" : "Cancel restore process", - style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.buttonTextPrimary, - ), + } else { + if (await _requestCancel()) { + await _cancel(); + } + } + }, + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + child: Text( + _success ? "OK" : "Cancel restore process", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _success - ? PrimaryButton( + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _success + ? PrimaryButton( width: 248, buttonHeight: ButtonHeight.l, enabled: true, label: "Done", onPressed: () async { - final DesktopMenuItemId keyID = - DesktopMenuItemId.myStack; + const DesktopMenuItemId keyID = .myStack; ref - .read( - currentDesktopMenuItemProvider.state, - ) - .state = keyID; + .read( + currentDesktopMenuItemProvider + .state, + ) + .state = + keyID; if (widget.shouldPushToHome) { unawaited( @@ -756,7 +733,7 @@ class _StackRestoreProgressViewState } }, ) - : SecondaryButton( + : SecondaryButton( width: 248, buttonHeight: ButtonHeight.l, enabled: true, @@ -767,8 +744,8 @@ class _StackRestoreProgressViewState } }, ), - ], - ), + ], + ), ), ], ), diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 1606d4ae08..60ea3f4f6f 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -1,14 +1,13 @@ import 'dart:async'; -import 'dart:convert'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../providers/db/drift_provider.dart'; +import '../../models/shopinbit/shopinbit_request_draft.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; import '../../services/shopinbit/src/models/address.dart'; import '../../services/shopinbit/src/models/car_research.dart'; import '../../themes/stack_colors.dart'; @@ -31,11 +30,11 @@ import 'shopinbit_car_research_payment_view.dart'; import 'shopinbit_step_2.dart'; class ShopInBitCarFeeView extends ConsumerStatefulWidget { - const ShopInBitCarFeeView({super.key, required this.model}); + const ShopInBitCarFeeView({super.key, required this.draft}); static const String routeName = "/shopInBitCarFee"; - final ShopInBitOrderModel model; + final ShopinbitRequestDraft draft; @override ConsumerState createState() => @@ -98,14 +97,10 @@ class _ShopInBitCarFeeViewState extends ConsumerState { @override void initState() { super.initState(); - _nameController = TextEditingController(text: widget.model.shippingName); - _streetController = TextEditingController( - text: widget.model.shippingStreet, - ); - _cityController = TextEditingController(text: widget.model.shippingCity); - _postalCodeController = TextEditingController( - text: widget.model.shippingPostalCode, - ); + _nameController = TextEditingController(); + _streetController = TextEditingController(); + _cityController = TextEditingController(); + _postalCodeController = TextEditingController(); _nameFocusNode = FocusNode(); _streetFocusNode = FocusNode(); _cityFocusNode = FocusNode(); @@ -133,11 +128,6 @@ class _ShopInBitCarFeeViewState extends ConsumerState { } _fetchCountries(); - - // Pre-select country on resume if model already has a shipping country. - if (widget.model.shippingCountry.isNotEmpty) { - _selectedCountryIso = widget.model.shippingCountry; - } } @override @@ -216,13 +206,6 @@ class _ShopInBitCarFeeViewState extends ConsumerState { // Delivery address (always provided) final deliveryName = _splitFullName(_nameController.text); - widget.model.setShippingAddress( - name: _nameController.text.trim(), - street: _streetController.text.trim(), - city: _cityController.text.trim(), - postalCode: _postalCodeController.text.trim(), - country: _selectedCountryIso!, - ); // Billing address: use separate billing fields if different, // else use delivery @@ -251,9 +234,9 @@ class _ShopInBitCarFeeViewState extends ConsumerState { // Cache the car request alongside billing so the backend failsafe can // create the real car research ticket once the fee is paid. final request = CarResearchRequest( - customerPseudonym: widget.model.displayName, - comment: widget.model.requestDescription, - deliveryCountry: widget.model.deliveryCountry, + customerPseudonym: kShopInBitCustomerPseudonym, + comment: widget.draft.requestDescription, + deliveryCountry: widget.draft.deliveryCountry, ); final resp = await ref @@ -286,18 +269,8 @@ class _ShopInBitCarFeeViewState extends ConsumerState { final invoice = resp.value!; - // Persist pending state so the user can resume if they close the dialog. - // Sentinel ticketId; unique-replace index ensures at most one pending - // record. - widget.model.ticketId = "pending-car-research"; - widget.model.carResearchInvoiceId = invoice.btcpayInvoice; - widget.model.isPendingPayment = true; - widget.model.carResearchExpiresAt = invoice.expiresAt; - widget.model.carResearchPaymentLinks = jsonEncode(invoice.paymentLinks); - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(widget.model.toCompanion()); + // No local persistence: an unfinished fee is recovered server-side via + // `GET /car-research/invoices/current` (see the requests list). // Best-effort fee fetch; do not block navigation on fee parse failure. await _loadFee(invoice); @@ -307,7 +280,7 @@ class _ShopInBitCarFeeViewState extends ConsumerState { unawaited( Navigator.of(context).pushNamed( ShopInBitCarResearchPaymentView.routeName, - arguments: (widget.model, invoice), + arguments: invoice, ), ); } catch (e, s) { diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 138b331f8a..18676e9f4b 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -5,7 +5,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; @@ -31,15 +30,10 @@ import 'shopinbit_tickets_view.dart'; enum _PaymentFlowState { idle, polling, finalizing, complete, error } class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { - const ShopInBitCarResearchPaymentView({ - super.key, - required this.model, - required this.invoice, - }); + const ShopInBitCarResearchPaymentView({super.key, required this.invoice}); static const String routeName = "/shopInBitCarResearchPayment"; - final ShopInBitOrderModel model; final CarResearchInvoice invoice; @override @@ -84,7 +78,8 @@ class _ShopInBitCarResearchPaymentViewState paymentUri: _currentAddress, address: target.address, amount: target.amount, - model: widget.model, + // The car research fee is paid before any ticket exists. + apiTicketId: 0, // After the wallet send, pop back here so polling can continue. routeOnSuccessName: ShopInBitCarResearchPaymentView.routeName, ); @@ -280,8 +275,8 @@ class _ShopInBitCarResearchPaymentViewState setState(() => _flowState = _PaymentFlowState.finalizing); _pollTimer?.cancel(); - final db = ref.read(pSharedDrift); - final client = ref.read(pShopinBitService).client; + final service = ref.read(pShopinBitService); + final client = service.client; try { // Best-effort: the BTCPay webhook is the failsafe that finalizes the fee @@ -293,8 +288,7 @@ class _ShopInBitCarResearchPaymentViewState if (logResp.hasError || logResp.value == null) { // Payment is confirmed but we could not log it. The webhook will // finalize it server side, so send the user to their requests where - // the finalized ticket will appear, and leave the pending record so - // they can resume if needed. + // the finalized ticket will appear. if (mounted) { await showDialog( context: context, @@ -314,47 +308,29 @@ class _ShopInBitCarResearchPaymentViewState } final result = logResp.value!; - widget.model.feeTicketNumber = result.ticketNumber; // log-payment returns the partner-scoped fee receipt, which the customer - // key cannot poll. Adopt the customer-facing car research ticket the - // backend created from the cached request so polling targets it instead. + // key cannot poll. Pull the customer-facing car research ticket the + // backend created from the cached request into the local DB, then open + // it. `refreshAll` inserts it so the order-created view can read it. + await service.refreshAll(); final realTicket = await _resolveRealTicket(result.ticketId); - final prevTicketId = widget.model.ticketId; - if (realTicket != null) { - widget.model.apiTicketId = realTicket.id; - widget.model.ticketId = realTicket.number; - } else { - // Backend has not surfaced the ticket yet. Show the receipt number and - // leave polling disabled so we don't hammer the inaccessible receipt; - // the requests list refresh will pick up the real ticket later. - widget.model.apiTicketId = 0; - widget.model.ticketId = result.ticketNumber; - } - widget.model.status = ShopInBitOrderStatus.pending; - widget.model.isPendingPayment = false; - widget.model.needsCreateRequest = false; - - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(widget.model.toCompanion()); - - // Drop the sentinel pending row now that we have a real ticket id. - if (prevTicketId != null && prevTicketId != widget.model.ticketId) { - await (db.delete( - db.shopInBitTickets, - )..where((t) => t.ticketId.equals(prevTicketId))).go(); - } - if (!mounted) return; setState(() => _flowState = _PaymentFlowState.complete); - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); + if (realTicket != null) { + unawaited( + Navigator.of(context).pushNamed( + ShopInBitOrderCreated.routeName, + arguments: realTicket.id, + ), + ); + } else { + // Backend has not surfaced the ticket yet; the requests list will pick + // it up on its next refresh. + _popToTickets(); + } } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); @@ -373,26 +349,17 @@ class _ShopInBitCarResearchPaymentViewState } /// Find the customer-facing car research ticket the backend created from the - /// cached request, excluding the partner-scoped fee receipt and any ticket we - /// already track. Returns the newest match, or null if none is visible yet. + /// cached request, excluding the partner-scoped fee receipt. Returns the + /// newest match, or null if none is visible yet. Future _resolveRealTicket(int receiptTicketId) async { final service = ref.read(pShopinBitService); - final db = ref.read(pSharedDrift); try { final customerKey = await service.ensureCustomerKey(); final resp = await service.client.getTicketsByCustomer(customerKey); if (resp.hasError || resp.value == null) return null; - final knownApiIds = (await db.select(db.shopInBitTickets).get()) - .map((t) => t.apiTicketId) - .toSet(); - final candidates = - resp.value! - .where( - (t) => t.id != receiptTicketId && !knownApiIds.contains(t.id), - ) - .toList() + resp.value!.where((t) => t.id != receiptTicketId).toList() ..sort((a, b) => b.id.compareTo(a.id)); return candidates.isEmpty ? null : candidates.first; diff --git a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart index a6f85da132..2587a17fe5 100644 --- a/lib/pages/shopinbit/shopinbit_confirm_send_view.dart +++ b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart @@ -4,9 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/isar/models/isar_models.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; @@ -40,7 +40,7 @@ class ShopInBitConfirmSendView extends ConsumerStatefulWidget { required this.txData, required this.walletId, this.routeOnSuccessName = WalletView.routeName, - required this.model, + required this.apiTicketId, this.tokenContract, }); @@ -49,7 +49,7 @@ class ShopInBitConfirmSendView extends ConsumerStatefulWidget { final TxData txData; final String walletId; final String routeOnSuccessName; - final ShopInBitOrderModel model; + final int apiTicketId; final EthContract? tokenContract; @override @@ -61,7 +61,7 @@ class _ShopInBitConfirmSendViewState extends ConsumerState { late final String walletId; late final String routeOnSuccessName; - late final ShopInBitOrderModel model; + late final int apiTicketId; final isDesktop = Util.isDesktop; @@ -118,16 +118,12 @@ class _ShopInBitConfirmSendViewState TransactionNote(walletId: walletId, txid: txid, value: note), ); - // Update model status after successful broadcast - model.status = ShopInBitOrderStatus.paymentPending; - model.paymentMethod = widget.tokenContract != null - ? widget.tokenContract!.symbol.toUpperCase() - : coin.ticker.toUpperCase(); - - final db = ref.read(pSharedDrift); - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(model.toCompanion()); + // The server (and the BTCPay webhook) own ticket + payment state from + // here, so there's nothing to persist locally; just nudge a refresh so + // the ticket row reflects the new payment status promptly. + if (apiTicketId != 0) { + unawaited(ref.read(pShopinBitService).refreshOne(apiTicketId)); + } // pop back to wallet if (context.mounted) { @@ -250,12 +246,15 @@ class _ShopInBitConfirmSendViewState void initState() { walletId = widget.walletId; routeOnSuccessName = widget.routeOnSuccessName; - model = widget.model; + apiTicketId = widget.apiTicketId; super.initState(); } @override Widget build(BuildContext context) { + final ticketNumber = + ref.watch(pShopInBitTicket(apiTicketId)).asData?.value?.ticketNumber ?? + ""; return ConditionalParent( condition: !isDesktop, builder: (child) { @@ -674,7 +673,7 @@ class _ShopInBitConfirmSendViewState children: [ Text("Request ID", style: STextStyles.smallMed12(context)), Text( - model.ticketId ?? "", + ticketNumber, style: STextStyles.itemSubtitle12(context), textAlign: TextAlign.right, ), diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart index b1594930ba..54ac5bab19 100644 --- a/lib/pages/shopinbit/shopinbit_offer_view.dart +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/show_loading.dart'; @@ -19,11 +18,11 @@ import '../../widgets/stack_dialog.dart'; import 'shopinbit_shipping_view.dart'; class ShopInBitOfferView extends ConsumerStatefulWidget { - const ShopInBitOfferView({super.key, required this.model}); + const ShopInBitOfferView({super.key, required this.apiTicketId}); static const String routeName = "/shopInBitOffer"; - final ShopInBitOrderModel model; + final int apiTicketId; @override ConsumerState createState() => _ShopInBitOfferViewState(); @@ -35,7 +34,7 @@ class _ShopInBitOfferViewState extends ConsumerState { @override void initState() { super.initState(); - if (widget.model.apiTicketId != 0) { + if (widget.apiTicketId != 0) { _loadOffer(); } } @@ -43,19 +42,11 @@ class _ShopInBitOfferViewState extends ConsumerState { Future _loadOffer() async { setState(() => _loading = true); try { - final resp = await ref - .read(pShopinBitService) - .client - .getTicketFull(widget.model.apiTicketId); - if (!resp.hasError && resp.value != null) { - final t = resp.value!; - widget.model.setOffer( - productName: t.productName, - price: t.customerPrice, - ); - } + // Refresh pulls /full (offer product + price) into the ticket row, which + // we then read reactively from the DB stream. + await ref.read(pShopinBitService).refreshOne(widget.apiTicketId); } catch (_) { - // Fall back to local data + // Fall back to whatever the row already has. } finally { if (mounted) setState(() => _loading = false); } @@ -64,7 +55,10 @@ class _ShopInBitOfferViewState extends ConsumerState { @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; - final model = widget.model; + final ticket = ref + .watch(pShopInBitTicket(widget.apiTicketId)) + .asData + ?.value; final content = Column( mainAxisSize: .min, @@ -96,7 +90,7 @@ class _ShopInBitOfferViewState extends ConsumerState { ), const SizedBox(height: 4), Text( - model.offerProductName ?? (_loading ? "Loading..." : "N/A"), + ticket?.offerProductName ?? (_loading ? "Loading..." : "N/A"), style: isDesktop ? STextStyles.desktopTextSmall(context) : STextStyles.titleBold12(context), @@ -117,9 +111,9 @@ class _ShopInBitOfferViewState extends ConsumerState { ), const SizedBox(height: 4), Text( - _loading && model.offerPrice == null + _loading && ticket?.offerPrice == null ? "Loading..." - : "${model.offerPrice ?? '0'} EUR", + : "${ticket?.offerPrice ?? '0'} EUR", style: isDesktop ? STextStyles.desktopTextSmall(context) : STextStyles.titleBold12(context), @@ -148,8 +142,7 @@ class _ShopInBitOfferViewState extends ConsumerState { buttonHeight: Util.isDesktop ? ButtonHeight.l : null, enabled: !_loading, onPressed: () async { - // TODO verify this is ok to stay set to accepted if the next route pops back and then decline is tapped - model.status = ShopInBitOrderStatus.accepted; + final deliveryCountry = ticket?.deliveryCountry ?? ""; final shopinBitApi = ref.read(pShopinBitService).client; final response = await showLoading( @@ -171,12 +164,12 @@ class _ShopInBitOfferViewState extends ConsumerState { response?.exception?.toString() ?? "Failed to fetch countries data"; } else if (response!.value! - .where((c) => c['iso'] == model.deliveryCountry) + .where((c) => c['iso'] == deliveryCountry) .length != 1) { errorMessage = "Delivery country code \"" - "${model.deliveryCountry}" + "$deliveryCountry" "\" is invalid"; } @@ -197,7 +190,11 @@ class _ShopInBitOfferViewState extends ConsumerState { if (context.mounted) { await Navigator.of(context).pushNamed( ShopInBitShippingView.routeName, - arguments: (model: model, countries: response!.value!), + arguments: ( + apiTicketId: widget.apiTicketId, + deliveryCountry: deliveryCountry, + countries: response!.value!, + ), ); } }, diff --git a/lib/pages/shopinbit/shopinbit_order_created.dart b/lib/pages/shopinbit/shopinbit_order_created.dart index 9680519e0c..0389a24d78 100644 --- a/lib/pages/shopinbit/shopinbit_order_created.dart +++ b/lib/pages/shopinbit/shopinbit_order_created.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; @@ -18,12 +19,12 @@ import '../../widgets/rounded_white_container.dart'; import '../more_view/services_view.dart'; import 'shopinbit_ticket_detail.dart'; -class ShopInBitOrderCreated extends StatelessWidget { - const ShopInBitOrderCreated({super.key, required this.model}); +class ShopInBitOrderCreated extends ConsumerWidget { + const ShopInBitOrderCreated({super.key, required this.apiTicketId}); static const String routeName = "/shopInBitOrderCreated"; - final ShopInBitOrderModel model; + final int apiTicketId; static void _popToServices(BuildContext context) { Navigator.of(context).popUntil((route) { @@ -38,8 +39,9 @@ class ShopInBitOrderCreated extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final isDesktop = Util.isDesktop; + final ticket = ref.watch(pShopInBitTicket(apiTicketId)).asData?.value; return ConditionalParent( condition: isDesktop, @@ -166,7 +168,7 @@ class ShopInBitOrderCreated extends StatelessWidget { : STextStyles.itemSubtitle12(context), ), Text( - model.ticketId ?? "N/A", + ticket?.ticketNumber ?? "N/A", style: isDesktop ? STextStyles.desktopTextSmall(context) : STextStyles.titleBold12(context), @@ -216,7 +218,7 @@ class ShopInBitOrderCreated extends StatelessWidget { onPressed: () { Navigator.of(context).pushNamed( ShopInBitTicketDetail.routeName, - arguments: model, + arguments: apiTicketId, ); }, ), diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index bd6aade79f..93a6974bf8 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; @@ -132,7 +131,7 @@ Future _pushShopInBitSendFrom({ required CryptoCurrency coin, required Amount? amount, required String address, - required ShopInBitOrderModel model, + required int apiTicketId, EthContract? tokenContract, bool popDesktopBeforeShow = false, String? routeOnSuccessName, @@ -147,7 +146,7 @@ Future _pushShopInBitSendFrom({ coin: coin, amount: amount, address: address, - model: model, + apiTicketId: apiTicketId, shouldPopRoot: true, tokenContract: tokenContract, ), @@ -160,7 +159,7 @@ Future _pushShopInBitSendFrom({ coin: coin, amount: amount, address: address, - model: model, + apiTicketId: apiTicketId, tokenContract: tokenContract, routeOnSuccessName: routeOnSuccessName, ), @@ -178,7 +177,7 @@ Future tryNavigateToShopInBitWalletSend({ required String paymentUri, required String address, required Amount? amount, - required ShopInBitOrderModel model, + required int apiTicketId, bool popDesktopBeforeShow = false, String? routeOnSuccessName, }) async { @@ -191,7 +190,7 @@ Future tryNavigateToShopInBitWalletSend({ coin: coin, amount: amount, address: address, - model: model, + apiTicketId: apiTicketId, popDesktopBeforeShow: popDesktopBeforeShow, routeOnSuccessName: routeOnSuccessName, ); @@ -211,7 +210,7 @@ Future tryNavigateToShopInBitWalletSend({ coin: ethCoin, amount: amount, address: address, - model: model, + apiTicketId: apiTicketId, tokenContract: tokenContract, popDesktopBeforeShow: popDesktopBeforeShow, routeOnSuccessName: routeOnSuccessName, diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 8f4105c9ea..97888d094d 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -7,7 +7,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; @@ -31,13 +30,13 @@ import 'shopinbit_payment_shared.dart'; class ShopInBitPaymentView extends ConsumerStatefulWidget { const ShopInBitPaymentView({ super.key, - required this.model, + required this.apiTicketId, required this.paymentInfo, }); static const String routeName = "/shopInBitPayment"; - final ShopInBitOrderModel model; + final int apiTicketId; // Caller loads this before pushing, so we always open with usable addresses. final PaymentInfo paymentInfo; @@ -60,8 +59,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { String get _currentAddress => _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : ""; - String get _totalPrice => - _paymentInfo?.customerPrice ?? widget.model.offerPrice ?? "0"; + String get _totalPrice => _paymentInfo?.customerPrice ?? "0"; String get _status => _paymentInfo?.status ?? 'ready_to_pay'; @@ -80,7 +78,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { void initState() { super.initState(); _applyPaymentInfo(widget.paymentInfo); - if (widget.model.apiTicketId != 0) { + if (widget.apiTicketId != 0) { _startPolling(); } } @@ -113,7 +111,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { final resp = await ref .read(pShopinBitService) .client - .getPayment(widget.model.apiTicketId); + .getPayment(widget.apiTicketId); if (!resp.hasError && resp.value != null && mounted) { setState(() => _applyPaymentInfo(resp.value!)); if (_isTerminal) { @@ -129,7 +127,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { whileFuture: ref .read(pShopinBitService) .client - .putPayment(widget.model.apiTicketId), + .putPayment(widget.apiTicketId), context: context, message: "Refreshing invoice", ); @@ -146,7 +144,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { whileFuture: ref .read(pShopinBitService) .client - .getPayment(widget.model.apiTicketId), + .getPayment(widget.apiTicketId), context: context, message: "Checking for payment", ); @@ -223,7 +221,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { paymentUri: _currentAddress, address: target.address, amount: target.amount, - model: widget.model, + apiTicketId: widget.apiTicketId, popDesktopBeforeShow: true, )) { return; diff --git a/lib/pages/shopinbit/shopinbit_send_from_view.dart b/lib/pages/shopinbit/shopinbit_send_from_view.dart index d2c08e26b3..3e6bbeff1f 100644 --- a/lib/pages/shopinbit/shopinbit_send_from_view.dart +++ b/lib/pages/shopinbit/shopinbit_send_from_view.dart @@ -8,7 +8,6 @@ import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../models/isar/models/blockchain_data/address.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../pages_desktop_specific/desktop_home_view.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; @@ -45,7 +44,7 @@ class ShopInBitSendFromView extends ConsumerStatefulWidget { const ShopInBitSendFromView({ super.key, required this.coin, - required this.model, + required this.apiTicketId, this.amount, required this.address, this.shouldPopRoot = false, @@ -58,7 +57,7 @@ class ShopInBitSendFromView extends ConsumerStatefulWidget { final CryptoCurrency coin; final Amount? amount; final String address; - final ShopInBitOrderModel model; + final int apiTicketId; final bool shouldPopRoot; final EthContract? tokenContract; // If set, overrides the default success route (HomeView/DesktopHomeView). @@ -73,7 +72,7 @@ class _ShopInBitSendFromViewState extends ConsumerState { late final CryptoCurrency coin; late final Amount? amount; late final String address; - late final ShopInBitOrderModel model; + late final int apiTicketId; late final EthContract? tokenContract; @override @@ -81,7 +80,7 @@ class _ShopInBitSendFromViewState extends ConsumerState { coin = widget.coin; address = widget.address; amount = widget.amount; - model = widget.model; + apiTicketId = widget.apiTicketId; tokenContract = widget.tokenContract; super.initState(); } @@ -196,7 +195,7 @@ class _ShopInBitSendFromViewState extends ConsumerState { walletId: walletIds[index], amount: amount, address: address, - model: model, + apiTicketId: apiTicketId, tokenContract: tokenContract, routeOnSuccessName: widget.routeOnSuccessName, ), @@ -217,7 +216,7 @@ class ShopInBitSendFromCard extends ConsumerStatefulWidget { required this.walletId, this.amount, required this.address, - required this.model, + required this.apiTicketId, this.tokenContract, this.routeOnSuccessName, }); @@ -225,7 +224,7 @@ class ShopInBitSendFromCard extends ConsumerStatefulWidget { final String walletId; final Amount? amount; final String address; - final ShopInBitOrderModel model; + final int apiTicketId; final EthContract? tokenContract; final String? routeOnSuccessName; @@ -238,7 +237,7 @@ class _ShopInBitSendFromCardState extends ConsumerState { late final String walletId; late final Amount? amount; late final String address; - late final ShopInBitOrderModel model; + late final int apiTicketId; late final EthContract? tokenContract; Future _send() async { @@ -380,7 +379,7 @@ class _ShopInBitSendFromCardState extends ConsumerState { (Util.isDesktop ? DesktopHomeView.routeName : HomeView.routeName), - model: model, + apiTicketId: apiTicketId, tokenContract: tokenContract, ), settings: const RouteSettings( @@ -431,7 +430,7 @@ class _ShopInBitSendFromCardState extends ConsumerState { walletId = widget.walletId; amount = widget.amount; address = widget.address; - model = widget.model; + apiTicketId = widget.apiTicketId; tokenContract = widget.tokenContract; super.initState(); } diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index bb92bcd9a1..757c771320 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -37,31 +37,21 @@ class ShopInBitSettingsView extends ConsumerStatefulWidget { class _ShopInBitSettingsViewState extends ConsumerState { final _manualKeyController = TextEditingController(); - final _displayNameController = TextEditingController(); String? _currentKey; bool _loading = false; - bool _savingName = false; @override void initState() { super.initState(); - // not the greatest solution but its the least invasive with the current - // ui code impl () async { final settings = await ref .read(pSharedDrift) - .shopinBitSettingsDao - .getSettings(); + .shopInBitSettingsDao + .getCurrentSettings(); if (mounted) { - final key = await ref.read(pShopinBitService).loadCustomerKey(); - if (mounted) { - setState(() { - _currentKey = key; - _displayNameController.text = settings.displayName ?? ""; - }); - } + setState(() => _currentKey = settings?.customerKey); } }(); } @@ -69,30 +59,9 @@ class _ShopInBitSettingsViewState extends ConsumerState { @override void dispose() { _manualKeyController.dispose(); - _displayNameController.dispose(); super.dispose(); } - Future _saveDisplayName() async { - final name = _displayNameController.text.trim(); - if (name.isEmpty) return; - setState(() => _savingName = true); - try { - await ref.read(pSharedDrift).shopinBitSettingsDao.setDisplayName(name); - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Display name updated", - context: context, - ), - ); - } - } finally { - if (mounted) setState(() => _savingName = false); - } - } - Future _generate() async { if (_currentKey != null) { final proceed = await _showChangeWarning(); @@ -103,9 +72,7 @@ class _ShopInBitSettingsViewState extends ConsumerState { try { final String key; if (_currentKey != null) { - final resp = await ref.read(pShopinBitService).client.generateKey(); - key = resp.valueOrThrow; - await ref.read(pShopinBitService).setCustomerKey(key); + key = await ref.read(pShopinBitService).generateCustomerKey(); } else { key = await ref.read(pShopinBitService).ensureCustomerKey(); } @@ -150,7 +117,7 @@ class _ShopInBitSettingsViewState extends ConsumerState { setState(() => _loading = true); try { - await ref.read(pShopinBitService).setCustomerKey(newKey); + await ref.read(pShopinBitService).recoverCustomerKey(newKey); setState(() { _currentKey = newKey; _manualKeyController.clear(); @@ -498,38 +465,6 @@ class _ShopInBitSettingsViewState extends ConsumerState { label: "Set key", onPressed: _setManualKey, ), - const SizedBox(height: 20), - Text( - "Display Name", - style: STextStyles.desktopTextSmall(context), - ), - const SizedBox(height: 8), - Text( - "The name ShopinBit staff will see " - "when communicating with you.", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - const SizedBox(height: 16), - SizedBox( - width: 512, - child: AdaptiveTextField( - labelText: "Display name", - controller: _displayNameController, - onChangedComprehensive: (_) => setState(() {}), - ), - ), - const SizedBox(height: 16), - PrimaryButton( - width: 210, - buttonHeight: ButtonHeight.m, - enabled: - !_savingName && - _displayNameController.text.trim().isNotEmpty, - label: "Save", - onPressed: _saveDisplayName, - ), ], ), ), @@ -692,43 +627,6 @@ class _ShopInBitSettingsViewState extends ConsumerState { ), ), const SizedBox(height: 12), - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Display Name", - style: STextStyles.titleBold12(context), - ), - const SizedBox(height: 8), - Text( - "The name ShopinBit staff will see " - "when communicating with you.", - style: STextStyles.itemSubtitle12( - context, - ), - ), - const SizedBox(height: 12), - AdaptiveTextField( - labelText: "Display name", - controller: _displayNameController, - onChangedComprehensive: (_) => - setState(() {}), - ), - const SizedBox(height: 12), - PrimaryButton( - label: "Save", - enabled: - !_savingName && - _displayNameController.text - .trim() - .isNotEmpty, - onPressed: _saveDisplayName, - ), - ], - ), - ), - const SizedBox(height: 12), ], ), ), diff --git a/lib/pages/shopinbit/shopinbit_setup_view.dart b/lib/pages/shopinbit/shopinbit_setup_view.dart index b02d5f19f9..6e6ba90fb6 100644 --- a/lib/pages/shopinbit/shopinbit_setup_view.dart +++ b/lib/pages/shopinbit/shopinbit_setup_view.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; @@ -13,62 +12,43 @@ import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/rounded_white_container.dart'; -import '../../widgets/textfields/adaptive_text_field.dart'; import 'shopinbit_step_2.dart'; class ShopInBitSetupView extends ConsumerStatefulWidget { - const ShopInBitSetupView({super.key, required this.model}); + const ShopInBitSetupView({super.key}); static const String routeName = "/shopInBitSetup"; - final ShopInBitOrderModel model; - @override ConsumerState createState() => _ShopInBitSetupViewState(); } class _ShopInBitSetupViewState extends ConsumerState { late final Future _keyFuture; - final TextEditingController _nameController = TextEditingController(); - - bool get _canContinue => _nameController.text.trim().isNotEmpty; + String? _key; @override void initState() { super.initState(); _keyFuture = ref.read(pShopinBitService).ensureCustomerKey(); - - // not the greatest solution but its the least invasive with the current - // ui code impl () async { - final settings = await ref - .read(pSharedDrift) - .shopinBitSettingsDao - .getSettings(); - if (mounted) { - setState(() { - _nameController.text = settings.displayName ?? ""; - }); - } + final key = await _keyFuture; + if (mounted) setState(() => _key = key); }(); } - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - Future _completeSetup() async { - final name = _nameController.text.trim(); - widget.model.displayName = name; - await ref.read(pSharedDrift).shopinBitSettingsDao.setDisplayName(name); - await ref.read(pSharedDrift).shopinBitSettingsDao.setSetupComplete(true); + final key = _key; + if (key == null) return; + await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .setSetupComplete(key, true); if (mounted) { await Navigator.of( context, - ).pushReplacementNamed(ShopInBitStep2.routeName, arguments: widget.model); + ).pushReplacementNamed(ShopInBitStep2.routeName); } } @@ -162,24 +142,11 @@ class _ShopInBitSetupViewState extends ConsumerState { ); }, ), - const SizedBox(height: 32), - Text( - "Set a Display Name to use with ShopinBit staff", - style: STextStyles.smallMed12(context), - ), - const SizedBox(height: 8), - AdaptiveTextField( - labelText: "Display name", - controller: _nameController, - autocorrect: false, - enableSuggestions: false, - onChangedComprehensive: (_) => setState(() {}), - ), const Spacer(), PrimaryButton( label: "Complete Setup", - enabled: _canContinue, - onPressed: _canContinue ? _completeSetup : null, + enabled: _key != null, + onPressed: _key != null ? _completeSetup : null, ), ], ), diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index adb0892404..1f3c4125e1 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/address.dart'; import '../../services/shopinbit/src/models/payment.dart'; @@ -28,13 +27,15 @@ import 'shopinbit_payment_view.dart'; class ShopInBitShippingView extends ConsumerStatefulWidget { const ShopInBitShippingView({ super.key, - required this.model, + required this.apiTicketId, + required this.deliveryCountry, required this.countries, }); static const String routeName = "/shopInBitShipping"; - final ShopInBitOrderModel model; + final int apiTicketId; + final String deliveryCountry; final List> countries; @override @@ -113,7 +114,7 @@ class _ShopInBitShippingViewState extends ConsumerState { _billingCityFocusNode = FocusNode(); _billingPostalCodeFocusNode = FocusNode(); - _selectedCountryIso = widget.model.deliveryCountry; + _selectedCountryIso = widget.deliveryCountry; // firstWhere should never fail here as the caller of this widget must // check that countries contains the expected value. Failure here should be @@ -168,24 +169,6 @@ class _ShopInBitShippingViewState extends ConsumerState { final postalCode = _postalCodeController.text.trim(); final country = _selectedCountryIso; - widget.model.setShippingAddress( - name: name, - street: street, - city: city, - postalCode: postalCode, - country: country, - ); - - // The payment view needs a live invoice, so load it here and only navigate - // once we have usable payment links. - if (widget.model.apiTicketId == 0) { - // No ticket, nothing to invoice. - await _showPaymentLoadError( - "This request isn't ready for payment yet. Please try again.", - ); - return; - } - PaymentInfo? paymentInfo; setState(() => _submitting = true); try { @@ -216,7 +199,7 @@ class _ShopInBitShippingViewState extends ConsumerState { .read(pShopinBitService) .client .submitAddress( - widget.model.apiTicketId, + widget.apiTicketId, shipping: Address( firstName: firstName, lastName: lastName, @@ -233,10 +216,7 @@ class _ShopInBitShippingViewState extends ConsumerState { debugPrint("submitAddress failed: ${resp.exception?.message}"); } - paymentInfo = await fetchShopInBitPaymentInfo( - ref, - widget.model.apiTicketId, - ); + paymentInfo = await fetchShopInBitPaymentInfo(ref, widget.apiTicketId); } catch (e) { debugPrint("submitAddress threw: $e"); } finally { @@ -254,11 +234,9 @@ class _ShopInBitShippingViewState extends ConsumerState { return; } - unawaited( - Navigator.of(context).pushNamed( - ShopInBitPaymentView.routeName, - arguments: (widget.model, paymentInfo), - ), + await Navigator.of(context).pushNamed( + ShopInBitPaymentView.routeName, + arguments: (apiTicketId: widget.apiTicketId, paymentInfo: paymentInfo), ); } diff --git a/lib/pages/shopinbit/shopinbit_step_1.dart b/lib/pages/shopinbit/shopinbit_step_1.dart deleted file mode 100644 index a1fa23694c..0000000000 --- a/lib/pages/shopinbit/shopinbit_step_1.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../themes/stack_colors.dart'; -import '../../utilities/text_styles.dart'; -import '../../utilities/util.dart'; -import '../../widgets/background.dart'; -import '../../widgets/conditional_parent.dart'; -import '../../widgets/custom_buttons/app_bar_icon_button.dart'; -import '../../widgets/desktop/desktop_dialog_close_button.dart'; -import '../../widgets/desktop/primary_button.dart'; -import '../../widgets/dialogs/s_dialog.dart'; -import '../../widgets/textfields/adaptive_text_field.dart'; -import '../exchange_view/sub_widgets/step_row.dart'; -import 'shopinbit_step_2.dart'; - -class ShopInBitStep1 extends StatefulWidget { - const ShopInBitStep1({super.key, required this.model}); - - static const String routeName = "/shopInBitStep1"; - - final ShopInBitOrderModel model; - - @override - State createState() => _ShopInBitStep1State(); -} - -class _ShopInBitStep1State extends State { - late final TextEditingController _nameController; - - bool _canContinue = false; - - void _continue() { - widget.model.displayName = _nameController.text.trim(); - Navigator.of( - context, - ).pushNamed(ShopInBitStep2.routeName, arguments: widget.model); - } - - @override - void initState() { - super.initState(); - _canContinue = widget.model.displayName.isNotEmpty; - _nameController = TextEditingController(text: widget.model.displayName); - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isDesktop = Util.isDesktop; - - return ConditionalParent( - condition: isDesktop, - builder: (child) => SDialog( - child: SizedBox( - width: 580, - child: Column( - mainAxisSize: .min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "ShopinBit", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Flexible( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: child, - ), - ), - ], - ), - ), - ), - child: ConditionalParent( - condition: !isDesktop, - builder: (child) => Background( - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () => Navigator.of(context).pop(), - ), - title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: child), - ), - ), - ); - }, - ), - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - StepRow( - count: 4, - current: 0, - width: MediaQuery.of(context).size.width - 32, - ), - const SizedBox(height: 14), - Text( - "Create your profile", - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - SizedBox(height: isDesktop ? 16 : 8), - Text( - "Enter a display name to use with ShopinBit.", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - SizedBox(height: isDesktop ? 32 : 24), - AdaptiveTextField( - labelText: "Display name", - controller: _nameController, - autocorrect: false, - enableSuggestions: false, - onChangedComprehensive: (value) { - if (mounted && _canContinue != value.isNotEmpty) { - setState(() => _canContinue = value.isNotEmpty); - } - }, - ), - isDesktop ? const SizedBox(height: 32) : const Spacer(), - PrimaryButton( - label: "Next", - enabled: _canContinue, - onPressed: _canContinue ? _continue : null, - ), - if (isDesktop) const SizedBox(height: 32), - ], - ), - ), - ); - } -} diff --git a/lib/pages/shopinbit/shopinbit_step_2.dart b/lib/pages/shopinbit/shopinbit_step_2.dart index 2a4d9f09ff..742f198e78 100644 --- a/lib/pages/shopinbit/shopinbit_step_2.dart +++ b/lib/pages/shopinbit/shopinbit_step_2.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../db/drift/shared_db/shared_database.dart'; +import '../../models/shopinbit/shopinbit_enums.dart'; import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -21,15 +22,10 @@ import 'shopinbit_step_3.dart'; import 'shopinbit_step_4.dart'; class ShopInBitStep2 extends ConsumerStatefulWidget { - const ShopInBitStep2({ - super.key, - required this.model, - this.isActuallyFirstStep = false, - }); + const ShopInBitStep2({super.key, this.isActuallyFirstStep = false}); static const String routeName = "/shopInBitStep2"; - final ShopInBitOrderModel model; final bool isActuallyFirstStep; @override @@ -40,32 +36,33 @@ class _ShopInBitStep2State extends ConsumerState { ShopInBitCategory? _selected; Future _continue() async { - widget.model.category = _selected; - final skipGuidelines = - (await ref.read(pSharedDrift).shopinBitSettingsDao.getSettings()) - .guidelinesAccepted; + final category = _selected!; + + final settings = await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .getCurrentSettings(); + + if (settings == null) { + throw Exception("Shopinbit settings should never be null here. Fixme"); + } + if (!mounted) return; + final skipGuidelines = settings.guidelinesAcceptedFor(category); + if (skipGuidelines) { - widget.model.guidelinesAccepted = true; await Navigator.of( context, - ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); + ).pushNamed(ShopInBitStep4.routeName, arguments: category); } else { - await Navigator.of( - context, - ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model); + await Navigator.of(context).pushNamed( + ShopInBitStep3.routeName, + arguments: (category: category, customerKey: settings.customerKey), + ); } } - @override - void initState() { - super.initState(); - // Reset category selection. - widget.model.category = null; - _selected = null; - } - @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; diff --git a/lib/pages/shopinbit/shopinbit_step_3.dart b/lib/pages/shopinbit/shopinbit_step_3.dart index d5cae9d600..eb997111e8 100644 --- a/lib/pages/shopinbit/shopinbit_step_3.dart +++ b/lib/pages/shopinbit/shopinbit_step_3.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../models/shopinbit/shopinbit_enums.dart'; import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; @@ -17,11 +17,16 @@ import '../exchange_view/sub_widgets/step_row.dart'; import 'shopinbit_step_4.dart'; class ShopInBitStep3 extends ConsumerStatefulWidget { - const ShopInBitStep3({super.key, required this.model}); + const ShopInBitStep3({ + super.key, + required this.category, + required this.customerKey, + }); static const String routeName = "/shopInBitStep3"; - final ShopInBitOrderModel model; + final ShopInBitCategory category; + final String customerKey; @override ConsumerState createState() => _ShopInBitStep3State(); @@ -31,7 +36,7 @@ class _ShopInBitStep3State extends ConsumerState { bool _agreed = false; String _guidelinesText() { - switch (widget.model.category) { + switch (widget.category) { case ShopInBitCategory.concierge: return "Concierge Service Guidelines:\n\n" "\u2022 Minimum: fee of 100 EUR or minimum order " @@ -70,19 +75,19 @@ class _ShopInBitStep3State extends ConsumerState { "disguised as vehicle purchases.\n\n" "\u2022 Provide details about the make, model, year, " "and any specific requirements."; - case null: - return ""; } } - void _continue() { - widget.model.guidelinesAccepted = true; - // Persist acceptance. - ref.read(pSharedDrift).shopinBitSettingsDao.setGuidelinesAccepted(true); + Future _continue() async { + await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .setGuidelinesAccepted(widget.customerKey, widget.category, true); - Navigator.of( + if (!mounted) return; + await Navigator.of( context, - ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); + ).pushNamed(ShopInBitStep4.routeName, arguments: widget.category); } @override @@ -176,10 +181,7 @@ class _ShopInBitStep3State extends ConsumerState { ), Expanded( child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), + padding: const .only(bottom: 32, left: 32, right: 32, top: 16), child: content, ), ), diff --git a/lib/pages/shopinbit/shopinbit_step_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart index 605d6e22c7..80cf61316b 100644 --- a/lib/pages/shopinbit/shopinbit_step_4.dart +++ b/lib/pages/shopinbit/shopinbit_step_4.dart @@ -1,6 +1,6 @@ import "package:flutter/material.dart"; -import "../../models/shopinbit/shopinbit_order_model.dart"; +import "../../models/shopinbit/shopinbit_enums.dart"; import "../../themes/stack_colors.dart"; import "../../utilities/text_styles.dart"; import "../../utilities/util.dart"; @@ -12,15 +12,14 @@ import "../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.da import "../../widgets/dialogs/s_dialog.dart"; import "step_4_components/shopinbit_car_research_form.dart"; import "step_4_components/shopinbit_concierge_form.dart"; -import "step_4_components/shopinbit_generic_form.dart"; import "step_4_components/shopinbit_travel_form.dart"; class ShopInBitStep4 extends StatelessWidget { - const ShopInBitStep4({super.key, required this.model}); + const ShopInBitStep4({super.key, required this.category}); static const String routeName = "/shopInBitStep4"; - final ShopInBitOrderModel model; + final ShopInBitCategory category; @override Widget build(BuildContext context) { @@ -30,11 +29,10 @@ class ShopInBitStep4 extends StatelessWidget { child: ConditionalParent( condition: !Util.isDesktop, builder: (child) => _ShopInBitStep4MobileShell(content: child), - child: switch (model.category) { - ShopInBitCategory.concierge => ShopInBitConciergeForm(model: model), - ShopInBitCategory.car => ShopInBitCarResearchForm(model: model), - ShopInBitCategory.travel => ShopInBitTravelForm(model: model), - null => ShopInBitGenericForm(model: model), + child: switch (category) { + ShopInBitCategory.concierge => const ShopInBitConciergeForm(), + ShopInBitCategory.car => const ShopInBitCarResearchForm(), + ShopInBitCategory.travel => const ShopInBitTravelForm(), }, ), ); diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index e2eebf84f9..364407ad66 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -6,11 +6,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:intl/intl.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../providers/db/drift_provider.dart'; -import '../../providers/global/shopin_bit_orders_provider.dart'; +import '../../db/drift/shared_db/shared_database.dart'; +import '../../models/shopinbit/shopinbit_enums.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; -import '../../services/shopinbit/shopinbit_orders_service.dart'; +import '../../services/shopinbit/src/models/message.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; @@ -27,11 +26,11 @@ import '../../widgets/rounded_white_container.dart'; import 'shopinbit_offer_view.dart'; class ShopInBitTicketDetail extends ConsumerStatefulWidget { - const ShopInBitTicketDetail({super.key, required this.model}); + const ShopInBitTicketDetail({super.key, required this.apiTicketId}); static const String routeName = "/shopInBitTicketDetail"; - final ShopInBitOrderModel model; + final int apiTicketId; @override ConsumerState createState() => @@ -40,73 +39,72 @@ class ShopInBitTicketDetail extends ConsumerStatefulWidget { class _ShopInBitTicketDetailState extends ConsumerState { late final TextEditingController _messageController; - late final ShopInBitOrdersService _ordersService; - late final ShopInBitOrderModel _model; - bool _polling = false; + + // Optimistically-shown messages the user just sent, kept until the next + // refresh folds them into the persisted ticket row. + final List _pending = []; bool _sending = false; + int get _id => widget.apiTicketId; + @override void initState() { super.initState(); + _messageController = TextEditingController(); - _ordersService = ref.read(pShopInBitOrdersService); - _model = _ordersService.upsert(widget.model); - if (_model.apiTicketId != 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _polling = true; - _ordersService.startPolling( - _model.apiTicketId, - pollInBackground: !_isCarResearch, - ); - }); - } + + // start with a refresh right away and then start polling for updates + unawaited(_refresh().then((_) => _startPolling())); } @override void dispose() { - if (_polling) { - _ordersService.stopPolling(_model.apiTicketId); - } + _pollingTimer?.cancel(); + _pollingTimer = null; _messageController.dispose(); super.dispose(); } - bool get _isCarResearch => _model.category == ShopInBitCategory.car; + Timer? _pollingTimer; + Future _poll() async { + await _refresh(); + if (!mounted) return; + _pollingTimer = Timer(const Duration(seconds: 30), _poll); + } + + void _startPolling() { + _pollingTimer?.cancel(); + unawaited(_poll()); + } - Future _refresh() => _ordersService.refreshOne(_model.apiTicketId); + Future _refresh() => ref.read(pShopinBitService).refreshOne(_id); Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty || _sending) return; - setState(() => _sending = true); + setState(() { + _sending = true; + _pending.add( + TicketMessage( + timestamp: DateTime.now(), + fromAgent: false, + content: text, + ), + ); + }); _messageController.clear(); - // Add optimistic local message - _model.addMessage( - ShopInBitMessage(text: text, timestamp: DateTime.now(), isFromUser: true), - ); - setState(() {}); - try { - if (_model.apiTicketId != 0) { - await ref - .read(pShopinBitService) - .client - .sendMessage(_model.apiTicketId, text); - // Pull fresh state from the API via the service so the watcher updates. + final ok = await ref.read(pShopinBitService).sendMessage(_id, text); + if (ok) { + // Pull the server's copy into the DB row, then drop our optimistic one. await _refresh(); + if (mounted) setState(() => _pending.clear()); } - final db = ref.read(pSharedDrift); - unawaited( - db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(_model.toCompanion()), - ); } catch (_) { - // Keep optimistic local message + // Keep the optimistic message on failure so the text isn't lost. } finally { if (mounted) setState(() => _sending = false); } @@ -194,40 +192,35 @@ class _ShopInBitTicketDetailState extends ConsumerState { return widgets; } - Widget _chatBubble(ShopInBitMessage message, bool isDesktop) { - final textColor = message.isFromUser + Widget _chatBubble(TicketMessage message, bool isDesktop) { + final isFromUser = !message.fromAgent; + final textColor = isFromUser ? Theme.of(context).extension()!.buttonTextPrimary : Theme.of(context).extension()!.buttonTextSecondary; return Align( - alignment: message.isFromUser - ? Alignment.centerRight - : Alignment.centerLeft, + alignment: isFromUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( constraints: BoxConstraints(maxWidth: isDesktop ? 380 : 260), margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: message.isFromUser + color: isFromUser ? Theme.of(context).extension()!.buttonBackPrimary : Theme.of(context).extension()!.buttonBackSecondary, borderRadius: BorderRadius.only( topLeft: const Radius.circular(12), topRight: const Radius.circular(12), - bottomLeft: message.isFromUser - ? const Radius.circular(12) - : Radius.zero, - bottomRight: message.isFromUser - ? Radius.zero - : const Radius.circular(12), + bottomLeft: isFromUser ? const Radius.circular(12) : Radius.zero, + bottomRight: isFromUser ? Radius.zero : const Radius.circular(12), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (message.isFromUser) + if (isFromUser) Text( - message.text, + message.content, style: (isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) @@ -235,7 +228,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { .copyWith(color: textColor), ) else - ..._buildMessageContent(message.text, isDesktop, textColor), + ..._buildMessageContent(message.content, isDesktop, textColor), const SizedBox(height: 4), Text( _formatTime(message.timestamp), @@ -245,7 +238,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { : STextStyles.itemSubtitle12(context)) .copyWith( fontSize: 10, - color: message.isFromUser + color: isFromUser ? Colors.white.withOpacity(0.7) : Theme.of(context) .extension()! @@ -262,9 +255,15 @@ class _ShopInBitTicketDetailState extends ConsumerState { @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; - final service = ref.watch(pShopInBitOrdersService); - final model = service.get(_model.apiTicketId) ?? _model; - final isRefreshing = service.isRefreshing(_model.apiTicketId); + final ShopInBitTicket? ticket = ref + .watch(pShopInBitTicket(_id)) + .asData + ?.value; + + final ticketNumber = ticket?.ticketNumber ?? "Request"; + final status = ticket?.status ?? ShopInBitOrderStatus.pending; + final isCarResearch = ticket?.category == ShopInBitCategory.car; + final messages = [...?ticket?.messages, ..._pending]; final statusBar = Padding( padding: .only(bottom: isDesktop ? 12 : 8), @@ -276,7 +275,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ SelectableText( - model.ticketId ?? "Request", + ticketNumber, style: isDesktop ? STextStyles.desktopTextSmall(context) : STextStyles.titleBold12(context), @@ -285,18 +284,18 @@ class _ShopInBitTicketDetailState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: model.status + color: status .getColor(Theme.of(context).extension()!) .withOpacity(0.2), ), child: Text( - model.status.label, + status.label, style: (isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) : STextStyles.itemSubtitle12(context)) .copyWith( - color: model.status.getColor( + color: status.getColor( Theme.of(context).extension()!, ), ), @@ -307,7 +306,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { ), ); - final offerBanner = model.status == ShopInBitOrderStatus.offerAvailable + final offerBanner = status == ShopInBitOrderStatus.offerAvailable ? Padding( padding: .only(bottom: isDesktop ? 12 : 8), child: RoundedWhiteContainer( @@ -328,7 +327,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { onPressed: () { Navigator.of(context).pushNamed( ShopInBitOfferView.routeName, - arguments: model, + arguments: _id, ); }, ), @@ -346,8 +345,8 @@ class _ShopInBitTicketDetailState extends ConsumerState { ), const SizedBox(height: 4), Text( - "${model.offerProductName ?? 'Item'} \u2014 " - "${model.offerPrice ?? '0'} EUR", + "${ticket?.offerProductName ?? 'Item'} — " + "${ticket?.offerPrice ?? '0'} EUR", style: isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) : STextStyles.itemSubtitle12(context), @@ -360,7 +359,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { onPressed: () { Navigator.of(context).pushNamed( ShopInBitOfferView.routeName, - arguments: model, + arguments: _id, ); }, ), @@ -375,9 +374,9 @@ class _ShopInBitTicketDetailState extends ConsumerState { reverse: true, padding: const EdgeInsets.all(8), physics: const AlwaysScrollableScrollPhysics(), - itemCount: model.messages.length, + itemCount: messages.length, itemBuilder: (context, index) { - final message = model.messages[model.messages.length - 1 - index]; + final message = messages[messages.length - 1 - index]; return _chatBubble(message, isDesktop); }, ); @@ -443,7 +442,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { ); final requestDetailsSection = - _isCarResearch && model.requestDescription.isNotEmpty + isCarResearch && (ticket?.requestDescription.isNotEmpty ?? false) ? Padding( padding: EdgeInsets.only(bottom: isDesktop ? 12 : 8), child: RoundedWhiteContainer( @@ -463,7 +462,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { ), const SizedBox(height: 8), SelectableText( - model.requestDescription, + ticket!.requestDescription, style: isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) : STextStyles.itemSubtitle12(context), @@ -474,31 +473,11 @@ class _ShopInBitTicketDetailState extends ConsumerState { ) : const SizedBox.shrink(); - // After the fee is paid the backend creates the real car ticket from the - // cached request, so we surface a finalizing note instead of asking the - // client to create the request itself. - final finalizingNote = - model.needsCreateRequest && model.category == ShopInBitCategory.car - ? Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: RoundedWhiteContainer( - child: Text( - "We're finalizing your car research request. Pull to refresh " - "if it doesn't appear shortly.", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), - ), - ), - ) - : const SizedBox.shrink(); - final body = Column( mainAxisSize: .min, crossAxisAlignment: .stretch, children: [ statusBar, - finalizingNote, offerBanner, requestDetailsSection, chatArea, @@ -528,10 +507,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { Row( mainAxisSize: MainAxisSize.min, children: [ - RefreshButton( - isRefreshing: isRefreshing, - onPressed: _refresh, - ), + RefreshButton(isRefreshing: false, onPressed: _refresh), const SizedBox(width: 8), const DesktopDialogCloseButton(), ], @@ -564,7 +540,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { onPressed: () => Navigator.of(context).pop(), ), title: Text( - model.ticketId ?? "Request", + ticketNumber, style: STextStyles.navBarTitle(context), ), ), diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index 0f02a30f2e..aaecb69bb1 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -1,14 +1,11 @@ import "dart:async"; -import "dart:convert"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_svg/flutter_svg.dart"; import "../../db/drift/shared_db/shared_database.dart"; -import "../../models/shopinbit/shopinbit_order_model.dart"; -import "../../providers/db/drift_provider.dart"; -import "../../providers/global/shopin_bit_orders_provider.dart"; +import "../../models/shopinbit/shopinbit_enums.dart"; import "../../providers/global/shopin_bit_service_provider.dart"; import "../../services/shopinbit/src/models/car_research.dart"; import "../../themes/stack_colors.dart"; @@ -23,7 +20,6 @@ import "../../widgets/dialogs/s_dialog.dart"; import "../../widgets/loading_indicator.dart"; import "../../widgets/refresh_control.dart"; import "../../widgets/rounded_container.dart"; -import "shopinbit_car_fee_view.dart"; import "shopinbit_car_research_payment_view.dart"; import "shopinbit_ticket_detail.dart"; @@ -38,141 +34,87 @@ class ShopInBitTicketsView extends ConsumerStatefulWidget { } class _ShopInBitTicketsViewState extends ConsumerState { - List _tickets = []; - ShopInBitTicket? _pendingTicket; - StreamSubscription>? _ticketsSub; bool _refreshing = false; bool _resuming = false; + // An unfinished car research fee invoice recovered from the server, if any. + // The fee is paid before any ticket exists, so this is the only way to let + // the user resume it — there is no local "pending" row anymore. + CarResearchInvoice? _resumableInvoice; + @override void initState() { super.initState(); - final db = ref.read(pSharedDrift); - _ticketsSub = db.select(db.shopInBitTickets).watch().listen((rows) { - if (!mounted) return; - setState(() { - _pendingTicket = rows.where((t) => t.isPendingPayment).firstOrNull; - _tickets = rows - .where((t) => !t.isPendingPayment) - .map(ShopInBitOrderModel.fromDriftRow) - .toList(); - }); - }); WidgetsBinding.instance.addPostFrameCallback((_) => _refresh()); } - @override - void dispose() { - _ticketsSub?.cancel(); - super.dispose(); - } - Future _refresh() async { if (_refreshing) return; if (mounted) setState(() => _refreshing = true); try { - await ref.read(pShopInBitOrdersService).refreshAll(); + await Future.wait([ + ref.read(pShopinBitService).refreshAll(), + _loadResumableInvoice(), + ]); } finally { if (mounted) setState(() => _refreshing = false); } } - Future _resumeFlow(ShopInBitTicket pending) async { - if (_resuming) return; - final model = ShopInBitOrderModel.fromDriftRow(pending); - - // Recover the live invoice from the server first so resume works even if - // local invoice state was lost. - setState(() => _resuming = true); - List? current; + /// Pull the most recent still-payable car research invoice from + /// `GET /car-research/invoices/current` so we can surface a "resume" entry. + Future _loadResumableInvoice() async { + CarResearchInvoice? resumable; try { - current = (await ref - .read(pShopinBitService) - .client - .getCurrentCarResearchInvoices()) - .value; + final resp = await ref + .read(pShopinBitService) + .client + .getCurrentCarResearchInvoices(); + final invoices = resp.value; + if (invoices != null) { + for (final inv in invoices) { + final payable = + inv.expiresAt != null && + inv.paymentLinks.isNotEmpty && + (inv.expiresAt!.isAfter(DateTime.now()) || + carResearchIsFinalized(inv.status, inv.additional)); + if (payable) { + resumable = CarResearchInvoice( + btcpayInvoice: inv.invoiceId, + expiresAt: inv.expiresAt!, + paymentLinks: inv.paymentLinks, + ); + break; + } + } + } } catch (_) { - // Fall back to locally stored invoice state below. - } finally { - if (mounted) setState(() => _resuming = false); + // Leave _resumableInvoice unchanged on failure. + return; } - if (!mounted) return; - - final invoice = _liveInvoiceFrom(current, pending); + if (mounted) setState(() => _resumableInvoice = resumable); + } - if (invoice != null) { + Future _resumeFlow(CarResearchInvoice invoice) async { + if (_resuming) return; + setState(() => _resuming = true); + try { await Navigator.of(context).pushNamed( ShopInBitCarResearchPaymentView.routeName, - arguments: (model, invoice), - ); - } else { - // No recoverable invoice anywhere: re-create one from the fee view. - await Navigator.of( - context, - ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model); - } - } - - /// Pick a still-payable invoice, preferring the server's current invoices - /// and falling back to locally stored invoice state. - CarResearchInvoice? _liveInvoiceFrom( - List? current, - ShopInBitTicket pending, - ) { - if (current != null && current.isNotEmpty) { - final match = current.firstWhere( - (i) => i.invoiceId == pending.carResearchInvoiceId, - orElse: () => current.first, - ); - final payable = - match.expiresAt != null && - match.paymentLinks.isNotEmpty && - (match.expiresAt!.isAfter(DateTime.now()) || - carResearchIsFinalized(match.status, match.additional)); - if (payable) { - return CarResearchInvoice( - btcpayInvoice: match.invoiceId, - expiresAt: match.expiresAt!, - paymentLinks: match.paymentLinks, - ); - } - } - - final expiresAt = pending.carResearchExpiresAt; - final linksJson = pending.carResearchPaymentLinks; - final invoiceId = pending.carResearchInvoiceId; - if (expiresAt != null && - expiresAt.isAfter(DateTime.now()) && - linksJson != null && - invoiceId != null) { - final links = (jsonDecode(linksJson) as Map).map( - (k, v) => MapEntry(k, v as String), - ); - return CarResearchInvoice( - btcpayInvoice: invoiceId, - expiresAt: expiresAt, - paymentLinks: links, + arguments: invoice, ); + } finally { + if (mounted) setState(() => _resuming = false); } - - return null; } - static String _categoryLabel(ShopInBitCategory? category) => - switch (category) { - ShopInBitCategory.concierge => "Concierge", - ShopInBitCategory.travel => "Travel", - ShopInBitCategory.car => "Car", - null => "", - }; - List _buildListChildren({ required BuildContext context, required bool isDesktop, - required ShopInBitTicket? pending, - required bool hasTickets, + required List tickets, + required CarResearchInvoice? resumable, }) { - if (pending == null && !hasTickets) { + if (resumable == null && tickets.isEmpty) { return [ const SizedBox(height: 80), Center( @@ -187,15 +129,15 @@ class _ShopInBitTicketsViewState extends ConsumerState { } final children = []; - if (pending != null) { + if (resumable != null) { children.add( RoundedContainer( color: Theme.of(context).extension()!.popupBG, - onPressed: _resuming ? null : () => unawaited(_resumeFlow(pending)), + onPressed: _resuming ? null : () => unawaited(_resumeFlow(resumable)), child: _RequestRow( title: "Car Research (In Progress)", subtitle: _resuming - ? "Checking your car research payment..." + ? "Opening your car research payment..." : "Tap to continue your car research payment", badgeText: "Resume", badgeColor: Theme.of( @@ -205,10 +147,12 @@ class _ShopInBitTicketsViewState extends ConsumerState { ), ), ); - if (hasTickets) children.add(SizedBox(height: isDesktop ? 16 : 12)); + if (tickets.isNotEmpty) { + children.add(SizedBox(height: isDesktop ? 16 : 12)); + } } - for (var i = 0; i < _tickets.length; i++) { - final ticket = _tickets[i]; + for (var i = 0; i < tickets.length; i++) { + final ticket = tickets[i]; if (i > 0) children.add(SizedBox(height: isDesktop ? 16 : 12)); children.add( RoundedContainer( @@ -217,13 +161,14 @@ class _ShopInBitTicketsViewState extends ConsumerState { ? Theme.of(context).extension()!.textFieldDefaultBG : null, color: Theme.of(context).extension()!.popupBG, - onPressed: () => Navigator.of( - context, - ).pushNamed(ShopInBitTicketDetail.routeName, arguments: ticket), + onPressed: () => Navigator.of(context).pushNamed( + ShopInBitTicketDetail.routeName, + arguments: ticket.apiTicketId, + ), child: _RequestRow( - title: ticket.ticketId ?? "N/A", + title: ticket.ticketNumber, subtitle: - "${_categoryLabel(ticket.category)} • " + "${ticket.category.label} • " "${ticket.requestDescription}", badgeText: ticket.status.label, badgeColor: ticket.status.getColor( @@ -239,8 +184,9 @@ class _ShopInBitTicketsViewState extends ConsumerState { @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; - final pending = _pendingTicket; - final hasTickets = _tickets.isNotEmpty; + final tickets = + ref.watch(pShopInBitTickets).asData?.value ?? const []; + final resumable = _resumableInvoice; return ConditionalParent( condition: isDesktop, @@ -319,8 +265,8 @@ class _ShopInBitTicketsViewState extends ConsumerState { ..._buildListChildren( context: context, isDesktop: isDesktop, - pending: pending, - hasTickets: hasTickets, + tickets: tickets, + resumable: resumable, ), ], ), @@ -385,11 +331,7 @@ class _RequestRow extends StatelessWidget { ), SizedBox(width: isDesktop ? 16 : 8), loading - ? const SizedBox( - width: 20, - height: 20, - child: LoadingIndicator(), - ) + ? const SizedBox(width: 20, height: 20, child: LoadingIndicator()) : SvgPicture.asset( Assets.svg.chevronRight, width: 20, diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart index 0cb6511825..767d57cbe1 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart @@ -5,19 +5,14 @@ import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_svg/flutter_svg.dart"; -import "../../../models/shopinbit/shopinbit_order_model.dart"; -import "../../../providers/db/drift_provider.dart"; +import "../../../models/shopinbit/shopinbit_request_draft.dart"; import "../../../themes/stack_colors.dart"; import "../../../utilities/assets.dart"; import "../../../utilities/text_styles.dart"; import "../../../utilities/util.dart"; -import "../../../widgets/desktop/primary_button.dart"; -import "../../../widgets/desktop/secondary_button.dart"; import "../../../widgets/rounded_white_container.dart"; -import "../../../widgets/stack_dialog.dart"; import "../../../widgets/textfields/adaptive_text_field.dart"; import "../shopinbit_car_fee_view.dart"; -import "../shopinbit_tickets_view.dart"; import "shopinbit_country_picker.dart"; import "shopinbit_labeled_checkbox.dart"; import "shopinbit_privacy_checkbox.dart"; @@ -31,9 +26,7 @@ const int _minCarBudget = 20000; const int _minCarFieldLength = 3; class ShopInBitCarResearchForm extends ConsumerStatefulWidget { - const ShopInBitCarResearchForm({super.key, required this.model}); - - final ShopInBitOrderModel model; + const ShopInBitCarResearchForm({super.key}); @override ConsumerState createState() => @@ -75,9 +68,6 @@ class _ShopInBitCarResearchFormState () => _carDescriptionTouched = true, ); _wireTouchOnBlur(_carBudgetFocusNode, () => _carBudgetTouched = true); - if (widget.model.deliveryCountry.isNotEmpty) { - _selectedCountryIso = widget.model.deliveryCountry; - } } void _wireTouchOnBlur(FocusNode node, VoidCallback markTouched) { @@ -111,7 +101,8 @@ class _ShopInBitCarResearchFormState _selectedCarCondition != null && carBudgetValue != null && carBudgetValue >= _minCarBudget && - _selectedCountryIso != null; + _selectedCountryIso != null && + _selectedCountryIso!.isNotEmpty; } Future _submit() async { @@ -119,62 +110,28 @@ class _ShopInBitCarResearchFormState try { final String countryIso = _selectedCountryIso!; - widget.model - ..requestDescription = + final draft = ShopinbitRequestDraft( + category: .car, + requestDescription: "Brand: ${_brandController.text.trim()}\n" "Model: ${_modelController.text.trim()}\n" "Condition: $_selectedCarCondition\n" "Description: ${_carDescriptionController.text.trim()}\n" "Budget: ${_carBudgetController.text.trim()} EUR\n" - "Delivery country: $countryIso" - ..deliveryCountry = countryIso; - - // Block if another car research flow is already in progress. - final db = ref.read(pSharedDrift); - final existingPending = await (db.select( - db.shopInBitTickets, - )..where((t) => t.isPendingPayment.equals(true))).get(); - - if (existingPending.isNotEmpty && mounted) { - final bool? resumePrevious = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => StackDialog( - width: Util.isDesktop ? 500 : null, - title: "In-Progress Car Research", - message: - "You have an unfinished car research payment. " - "Would you like to resume it or start a new search?", - leftButton: SecondaryButton( - label: "New", - buttonHeight: Util.isDesktop ? .l : null, - onPressed: Navigator.of(context).pop, - ), - rightButton: PrimaryButton( - label: "Resume", - buttonHeight: Util.isDesktop ? .l : null, - onPressed: () => Navigator.of(context).pop(true), - ), - ), - ); - - if (resumePrevious == true && mounted) { - unawaited( - Navigator.of(context).pushNamedAndRemoveUntil( - ShopInBitTicketsView.routeName, - (route) => route.isFirst, - ), - ); - return; - } - } + "Delivery country: $countryIso", + deliveryCountry: countryIso, + voucherCode: null, + ); + // Any unfinished car research fee is recovered from the server + // (`GET /car-research/invoices/current`) by the requests list, so there + // is no local "pending payment" state to guard against here. if (!mounted) return; unawaited( Navigator.of( context, - ).pushNamed(ShopInBitCarFeeView.routeName, arguments: widget.model), + ).pushNamed(ShopInBitCarFeeView.routeName, arguments: draft), ); } finally { if (mounted) setState(() => _submitting = false); diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart index 1c191cd722..669070c423 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart @@ -2,8 +2,7 @@ import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "../../../models/shopinbit/shopinbit_order_model.dart"; -import "../../../providers/db/drift_provider.dart"; +import "../../../models/shopinbit/shopinbit_request_draft.dart"; import "../../../providers/global/shopin_bit_service_provider.dart"; import "../../../utilities/util.dart"; import "../../../widgets/textfields/adaptive_text_field.dart"; @@ -21,9 +20,7 @@ const int _minConciergeBudget = 1000; const int _maxConciergeBudget = 100000; class ShopInBitConciergeForm extends ConsumerStatefulWidget { - const ShopInBitConciergeForm({super.key, required this.model}); - - final ShopInBitOrderModel model; + const ShopInBitConciergeForm({super.key}); @override ConsumerState createState() => @@ -60,9 +57,6 @@ class _ShopInBitConciergeFormState if (!_budgetFocusNode.hasFocus) _budgetTouched = true; setState(() {}); }); - if (widget.model.deliveryCountry.isNotEmpty) { - _selectedCountryIso = widget.model.deliveryCountry; - } } @override @@ -93,27 +87,24 @@ class _ShopInBitConciergeFormState Future _submit() async { setState(() => _submitting = true); - - final String countryIso = _selectedCountryIso!; - final String budgetText = _noLimit - ? "No limit" - : "${_budgetController.text.trim()} EUR"; - - widget.model - ..requestDescription = - "What to purchase: ${_whatToPurchaseController.text.trim()}\n" - "Condition: $_selectedCondition\n" - "Budget: $budgetText\n" - "Delivery country: $countryIso" - ..deliveryCountry = countryIso; - try { - await submitShopInBitRequest( - context, - widget.model, - ref.read(pShopinBitService), - ref.read(pSharedDrift), + final String countryIso = _selectedCountryIso!; + final String budgetText = _noLimit + ? "No limit" + : "${_budgetController.text.trim()} EUR"; + + final draft = ShopinbitRequestDraft( + category: .concierge, + requestDescription: + "What to purchase: ${_whatToPurchaseController.text.trim()}\n" + "Condition: $_selectedCondition\n" + "Budget: $budgetText\n" + "Delivery country: $countryIso", + deliveryCountry: countryIso, + voucherCode: null, ); + + await submitShopInBitRequest(context, draft, ref.read(pShopinBitService)); } finally { if (mounted) setState(() => _submitting = false); } diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart deleted file mode 100644 index 82b862ea87..0000000000 --- a/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart +++ /dev/null @@ -1,123 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; - -import "../../../models/shopinbit/shopinbit_order_model.dart"; -import "../../../providers/global/shopin_bit_service_provider.dart"; -import "../../../providers/providers.dart"; -import "../../../utilities/util.dart"; -import "../../../widgets/textfields/adaptive_text_field.dart"; -import "shopinbit_country_picker.dart"; -import "shopinbit_privacy_checkbox.dart"; -import "shopinbit_step4_header.dart"; -import "shopinbit_step4_submit.dart"; -import "shopinbit_step4_submit_button.dart"; - -/// Fallback Step 4 form used when no category was selected. Collects a free -/// text description and a delivery country. -/// -/// Note: the original code used the travel copy for this fallback; that -/// behaviour is preserved here. -class ShopInBitGenericForm extends ConsumerStatefulWidget { - const ShopInBitGenericForm({super.key, required this.model}); - - final ShopInBitOrderModel model; - - @override - ConsumerState createState() => - _ShopInBitGenericFormState(); -} - -class _ShopInBitGenericFormState extends ConsumerState { - late final TextEditingController _descriptionController; - final FocusNode _descriptionFocusNode = FocusNode(); - - String? _selectedCountryIso; - bool _privacyAccepted = false; - bool _submitting = false; - - @override - void initState() { - super.initState(); - _descriptionController = TextEditingController( - text: widget.model.requestDescription, - ); - _descriptionFocusNode.addListener(() => setState(() {})); - - if (widget.model.deliveryCountry.isNotEmpty) { - _selectedCountryIso = widget.model.deliveryCountry; - } - } - - @override - void dispose() { - _descriptionController.dispose(); - _descriptionFocusNode.dispose(); - super.dispose(); - } - - bool get _canContinue => - !_submitting && - _privacyAccepted && - _descriptionController.text.trim().isNotEmpty && - _selectedCountryIso != null; - - Future _submit() async { - setState(() => _submitting = true); - widget.model - ..requestDescription = _descriptionController.text.trim() - ..deliveryCountry = _selectedCountryIso!; - try { - await submitShopInBitRequest( - context, - widget.model, - ref.read(pShopinBitService), - ref.read(pSharedDrift), - ); - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - @override - Widget build(BuildContext context) { - final bool isDesktop = Util.isDesktop; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const ShopInBitStep4Header( - title: "Describe your travel request", - subtitle: "Provide details about your trip.", - ), - SizedBox(height: isDesktop ? 32 : 24), - AdaptiveTextField( - controller: _descriptionController, - focusNode: _descriptionFocusNode, - labelText: - "Describe your travel request (destinations, dates, passengers)", - minLines: 3, - maxLines: 6, - autocorrect: false, - enableSuggestions: false, - onChanged: (_) => setState(() {}), - ), - SizedBox(height: isDesktop ? 24 : 16), - ShopInBitCountryPicker( - selectedIso: _selectedCountryIso, - onChanged: (iso) => setState(() => _selectedCountryIso = iso), - ), - SizedBox(height: isDesktop ? 16 : 12), - ShopInBitPrivacyCheckbox( - value: _privacyAccepted, - onChanged: (v) => setState(() => _privacyAccepted = v), - ), - SizedBox(height: isDesktop ? 16 : 12), - ShopInBitStep4SubmitButton( - submitting: _submitting, - enabled: _canContinue, - onPressed: _submit, - ), - ], - ); - } -} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart index ed95f8c99b..e432f1eb89 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart @@ -2,9 +2,9 @@ import "dart:async"; import "package:flutter/material.dart"; -import "../../../db/drift/shared_db/shared_database.dart"; -import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../models/shopinbit/shopinbit_request_draft.dart"; import "../../../services/shopinbit/shopinbit_service.dart"; +import "../../../services/shopinbit/src/models/ticket.dart"; import "../../../utilities/util.dart"; import "../../../widgets/stack_dialog.dart"; import "../shopinbit_order_created.dart"; @@ -14,40 +14,24 @@ import "../shopinbit_order_created.dart"; /// /// Used by the concierge, travel and generic flows. The car flow has its own /// pre-payment branching (fee view) and does not call this helper. +/// +/// All persistence lives in [ShopInBitService.createRequest], which inserts +/// the fully-provenanced ticket row and kicks off a background refresh, so the +/// UI only has to hand over the [draft] and route on the returned id. Future submitShopInBitRequest( BuildContext context, - ShopInBitOrderModel model, + ShopinbitRequestDraft draft, ShopInBitService service, - SharedDatabase db, ) async { try { - final String customerKey = await service.ensureCustomerKey(); - - assert( - model.category != null, - "Step 4 reached with null category: Step 2 must set category before" - " reaching Step 4", - ); - - // API service_type: travel requests use "concierge" because the - // ShopinBit API routes both through the same concierge pipeline. - // Travel-specific details are captured in the structured comment field. - final String categoryStr = switch (model.category) { - ShopInBitCategory.concierge => "concierge", - ShopInBitCategory.travel => "concierge", - ShopInBitCategory.car => "car", - null => throw StateError("category must be non-null at Step 4 submit"), - }; - - final resp = await service.client.createRequest( - customerPseudonym: model.displayName, - externalCustomerKey: customerKey, - serviceType: categoryStr, - comment: model.requestDescription, - deliveryCountry: model.deliveryCountry, + final TicketRef? ref = await service.createRequest( + category: draft.category, + comment: draft.requestDescription, + deliveryCountry: draft.deliveryCountry, + voucherCode: draft.voucherCode, ); - if (resp.hasError) { + if (ref == null) { if (context.mounted) { await showDialog( context: context, @@ -55,7 +39,7 @@ Future submitShopInBitRequest( builder: (context) => StackOkDialog( title: "Failed to create request", maxWidth: Util.isDesktop ? 500 : null, - message: resp.exception?.message, + message: "Please try again in a moment.", desktopPopRootNavigator: Util.isDesktop, ), ); @@ -63,21 +47,12 @@ Future submitShopInBitRequest( return; } - final ref = resp.value!; - model - ..apiTicketId = ref.id - ..ticketId = ref.number - ..status = ShopInBitOrderStatus.pending; - await db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(model.toCompanion()); - if (!context.mounted) return; unawaited( Navigator.of( context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: model), + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: ref.id), ); } catch (e) { if (context.mounted) { diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart index e110eab537..780e4c00a0 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart @@ -2,8 +2,7 @@ import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "../../../models/shopinbit/shopinbit_order_model.dart"; -import "../../../providers/db/drift_provider.dart"; +import "../../../models/shopinbit/shopinbit_request_draft.dart"; import "../../../providers/global/shopin_bit_service_provider.dart"; import "../../../utilities/text_styles.dart"; import "../../../utilities/util.dart"; @@ -59,9 +58,7 @@ const int _minArrangementDetailsLength = 10; /// dates (either exact or flexible), travelers and budget, then submits via /// the shared submit helper. class ShopInBitTravelForm extends ConsumerStatefulWidget { - const ShopInBitTravelForm({super.key, required this.model}); - - final ShopInBitOrderModel model; + const ShopInBitTravelForm({super.key}); @override ConsumerState createState() => @@ -234,19 +231,17 @@ class _ShopInBitTravelFormState extends ConsumerState { Future _submit() async { setState(() => _submitting = true); - widget.model - ..requestDescription = _buildRequestDescription() + final draft = ShopinbitRequestDraft( + category: .travel, + requestDescription: _buildRequestDescription(), // Travel doesn't collect a delivery country: default to "DE" since the // API requires the field. Travel destinations are captured in the // structured comment field. - ..deliveryCountry = "DE"; + deliveryCountry: "DE", + voucherCode: null, + ); try { - await submitShopInBitRequest( - context, - widget.model, - ref.read(pShopinBitService), - ref.read(pSharedDrift), - ); + await submitShopInBitRequest(context, draft, ref.read(pShopinBitService)); } finally { if (mounted) setState(() => _submitting = false); } diff --git a/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart b/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart index d5a81094d9..3ae138b900 100644 --- a/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart +++ b/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart @@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../../app_config.dart'; -import '../../../models/shopinbit/shopinbit_order_model.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../pages/shopinbit/shopinbit_step_2.dart'; import '../../../pages/shopinbit/shopinbit_tickets_view.dart'; @@ -14,6 +13,7 @@ import '../../../providers/desktop/current_desktop_menu_item.dart'; import '../../../providers/global/shopin_bit_service_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; +import '../../../utilities/show_loading.dart'; import '../../../utilities/text_styles.dart'; import '../../../widgets/desktop/desktop_dialog.dart'; import '../../../widgets/desktop/desktop_dialog_close_button.dart'; @@ -23,7 +23,6 @@ import '../../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog import '../../../widgets/dialogs/request_external_link_navigation_dialog.dart'; import '../../../widgets/rounded_container.dart'; import '../../../widgets/rounded_white_container.dart'; -import '../../../widgets/textfields/adaptive_text_field.dart'; import '../../desktop_menu.dart'; import '../../settings/settings_menu.dart'; import 'sub_widgets/desktop_shopin_bit_first_run.dart'; @@ -40,12 +39,11 @@ class DesktopShopInBitView extends ConsumerStatefulWidget { class _DesktopServicesViewState extends ConsumerState { Future _showShopDialog() async { - final dao = ref.read(pSharedDrift).shopinBitSettingsDao; - final settings = await dao.getSettings(); - final model = ShopInBitOrderModel(); + final dao = ref.read(pSharedDrift).shopInBitSettingsDao; + final settings = await dao.getCurrentSettings(); bool isFirstRun = false; - if (!settings.setupComplete) { + if (settings == null || !settings.setupComplete) { // something went wrong if (!mounted) return; @@ -53,16 +51,10 @@ class _DesktopServicesViewState extends ConsumerState { final completed = await showDialog( context: context, barrierDismissible: false, - builder: (_) => _ShopInBitDesktopSetupDialog(model: model), + builder: (_) => const _ShopInBitDesktopSetupDialog(), ); if (completed != true) return; // user cancelled isFirstRun = true; - } else { - // Returning user: restore display name. - final savedName = settings.displayName; - if (savedName != null && savedName.isNotEmpty) { - model.displayName = savedName; - } } if (!mounted) return; @@ -73,9 +65,8 @@ class _DesktopServicesViewState extends ConsumerState { await showDialog( context: context, barrierDismissible: false, - builder: (_) => NestedNavigatorDialog( + builder: (_) => const NestedNavigatorDialog( initialRoute: DesktopShopinBitFirstRun.routeName, - initialRouteArgs: model, ), ); } else { @@ -85,9 +76,9 @@ class _DesktopServicesViewState extends ConsumerState { await showDialog( context: context, barrierDismissible: false, - builder: (_) => NestedNavigatorDialog( + builder: (_) => const NestedNavigatorDialog( initialRoute: ShopInBitStep2.routeName, - initialRouteArgs: (model: model, isActuallyFirstStep: true), + initialRouteArgs: true, ), ); @@ -234,9 +225,7 @@ class _DesktopServicesViewState extends ConsumerState { } class _ShopInBitDesktopSetupDialog extends ConsumerStatefulWidget { - const _ShopInBitDesktopSetupDialog({required this.model}); - - final ShopInBitOrderModel model; + const _ShopInBitDesktopSetupDialog(); @override ConsumerState<_ShopInBitDesktopSetupDialog> createState() => @@ -246,42 +235,33 @@ class _ShopInBitDesktopSetupDialog extends ConsumerStatefulWidget { class _ShopInBitDesktopSetupDialogState extends ConsumerState<_ShopInBitDesktopSetupDialog> { late final Future _keyFuture; - final TextEditingController _nameController = TextEditingController(); - - bool get _canContinue => _nameController.text.trim().isNotEmpty; + String? _key; @override void initState() { super.initState(); _keyFuture = ref.read(pShopinBitService).ensureCustomerKey(); - - // not the greatest solution but its the least invasive with the current - // ui code impl () async { - final settings = await ref - .read(pSharedDrift) - .shopinBitSettingsDao - .getSettings(); - if (mounted) { - setState(() { - _nameController.text = settings.displayName ?? ""; - }); - } + final key = await _keyFuture; + if (mounted) setState(() => _key = key); }(); } - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - Future _completeSetup() async { - final name = _nameController.text.trim(); - widget.model.displayName = name; - final dao = ref.read(pSharedDrift).shopinBitSettingsDao; - await dao.setDisplayName(name); - await dao.setSetupComplete(true); + final dao = ref.read(pSharedDrift).shopInBitSettingsDao; + await showLoading( + context: context, + message: "Saving...", + whileFuture: () async { + final settings = await dao.getCurrentSettings(); + if (settings == null) { + throw Exception("Devs pls clean this up"); + } + + await dao.setSetupComplete(settings.customerKey, true); + }(), + ); + if (mounted) { Navigator.of(context, rootNavigator: true).pop(true); } @@ -384,31 +364,14 @@ class _ShopInBitDesktopSetupDialogState ); }, ), - const SizedBox(height: 24), - Text( - "Display Name", - style: STextStyles.desktopTextExtraExtraSmall(context) - .copyWith( - color: Theme.of(context) - .extension()! - .textFieldActiveSearchIconRight, - ), - ), - const SizedBox(height: 8), - AdaptiveTextField( - controller: _nameController, - showPasteClearButton: true, - maxLines: 1, - onChangedComprehensive: (_) => setState(() {}), - ), const SizedBox(height: 40), Row( mainAxisAlignment: .end, children: [ PrimaryButton( label: "Complete Setup", - enabled: _canContinue, - onPressed: _canContinue ? _completeSetup : null, + enabled: _key != null, + onPressed: _key != null ? _completeSetup : null, horizontalContentPadding: 20, ), ], diff --git a/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart index 16e791d923..9f39165864 100644 --- a/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart +++ b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../../../../models/shopinbit/shopinbit_order_model.dart'; import '../../../../pages/shopinbit/shopinbit_step_2.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../widgets/desktop/primary_button.dart'; @@ -8,12 +7,10 @@ import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/dialogs/s_dialog.dart'; class DesktopShopinBitFirstRun extends StatelessWidget { - const DesktopShopinBitFirstRun({super.key, required this.model}); + const DesktopShopinBitFirstRun({super.key}); static const routeName = "/desktopShopinBitFirstRun"; - final ShopInBitOrderModel model; - @override Widget build(BuildContext context) { return SDialog( @@ -53,10 +50,9 @@ class DesktopShopinBitFirstRun extends StatelessWidget { width: 220, buttonHeight: ButtonHeight.l, label: "Continue", - onPressed: () => Navigator.of(context).pushReplacementNamed( - ShopInBitStep2.routeName, - arguments: model, - ), + onPressed: () => Navigator.of( + context, + ).pushReplacementNamed(ShopInBitStep2.routeName), ), ], ), diff --git a/lib/providers/global/shopin_bit_orders_provider.dart b/lib/providers/global/shopin_bit_orders_provider.dart deleted file mode 100644 index 2e46a7e76e..0000000000 --- a/lib/providers/global/shopin_bit_orders_provider.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../services/shopinbit/shopinbit_orders_service.dart'; -import 'shopin_bit_service_provider.dart'; - -final pShopInBitOrdersService = ChangeNotifierProvider( - (ref) => - ShopInBitOrdersService(shopInBitService: ref.read(pShopinBitService)), -); diff --git a/lib/providers/global/shopin_bit_service_provider.dart b/lib/providers/global/shopin_bit_service_provider.dart index 9f9c422e69..d2e5a49d77 100644 --- a/lib/providers/global/shopin_bit_service_provider.dart +++ b/lib/providers/global/shopin_bit_service_provider.dart @@ -1,8 +1,42 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../db/drift/shared_db/shared_database.dart'; +import '../../external_api_keys.dart'; +import '../../services/shopinbit/shopinbit_api.dart'; import '../../services/shopinbit/shopinbit_service.dart'; -import 'secure_store_provider.dart'; +import '../db/drift_provider.dart'; final pShopinBitService = Provider( - (ref) => ShopInBitService()..ensureInitialized(ref.read(secureStoreProvider)), + (ref) => ShopInBitService( + client: ShopInBitClient( + accessKey: kShopInBitAccessKey, + partnerSecret: kShopInBitPartnerSecret, + sandbox: true, // TODO set to false in prod + ), + db: ref.watch(pSharedDrift), + ), ); + +/// The active customer key's settings row (or null if none yet). +final pShopInBitSettings = StreamProvider.autoDispose( + (ref) => ref.watch(pSharedDrift).shopInBitSettingsDao.watchCurrentSettings(), +); + +/// All tickets for the active customer key, newest first. +final pShopInBitTickets = StreamProvider.autoDispose>(( + ref, +) async* { + final db = ref.watch(pSharedDrift); + final settings = await db.shopInBitSettingsDao.getCurrentSettings(); + if (settings == null) { + yield const []; + return; + } + yield* db.shopInBitTicketsDao.watchByCustomerKey(settings.customerKey); +}); + +final pShopInBitTicket = StreamProvider.autoDispose + .family( + (ref, apiTicketId) => + ref.watch(pSharedDrift).shopInBitTicketsDao.watchByApiId(apiTicketId), + ); diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 42a6c0ebab..a685528412 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -29,7 +29,8 @@ import 'models/keys/key_data_interface.dart'; import 'models/keys/view_only_wallet_data.dart'; import 'models/paynym/paynym_account_lite.dart'; import 'models/send_view_auto_fill_data.dart'; -import 'models/shopinbit/shopinbit_order_model.dart'; +import 'models/shopinbit/shopinbit_enums.dart'; +import 'models/shopinbit/shopinbit_request_draft.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart'; import 'pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; @@ -182,7 +183,6 @@ import 'pages/shopinbit/shopinbit_send_from_view.dart'; import 'pages/shopinbit/shopinbit_settings_view.dart'; import 'pages/shopinbit/shopinbit_setup_view.dart'; import 'pages/shopinbit/shopinbit_shipping_view.dart'; -import 'pages/shopinbit/shopinbit_step_1.dart'; import 'pages/shopinbit/shopinbit_step_2.dart'; import 'pages/shopinbit/shopinbit_step_3.dart'; import 'pages/shopinbit/shopinbit_step_4.dart'; @@ -1080,14 +1080,11 @@ class RouteGenerator { ); case ShopInBitSetupView.routeName: - if (args is ShopInBitOrderModel) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitSetupView(model: args), - settings: RouteSettings(name: settings.name), - ); - } - return _routeError("${settings.name} invalid args: ${args.toString()}"); + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ShopInBitSetupView(), + settings: RouteSettings(name: settings.name), + ); case CakePayVendorsView.routeName: return getRoute( @@ -1141,51 +1138,41 @@ class RouteGenerator { case CakePayConfirmSendView.routeName: return _routeError("${settings.name} should be pushed directly"); - case ShopInBitStep1.routeName: - if (args is ShopInBitOrderModel) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitStep1(model: args), - settings: RouteSettings(name: settings.name), - ); - } - return _routeError("${settings.name} invalid args: ${args.toString()}"); - case ShopInBitStep2.routeName: - if (args is ShopInBitOrderModel) { - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitStep2(model: args), - settings: RouteSettings(name: settings.name), - ); - } - return _routeError("${settings.name} invalid args: ${args.toString()}"); + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ShopInBitStep2(), + settings: RouteSettings(name: settings.name), + ); case ShopInBitStep3.routeName: - if (args is ShopInBitOrderModel) { + if (args is ({ShopInBitCategory category, String customerKey})) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitStep3(model: args), + builder: (_) => ShopInBitStep3( + category: args.category, + customerKey: args.customerKey, + ), settings: RouteSettings(name: settings.name), ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitStep4.routeName: - if (args is ShopInBitOrderModel) { + if (args is ShopInBitCategory) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitStep4(model: args), + builder: (_) => ShopInBitStep4(category: args), settings: RouteSettings(name: settings.name), ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitOrderCreated.routeName: - if (args is ShopInBitOrderModel) { + if (args is int) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitOrderCreated(model: args), + builder: (_) => ShopInBitOrderCreated(apiTicketId: args), settings: RouteSettings(name: settings.name), ); } @@ -1206,20 +1193,20 @@ class RouteGenerator { ); case ShopInBitTicketDetail.routeName: - if (args is ShopInBitOrderModel) { + if (args is int) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitTicketDetail(model: args), + builder: (_) => ShopInBitTicketDetail(apiTicketId: args), settings: RouteSettings(name: settings.name), ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitOfferView.routeName: - if (args is ShopInBitOrderModel) { + if (args is int) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitOfferView(model: args), + builder: (_) => ShopInBitOfferView(apiTicketId: args), settings: RouteSettings(name: settings.name), ); } @@ -1228,13 +1215,15 @@ class RouteGenerator { case ShopInBitShippingView.routeName: if (args is ({ - ShopInBitOrderModel model, + int apiTicketId, + String deliveryCountry, List> countries, })) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => ShopInBitShippingView( - model: args.model, + apiTicketId: args.apiTicketId, + deliveryCountry: args.deliveryCountry, countries: args.countries, ), settings: RouteSettings(name: settings.name), @@ -1243,35 +1232,32 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitCarFeeView.routeName: - if (args is ShopInBitOrderModel) { + if (args is ShopinbitRequestDraft) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitCarFeeView(model: args), + builder: (_) => ShopInBitCarFeeView(draft: args), settings: RouteSettings(name: settings.name), ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitCarResearchPaymentView.routeName: - if (args is (ShopInBitOrderModel, CarResearchInvoice)) { + if (args is CarResearchInvoice) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitCarResearchPaymentView( - model: args.$1, - invoice: args.$2, - ), + builder: (_) => ShopInBitCarResearchPaymentView(invoice: args), settings: RouteSettings(name: settings.name), ); } return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitPaymentView.routeName: - if (args is (ShopInBitOrderModel, PaymentInfo)) { + if (args is ({int apiTicketId, PaymentInfo paymentInfo})) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => ShopInBitPaymentView( - model: args.$1, - paymentInfo: args.$2, + apiTicketId: args.apiTicketId, + paymentInfo: args.paymentInfo, ), settings: RouteSettings(name: settings.name), ); @@ -1279,15 +1265,14 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitSendFromView.routeName: - if (args - is Tuple4) { + if (args is Tuple4) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, builder: (_) => ShopInBitSendFromView( coin: args.item1, amount: args.item2, address: args.item3, - model: args.item4, + apiTicketId: args.item4, ), settings: RouteSettings(name: settings.name), ); diff --git a/lib/services/shopinbit/shopinbit_orders_service.dart b/lib/services/shopinbit/shopinbit_orders_service.dart deleted file mode 100644 index 204bb25c3c..0000000000 --- a/lib/services/shopinbit/shopinbit_orders_service.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; - -import '../../db/drift/shared_db/shared_database.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; -import 'shopinbit_service.dart'; - -/// Holds canonical [ShopInBitOrderModel] instances keyed by `apiTicketId`, -/// refreshes them in the background, and notifies listeners only when -/// something actually changed. -/// -/// Modelled on `PriceService`, see `lib/services/price_service.dart`. -class ShopInBitOrdersService extends ChangeNotifier { - ShopInBitOrdersService({required this.shopInBitService}); - - static const Duration defaultPollInterval = Duration(seconds: 30); - - final ShopInBitService shopInBitService; - - final Map _tickets = {}; - final Set _inflight = {}; - final Map _polls = {}; - - /// Register [model] as the canonical instance for its `apiTicketId`. If a - /// canonical instance already exists, returns it; otherwise stores and - /// returns [model]. Callers should use the returned instance. - ShopInBitOrderModel upsert(ShopInBitOrderModel model) { - final existing = _tickets[model.apiTicketId]; - if (existing != null) return existing; - _tickets[model.apiTicketId] = model; - return model; - } - - ShopInBitOrderModel? get(int apiTicketId) => _tickets[apiTicketId]; - - bool isRefreshing(int apiTicketId) => _inflight.contains(apiTicketId); - - /// Fetch latest status + messages (+ offer details if applicable) for the - /// given ticket. No-ops if a fetch for this ticket is already in flight. - Future refreshOne(int apiTicketId) async { - if (apiTicketId == 0) return; - if (_inflight.contains(apiTicketId)) return; - final model = _tickets[apiTicketId]; - if (model == null) return; - - _inflight.add(apiTicketId); - notifyListeners(); - try { - final client = shopInBitService.client; - - // Fire both off concurrently, then await individually for typed access. - final messagesFuture = client.getMessages(apiTicketId); - final statusFuture = client.getTicketStatus(apiTicketId); - final messagesResp = await messagesFuture; - final statusResp = await statusFuture; - - bool changed = false; - - if (!messagesResp.hasError && messagesResp.value != null) { - final apiMessages = messagesResp.value!; - final last = model.messages.isEmpty ? null : model.messages.last; - final apiLast = apiMessages.isEmpty ? null : apiMessages.last; - final lengthsDiffer = model.messages.length != apiMessages.length; - final lastTimestampDiffers = last?.timestamp != apiLast?.timestamp; - if (lengthsDiffer || lastTimestampDiffers) { - model.clearMessages(); - for (final m in apiMessages) { - model.addMessage( - ShopInBitMessage( - text: m.content, - timestamp: m.timestamp, - isFromUser: !m.fromAgent, - ), - ); - } - changed = true; - } - } - - if (!statusResp.hasError && statusResp.value != null) { - final newStatus = ShopInBitOrderModel.statusFromTicketState( - statusResp.value!.state, - ); - model.statusRaw = statusResp.value!.stateRaw; - if (model.status != newStatus && newStatus != null) { - model.status = newStatus; - changed = true; - } - } - - if (model.status == ShopInBitOrderStatus.offerAvailable && - (model.offerProductName == null || model.offerPrice == null)) { - final offerResp = await client.getTicketFull(apiTicketId); - if (!offerResp.hasError && offerResp.value != null) { - final t = offerResp.value!; - model.setOffer(productName: t.productName, price: t.customerPrice); - changed = true; - } - } - - if (changed && model.ticketId != null) { - final db = SharedDrift.get(); - unawaited( - db - .into(db.shopInBitTickets) - .insertOnConflictUpdate(model.toCompanion()), - ); - } - } catch (_) { - // Silently leave the cached model in place. - } finally { - _inflight.remove(apiTicketId); - notifyListeners(); - } - } - - /// Start (or join) a refcounted poll for [apiTicketId]. The first call - /// kicks off an immediate refresh and creates the timer; subsequent calls - /// just bump the refcount. Pair each call with [stopPolling]. - /// - /// If [pollInBackground] is false, the immediate refresh still runs but no - /// timer is created (matches the existing behavior for car-research - /// tickets). - void startPolling( - int apiTicketId, { - Duration interval = defaultPollInterval, - bool pollInBackground = true, - }) { - if (apiTicketId == 0) return; - final existing = _polls[apiTicketId]; - if (existing != null) { - existing.refs += 1; - return; - } - final poll = _Poll(refs: 1, timer: null); - _polls[apiTicketId] = poll; - unawaited(refreshOne(apiTicketId)); - if (pollInBackground) { - poll.timer = Timer.periodic(interval, (_) { - unawaited(refreshOne(apiTicketId)); - }); - } - } - - void stopPolling(int apiTicketId) { - final poll = _polls[apiTicketId]; - if (poll == null) return; - poll.refs -= 1; - if (poll.refs <= 0) { - _polls.remove(apiTicketId)?.timer?.cancel(); - } - } - - /// Sync the customer's full ticket list from the API, walking each one to - /// refresh status / messages / offer in parallel. Used by the requests - /// list view. - Future refreshAll() async { - try { - final customerKey = await shopInBitService.ensureCustomerKey(); - final resp = await shopInBitService.client.getTicketsByCustomer( - customerKey, - ); - if (resp.hasError || resp.value == null) return; - - final db = SharedDrift.get(); - final localRows = await db.select(db.shopInBitTickets).get(); - final byApiId = {for (final r in localRows) r.apiTicketId: r}; - - final List> tasks = []; - for (final ticketRef in resp.value!) { - final row = byApiId[ticketRef.id]; - if (row == null) continue; - final model = upsert(ShopInBitOrderModel.fromDriftRow(row)); - tasks.add(refreshOne(model.apiTicketId)); - } - await Future.wait(tasks); - } catch (_) { - // Listeners still see whatever Drift / cache held before. - } - } - - @override - void dispose() { - for (final p in _polls.values) { - p.timer?.cancel(); - } - _polls.clear(); - super.dispose(); - } -} - -class _Poll { - _Poll({required this.refs, required this.timer}); - int refs; - Timer? timer; -} diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index af9825e2f3..0bdcf906fa 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -1,203 +1,339 @@ -import 'package:drift/drift.dart'; +import "dart:async"; -import '../../db/drift/shared_db/shared_database.dart'; -import '../../db/drift/shared_db/tables/shopin_bit_tickets.dart'; -import '../../external_api_keys.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../utilities/flutter_secure_storage_interface.dart'; -import '../../utilities/logger.dart'; -import 'src/client.dart'; -import 'src/models/message.dart'; -import 'src/models/ticket.dart'; +import "package:drift/drift.dart"; -const _kShopinBitCustomerKeyKeySecureStore = "shopinBitSecStoreCustomerKeyKey"; +import "../../db/drift/shared_db/shared_database.dart"; +import "../../models/shopinbit/shopinbit_enums.dart"; +import "src/api_response.dart"; +import "src/client.dart"; +import "src/models/message.dart"; +import "src/models/ticket.dart"; + +/// Display name sent to ShopinBit as `customer_pseudonym`. +const String kShopInBitCustomerPseudonym = "Satoshi"; class ShopInBitService { - SecureStorageInterface? _secureStorageInterface; + ShopInBitService({required this.client, required this.db}); - SecureStorageInterface get _secure { - if (_secureStorageInterface == null) { - throw Exception( - "Did you forget to call ShopInBitService.ensureInitialized()?", - ); + final ShopInBitClient client; + final SharedDatabase db; + + final Map> _inFlight = {}; + + // -- Customer key -- + + /// Returns the most-recently-used customer key. Generates a new one if + /// the DB has no settings yet. Always leaves [client] pointing at the + /// returned key. + Future ensureCustomerKey() async { + final ShopInBitSetting? current = await db.shopInBitSettingsDao + .getCurrentSettings(); + if (current != null) { + client.externalCustomerKey = current.customerKey; + await db.shopInBitSettingsDao.touch(current.customerKey); + return current.customerKey; } - return _secureStorageInterface!; + return generateCustomerKey(); } - /// If secure storage was already set, this function will do nothing - void ensureInitialized(SecureStorageInterface secureStore) { - _secureStorageInterface ??= secureStore; + Future generateCustomerKey() async { + final ApiResponse resp = await client.generateKey(); + return useCustomerKey(resp.valueOrThrow); } - ShopInBitClient? _client; - ShopInBitClient get client { - _client ??= ShopInBitClient( - accessKey: kShopInBitAccessKey, - partnerSecret: kShopInBitPartnerSecret, - sandbox: true, - ); - return _client!; + Future recoverCustomerKey(String key) => useCustomerKey(key); + + /// Switch the active customer key. Tickets for OTHER customer keys stay + /// in the DB — switching is just a header change plus an upsert into + /// settings. The UI filters tickets by the active key. + Future useCustomerKey(String key) async { + await db.shopInBitSettingsDao.upsert(key); + client.externalCustomerKey = key; + return key; } - Future loadCustomerKey() => - _secure.read(key: _kShopinBitCustomerKeyKeySecureStore); + // -- Refresh -- - Future ensureCustomerKey() async { - final currentKey = await loadCustomerKey(); + /// Refresh every ticket the API reports for the current customer key. + /// New tickets are hydrated and inserted; existing tickets are patched. + Future refreshAll() async { + final String key = await ensureCustomerKey(); + final ApiResponse> resp = await client.getTicketsByCustomer( + key, + ); + if (resp.hasError || resp.value == null) return; + await Future.wait(resp.value!.map((ref) => _refreshRef(ref, key))); + } - if (currentKey != null) { - Logging.instance.t("ShopInBitService: loaded customer key from DB"); - client.externalCustomerKey = currentKey; - return currentKey; - } - Logging.instance.i("ShopInBitService: generating new customer key"); - final resp = await client.generateKey(); - final customerKey = resp.valueOrThrow; - await setCustomerKey(customerKey); - Logging.instance.i("ShopInBitService: customer key stored"); - return customerKey; + /// Refresh a single ticket. The row must already exist; use this for + /// polling and post-action refreshes. For an unknown ticket id, call + /// [refreshAll] (which has the customer-key context needed to insert). + Future refreshOne(int apiTicketId) async { + final ShopInBitTicket? existing = await db.shopInBitTicketsDao.getByApiId( + apiTicketId, + ); + if (existing == null) return; + await _refreshRef( + TicketRef(id: existing.apiTicketId, number: existing.ticketNumber), + existing.customerKey, + ); } - Future setCustomerKey(String key) async { - await _secure.write(key: _kShopinBitCustomerKeyKeySecureStore, value: key); - client.externalCustomerKey = key; - Logging.instance.i("ShopInBitService: customer key stored"); + // -- Actions -- + + /// Create a new ticket. We know every required field at this point + /// (they're the inputs we just sent), so the DB row is inserted + /// synchronously with full provenance data and an empty conversation; + /// dynamic fields are then patched in by a background refresh. + Future createRequest({ + required ShopInBitCategory category, + required String comment, + required String deliveryCountry, + String? voucherCode, + }) async { + final String key = await ensureCustomerKey(); + final ApiResponse resp = await client.createRequest( + customerPseudonym: kShopInBitCustomerPseudonym, + externalCustomerKey: key, + serviceType: category.apiValue, + comment: comment, + deliveryCountry: deliveryCountry, + voucherCode: voucherCode, + ); + if (resp.hasError || resp.value == null) return null; + final TicketRef ref = resp.value!; + + await db.shopInBitTicketsDao.insertTicket( + ShopInBitTicketsCompanion.insert( + apiTicketId: ref.id, + customerKey: key, + ticketNumber: ref.number, + category: category, + requestDescription: comment, + deliveryCountry: deliveryCountry, + status: ShopInBitOrderStatus.pending, + statusRaw: "NEW", + ), + ); + + unawaited(refreshOne(ref.id)); + return ref; } - Future clearCustomerKey() async { - client.externalCustomerKey = null; - await _secure.delete(key: _kShopinBitCustomerKeyKeySecureStore); - Logging.instance.i("ShopInBitService: customer key cleared"); + Future sendMessage(int apiTicketId, String message) async { + final ApiResponse> resp = await client.sendMessage( + apiTicketId, + message, + ); + if (resp.hasError) return false; + unawaited(refreshOne(apiTicketId)); + return true; } - /// Fetch the customer's tickets from the API and build companions for any - /// that aren't already in the local database. Used to backfill rows for - /// tickets created out-of-band (other devices, web dashboard, etc.). - Future> fetchAllForCustomerKey( - String customerKey, - ) async { - final resp = await client.getTicketsByCustomer(customerKey); - if (resp.hasError || resp.value == null) { - Logging.instance.w( - "ShopInBitService.fetchAllForCustomerKey: getTicketsByCustomer failed: " - "${resp.exception?.message}", - ); - return const []; - } + // -- Internals -- + + /// Hydrate-or-update one ticket. Branches on whether the row already + /// exists: existing rows get a partial patch, brand-new rows are only + /// inserted if /full, /status, and /messages all succeed (no empty + /// placeholder rows). + /// + /// Concurrent calls for the same ticket id are coalesced onto the + /// in-flight refresh — later callers await the same completer rather + /// than kicking off a second round-trip. + Future _refreshRef(TicketRef ref, String customerKey) { + final int id = ref.id; - final db = SharedDrift.get(); - final localRows = await db.select(db.shopInBitTickets).get(); - final knownApiIds = localRows.map((r) => r.apiTicketId).toSet(); + final Completer? pending = _inFlight[id]; + if (pending != null) return pending.future; - final newRefs = resp.value! - .where((r) => !knownApiIds.contains(r.id)) - .toList(); - if (newRefs.isEmpty) return const []; + final Completer completer = Completer(); + _inFlight[id] = completer; - // Hydrate per-ticket in parallel. status + messages are exempt from the - // 60 req/min rate limit per the API spec; getTicketFull is only called - // for tickets whose state maps to offerAvailable. - final results = await Future.wait(newRefs.map(_hydrateNewTicket)); - return results.whereType().toList(); + // Fire-and-forget: _runRefresh should never throw (it routes errors through + // the completer), so the unawaited future is safe. Every caller — + // including the first — awaits the completer, guaranteeing there's a + // listener for any error. + unawaited(_refreshRefBody(ref, customerKey, completer)); + return completer.future; } - Future _hydrateNewTicket(TicketRef ref) async { + Future _refreshRefBody( + TicketRef ref, + String customerKey, + Completer completer, + ) async { + final int id = ref.id; try { - final statusFuture = client.getTicketStatus(ref.id); - final messagesFuture = client.getMessages(ref.id); - final statusResp = await statusFuture; - final messagesResp = await messagesFuture; - - if (statusResp.hasError || statusResp.value == null) { - Logging.instance.w( - "ShopInBitService.fetchAllForCustomerKey: status failed for " - "${ref.id}: ${statusResp.exception?.message}", + // Ensure the client points at the right key for this ticket's calls. + client.externalCustomerKey = customerKey; + + final ApiResponse fullResp; + final ApiResponse statusResp; + final ApiResponse> messagesResp; + (fullResp, statusResp, messagesResp) = await ( + client.getTicketFull(id), + client.getTicketStatus(id), + client.getMessages(id), + ).wait; + + final ShopInBitTicket? existing = await db.shopInBitTicketsDao.getByApiId( + id, + ); + + if (existing == null) { + await _insertHydrated( + ref: ref, + customerKey: customerKey, + full: fullResp.value, + status: statusResp.value, + messages: messagesResp.value, + ); + } else { + await _patchExisting( + existing: existing, + full: fullResp.value, + status: statusResp.value, + messages: messagesResp.value, ); - return null; } + completer.complete(); + } catch (e, s) { + completer.completeError(e, s); + } finally { + _inFlight.remove(id); + } + } - final apiMessages = messagesResp.value ?? const []; + /// Insert path: every required field must resolve to a real value. If + /// any of /full, /status, or /messages failed we bail rather than write + /// a half-populated row. + Future _insertHydrated({ + required TicketRef ref, + required String customerKey, + required TicketFull? full, + required TicketStatus? status, + required List? messages, + }) async { + if (full == null || status == null || messages == null) return; - final mappedStatus = - ShopInBitOrderModel.statusFromTicketState(statusResp.value!.state) ?? - ShopInBitOrderStatus.pending; + final ShopInBitOrderStatus? mappedStatus = + ShopInBitOrderStatus.fromTicketState(status.state); + if (mappedStatus == null) return; - String? offerProductName; - String? offerPrice; - if (mappedStatus == ShopInBitOrderStatus.offerAvailable) { - final fullResp = await client.getTicketFull(ref.id); - if (!fullResp.hasError && fullResp.value != null) { - offerProductName = fullResp.value!.productName; - offerPrice = fullResp.value!.customerPrice; - } - } + final ShopInBitCategory category = _inferCategory(messages); - final category = _inferCategoryFromMessages(apiMessages); - final feeTicketNumber = category == ShopInBitCategory.car - ? _extractFeeTicketNumber(apiMessages) - : null; - final requestDescription = _extractRequestDescription(apiMessages); - - final messages = apiMessages - .map( - (m) => ShopInBitTicketMessage( - text: m.content, - timestamp: m.timestamp, - isFromUser: !m.fromAgent, - ), - ) - .toList(); - - return ShopInBitTicketsCompanion( - ticketId: Value(ref.number), - displayName: const Value(""), - category: Value(category), - status: Value(mappedStatus), - statusRaw: Value(statusResp.value!.stateRaw), - requestDescription: Value(requestDescription), - deliveryCountry: const Value(""), - offerProductName: Value(offerProductName), - offerPrice: Value(offerPrice), - shippingName: const Value(""), - shippingStreet: const Value(""), - shippingCity: const Value(""), - shippingPostalCode: const Value(""), - shippingCountry: const Value(""), + await db.shopInBitTicketsDao.insertTicket( + ShopInBitTicketsCompanion.insert( + apiTicketId: ref.id, + customerKey: customerKey, + ticketNumber: ref.number, + category: category, + requestDescription: _extractRequestDescription(messages), + deliveryCountry: full.deliveryCountry, + status: mappedStatus, + statusRaw: status.stateRaw, + offerProductName: Value(full.productName), + offerPrice: Value(full.customerPrice), + paymentInvoiceStatus: Value(status.paymentInvoiceStatus), + trackingLink: Value(status.trackingLink), + lastAgentMessageAt: Value(status.lastAgentMessageAt), + feeTicketNumber: Value( + category == ShopInBitCategory.car + ? _extractFeeTicketNumber(messages) + : null, + ), messages: Value(messages), - createdAt: Value(DateTime.now()), - apiTicketId: Value(ref.id), - feeTicketNumber: Value(feeTicketNumber), - needsCreateRequest: const Value(false), - isPendingPayment: const Value(false), - ); - } catch (e, s) { - Logging.instance.e( - "ShopInBitService.fetchAllForCustomerKey: hydrate failed for ${ref.id}", - error: e, - stackTrace: s, - ); - return null; - } + updatedAt: Value(DateTime.now()), + ), + ); + } + + /// Patch path: only touches columns the API actually returned. Stable + /// provenance fields (category, requestDescription, ticketNumber) are + /// never overwritten on update — they were authoritative at insert time. + Future _patchExisting({ + required ShopInBitTicket existing, + required TicketFull? full, + required TicketStatus? status, + required List? messages, + }) async { + final ShopInBitOrderStatus? mappedStatus = status == null + ? null + : ShopInBitOrderStatus.fromTicketState(status.state); + + await db.shopInBitTicketsDao.updateTicket( + existing.apiTicketId, + ShopInBitTicketsCompanion( + // From /status — only patch when we got a recognised state. + status: mappedStatus == null + ? const Value.absent() + : Value(mappedStatus), + statusRaw: status == null + ? const Value.absent() + : Value(status.stateRaw), + paymentInvoiceStatus: status == null + ? const Value.absent() + : Value(status.paymentInvoiceStatus), + trackingLink: status == null + ? const Value.absent() + : Value(status.trackingLink), + lastAgentMessageAt: status == null + ? const Value.absent() + : Value(status.lastAgentMessageAt), + deliveryCountry: full == null + ? const Value.absent() + : Value(full.deliveryCountry), + offerProductName: full == null + ? const Value.absent() + : Value(full.productName), + offerPrice: full == null + ? const Value.absent() + : Value(full.customerPrice), + + // From /messages. + messages: messages == null ? const Value.absent() : Value(messages), + feeTicketNumber: messages == null + ? const Value.absent() + : Value( + existing.category == ShopInBitCategory.car + ? _extractFeeTicketNumber(messages) + : null, + ), + + updatedAt: Value(DateTime.now()), + ), + ); } } -// Infer category from the first user message. The car flow always seeds -// the comment with the "car research fee" line; travel requests built by -// _buildRequestDescription always start with "Arrangement: " followed by -// structured labels. Both are fragile against template changes in the form. -final RegExp _kCarResearchFeeRegex = RegExp(r'car research fee \(#([^)]+)\)'); +// -- Message parsers -- +// +// All "rich" fields the API doesn't surface directly are parsed from the +// first user message. The car flow seeds the comment with the standard +// "car research fee (#XYZ)" line; travel requests start with +// "Arrangement:" followed by structured labels. If either format changes +// server-side, update these regexes. + +final RegExp _kCarResearchFeeRegex = RegExp(r"car research fee \(#([^)]+)\)"); final RegExp _kTravelArrangementRegex = RegExp( - r'^Arrangement:\s', + r"^Arrangement:\s", multiLine: true, ); +final RegExp _kHtmlBrRegex = RegExp(r"", caseSensitive: false); +final RegExp _kHtmlTagRegex = RegExp(r"<[^>]+>"); -ShopInBitCategory _inferCategoryFromMessages(List messages) { - final firstUser = messages.where((m) => !m.fromAgent).firstOrNull; - if (firstUser == null) return ShopInBitCategory.concierge; - final content = firstUser.content; - if (_kCarResearchFeeRegex.hasMatch(content)) { - return ShopInBitCategory.car; +TicketMessage? _firstUserMessage(List messages) { + for (final TicketMessage m in messages) { + if (!m.fromAgent) return m; } + return null; +} + +ShopInBitCategory _inferCategory(List messages) { + final TicketMessage? first = _firstUserMessage(messages); + if (first == null) return ShopInBitCategory.concierge; + final String content = first.content; + if (_kCarResearchFeeRegex.hasMatch(content)) return ShopInBitCategory.car; if (_kTravelArrangementRegex.hasMatch(content)) { return ShopInBitCategory.travel; } @@ -205,19 +341,16 @@ ShopInBitCategory _inferCategoryFromMessages(List messages) { } String? _extractFeeTicketNumber(List messages) { - final firstUser = messages.where((m) => !m.fromAgent).firstOrNull; - if (firstUser == null) return null; - return _kCarResearchFeeRegex.firstMatch(firstUser.content)?.group(1); + final TicketMessage? first = _firstUserMessage(messages); + if (first == null) return null; + return _kCarResearchFeeRegex.firstMatch(first.content)?.group(1); } -// The original `comment` passed to POST /requests becomes the first user message. -final RegExp _kHtmlTagRegex = RegExp(r'<[^>]+>'); - String _extractRequestDescription(List messages) { - final firstUser = messages.where((m) => !m.fromAgent).firstOrNull; - if (firstUser == null) return ""; - return firstUser.content - .replaceAll(RegExp(r'', caseSensitive: false), '\n') - .replaceAll(_kHtmlTagRegex, '') + final TicketMessage? first = _firstUserMessage(messages); + if (first == null) return ""; + return first.content + .replaceAll(_kHtmlBrRegex, "\n") + .replaceAll(_kHtmlTagRegex, "") .trim(); } diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index 1ca8197852..c939c5a584 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -9,13 +9,13 @@ import '../../tor_service.dart'; import 'api_exception.dart'; import 'api_response.dart'; import 'endpoints.dart'; -import 'token_manager.dart'; import 'models/address.dart'; import 'models/car_research.dart'; import 'models/message.dart'; import 'models/payment.dart'; import 'models/ticket.dart'; import 'models/voucher.dart'; +import 'token_manager.dart'; const _kTag = "ShopInBitClient"; @@ -29,7 +29,6 @@ class ShopInBitClient { String? _externalCustomerKey; - String? get externalCustomerKey => _externalCustomerKey; set externalCustomerKey(String? key) => _externalCustomerKey = key; ShopInBitClient({ @@ -386,9 +385,8 @@ class ShopInBitClient { const []; return list .map( - (e) => CarResearchCurrentInvoice.fromJson( - e as Map, - ), + (e) => + CarResearchCurrentInvoice.fromJson(e as Map), ) .toList(); }, @@ -493,7 +491,7 @@ class ShopInBitClient { 'DELETE', '/partners/webhooks/$webhookId', needsCustomerKey: false, - parse: (_) => null, + parse: (_) {}, ); } diff --git a/lib/services/shopinbit/src/models/message.dart b/lib/services/shopinbit/src/models/message.dart index 2048b72c27..1341322598 100644 --- a/lib/services/shopinbit/src/models/message.dart +++ b/lib/services/shopinbit/src/models/message.dart @@ -16,4 +16,13 @@ class TicketMessage { content: json['content'] as String, ); } + + Map toMap() => { + "timestamp": timestamp.toIso8601String(), + "from_agent": fromAgent, + "content": content, + }; + + @override + String toString() => toMap().toString(); } diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 773c0f478b..52ee9fd333 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -42,7 +42,7 @@ class TicketRef { TicketRef({required this.id, required this.number}); factory TicketRef.fromJson(Map json) { - return TicketRef(id: _toInt(json['id']), number: json['number'].toString()); + return TicketRef(id: _toInt(json['id']), number: json['number'] as String); } Map toMap() { @@ -108,12 +108,13 @@ class TicketStatus { class TicketFull { final int id; final String number; - final String productName; - final String customerPrice; - final String partnerPrice; - final String partnerCommission; - final String netPurchasePrice; - final String netShippingCosts; + final String? productName; + final String? customerPrice; + final String? partnerPrice; + final String? partnerCommission; + final String? netPurchasePrice; + final String? netShippingCosts; + final String deliveryCountry; final int vatRate; TicketFull({ @@ -125,19 +126,23 @@ class TicketFull { required this.partnerCommission, required this.netPurchasePrice, required this.netShippingCosts, + required this.deliveryCountry, required this.vatRate, }); factory TicketFull.fromJson(Map json) { return TicketFull( id: _toInt(json['id']), - number: json['number'].toString(), - productName: (json['product_name'] ?? '').toString(), - customerPrice: (json['customer_price'] ?? '').toString(), - partnerPrice: (json['partner_price'] ?? '').toString(), - partnerCommission: (json['partner_commission'] ?? '').toString(), - netPurchasePrice: (json['net_purchase_price'] ?? '').toString(), - netShippingCosts: (json['net_shipping_costs'] ?? '').toString(), + number: json['number'] as String, + productName: json['product_name'] as String?, + customerPrice: json['customer_price'] as String?, + partnerPrice: json['partner_price'] as String?, + partnerCommission: json['partner_commission'] as String?, + netPurchasePrice: json['net_purchase_price'] as String?, + netShippingCosts: json['net_shipping_costs'] as String?, + deliveryCountry: + json['delivery_country'] as String? ?? + (json['deliverycountry'] as String), vatRate: _toInt(json['vat_rate']), ); } @@ -152,6 +157,7 @@ class TicketFull { "partner_commission": partnerCommission, "net_purchase_price": netPurchasePrice, "net_shipping_costs": netShippingCosts, + "delivery_country": deliveryCountry, "vat_rate": vatRate, }; } @@ -162,9 +168,5 @@ class TicketFull { int _toInt(dynamic value) { if (value is int) return value; - if (value is num) return value.toInt(); - // Un-priced offers come back with empty/missing numeric fields; returning 0 - // is safe as it's validated downstream and 0s result in an error dialog - // that pricing's unavailable. - return int.tryParse(value.toString()) ?? 0; + return int.parse(value.toString()); } diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart index 045a289eb5..74e272c779 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -1,8 +1,7 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; -import '../../../models/shopinbit/shopinbit_order_model.dart'; +import '../../../models/shopinbit/shopinbit_enums.dart'; +import '../../../models/shopinbit/shopinbit_request_draft.dart'; import '../../../pages/cakepay/cakepay_card_detail_view.dart'; import '../../../pages/cakepay/cakepay_order_view.dart'; import '../../../pages/cakepay/cakepay_orders_view.dart'; @@ -13,7 +12,6 @@ import '../../../pages/shopinbit/shopinbit_offer_view.dart'; import '../../../pages/shopinbit/shopinbit_order_created.dart'; import '../../../pages/shopinbit/shopinbit_payment_view.dart'; import '../../../pages/shopinbit/shopinbit_shipping_view.dart'; -import '../../../pages/shopinbit/shopinbit_step_1.dart'; import '../../../pages/shopinbit/shopinbit_step_2.dart'; import '../../../pages/shopinbit/shopinbit_step_3.dart'; import '../../../pages/shopinbit/shopinbit_step_4.dart'; @@ -35,77 +33,50 @@ abstract final class NestedNavigatorDialogRouteGenerator { switch (settings.name) { case DesktopShopinBitFirstRun.routeName: - if (args is ShopInBitOrderModel) { - return getRoute( - builder: (_) => DesktopShopinBitFirstRun(model: args), - settings: RouteSettings(name: settings.name), - ); - } - return _routeError( - "${settings.name} invalid args\n" - "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", - ); - - case ShopInBitStep1.routeName: - if (args is ShopInBitOrderModel) { - return getRoute( - builder: (_) => ShopInBitStep1(model: args), - settings: RouteSettings(name: settings.name), - ); - } - return _routeError( - "${settings.name} invalid args\n" - "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + return getRoute( + builder: (_) => const DesktopShopinBitFirstRun(), + settings: RouteSettings(name: settings.name), ); case ShopInBitStep2.routeName: - if (args is ShopInBitOrderModel) { - return getRoute( - builder: (_) => ShopInBitStep2(model: args), - settings: RouteSettings(name: settings.name), - ); - } - if (args is ({ShopInBitOrderModel model, bool isActuallyFirstStep})) { + if (args is bool) { return getRoute( - builder: (_) => ShopInBitStep2( - model: args.model, - isActuallyFirstStep: args.isActuallyFirstStep, - ), + builder: (_) => ShopInBitStep2(isActuallyFirstStep: args), settings: RouteSettings(name: settings.name), ); } - return _routeError( - "${settings.name} invalid args\n" - "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + return getRoute( + builder: (_) => const ShopInBitStep2(), + settings: RouteSettings(name: settings.name), ); case ShopInBitStep3.routeName: - if (args is ShopInBitOrderModel) { + if (args is ({ShopInBitCategory category, String customerKey})) { return getRoute( - builder: (_) => ShopInBitStep3(model: args), + builder: (_) => ShopInBitStep3( + category: args.category, + customerKey: args.customerKey, + ), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected ({ShopInBitCategory category, String customerKey})", ); case ShopInBitStep4.routeName: - if (args is ShopInBitOrderModel) { + if (args is ShopInBitCategory) { return getRoute( - builder: (_) => ShopInBitStep4(model: args), + builder: (_) => ShopInBitStep4(category: args), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected ShopInBitCategory", ); case ShopInBitTicketsView.routeName: @@ -115,82 +86,81 @@ abstract final class NestedNavigatorDialogRouteGenerator { ); case ShopInBitOrderCreated.routeName: - if (args is ShopInBitOrderModel) { + if (args is int) { return getRoute( - builder: (_) => ShopInBitOrderCreated(model: args), + builder: (_) => ShopInBitOrderCreated(apiTicketId: args), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected int apiTicketId", ); case ShopInBitCarFeeView.routeName: - if (args is ShopInBitOrderModel) { + if (args is ShopinbitRequestDraft) { return getRoute( - builder: (_) => ShopInBitCarFeeView(model: args), + builder: (_) => ShopInBitCarFeeView(draft: args), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected ShopinbitRequestDraft", ); case ShopInBitCarResearchPaymentView.routeName: - if (args is (ShopInBitOrderModel, CarResearchInvoice)) { + if (args is CarResearchInvoice) { return getRoute( - builder: (_) => ShopInBitCarResearchPaymentView( - model: args.$1, - invoice: args.$2, - ), + builder: (_) => ShopInBitCarResearchPaymentView(invoice: args), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ({ShopInBitOrderModel model, CarResearchInvoice invoice})", + "Expected CarResearchInvoice", ); case ShopInBitTicketDetail.routeName: - if (args is ShopInBitOrderModel) { + if (args is int) { return getRoute( - builder: (_) => ShopInBitTicketDetail(model: args), + builder: (_) => ShopInBitTicketDetail(apiTicketId: args), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected int apiTicketId", ); case ShopInBitOfferView.routeName: - if (args is ShopInBitOrderModel) { + if (args is int) { return getRoute( - builder: (_) => ShopInBitOfferView(model: args), + builder: (_) => ShopInBitOfferView(apiTicketId: args), settings: RouteSettings(name: settings.name), ); } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected int apiTicketId", ); case ShopInBitShippingView.routeName: if (args is ({ - ShopInBitOrderModel model, + int apiTicketId, + String deliveryCountry, List> countries, })) { return getRoute( builder: (_) => ShopInBitShippingView( - model: args.model, + apiTicketId: args.apiTicketId, + deliveryCountry: args.deliveryCountry, countries: args.countries, ), settings: RouteSettings(name: settings.name), @@ -199,15 +169,16 @@ abstract final class NestedNavigatorDialogRouteGenerator { return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected ShopInBitOrderModel", + "Expected ({int apiTicketId, String deliveryCountry, " + "List> countries})", ); case ShopInBitPaymentView.routeName: - if (args is (ShopInBitOrderModel, PaymentInfo)) { + if (args is ({int apiTicketId, PaymentInfo paymentInfo})) { return getRoute( builder: (_) => ShopInBitPaymentView( - model: args.$1, - paymentInfo: args.$2, + apiTicketId: args.apiTicketId, + paymentInfo: args.paymentInfo, ), settings: RouteSettings(name: settings.name), ); @@ -215,7 +186,7 @@ abstract final class NestedNavigatorDialogRouteGenerator { return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" - "Expected (ShopInBitOrderModel, PaymentInfo)", + "Expected ({int apiTicketId, PaymentInfo paymentInfo})", ); case CakePayVendorsView.routeName: diff --git a/test/price_test.mocks.dart b/test/price_test.mocks.dart index a7a491f5b7..5a7636276e 100644 --- a/test/price_test.mocks.dart +++ b/test/price_test.mocks.dart @@ -97,6 +97,34 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { ) as _i3.Future<_i2.Response>); + @override + _i3.Future<_i2.Response> put({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + @override _i3.Future<_i2.Response> patch({ required Uri? url, diff --git a/test/services/change_now/change_now_test.mocks.dart b/test/services/change_now/change_now_test.mocks.dart index 96aeaf72e4..2636a70d6a 100644 --- a/test/services/change_now/change_now_test.mocks.dart +++ b/test/services/change_now/change_now_test.mocks.dart @@ -97,6 +97,34 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { ) as _i3.Future<_i2.Response>); + @override + _i3.Future<_i2.Response> put({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + @override _i3.Future<_i2.Response> patch({ required Uri? url, diff --git a/test/services/paynym/paynym_is_api_test.mocks.dart b/test/services/paynym/paynym_is_api_test.mocks.dart index c62d8cc0c1..2d46a6cb2d 100644 --- a/test/services/paynym/paynym_is_api_test.mocks.dart +++ b/test/services/paynym/paynym_is_api_test.mocks.dart @@ -97,6 +97,34 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { ) as _i3.Future<_i2.Response>); + @override + _i3.Future<_i2.Response> put({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#put, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + @override _i3.Future<_i2.Response> patch({ required Uri? url, diff --git a/test/shopinbit/car_research_persistence_test.dart b/test/shopinbit/car_research_persistence_test.dart deleted file mode 100644 index b68fd5b64b..0000000000 --- a/test/shopinbit/car_research_persistence_test.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:stackwallet/models/shopinbit/shopinbit_order_model.dart'; - -// Parses "Key: Value\n" car research description; strips " EUR" from Budget. -Map _parseCarRequestDescription(String desc) { - final result = {}; - for (final line in desc.split('\n')) { - final separatorIndex = line.indexOf(': '); - if (separatorIndex == -1) continue; - final key = line.substring(0, separatorIndex); - var value = line.substring(separatorIndex + 2); - if (key == 'Budget') { - value = value.replaceAll(' EUR', ''); - } - result[key] = value; - } - return result; -} - -void main() { - group('car research persistence', () { - group('requestDescription parsing', () { - test('parses all six fields from canonical format', () { - const desc = - 'Brand: Toyota\n' - 'Model: Corolla\n' - 'Condition: used\n' - 'Description: sedan\n' - 'Budget: 10000 EUR\n' - 'Delivery country: DE'; - final parsed = _parseCarRequestDescription(desc); - expect(parsed['Brand'], 'Toyota'); - expect(parsed['Model'], 'Corolla'); - expect(parsed['Condition'], 'used'); - expect(parsed['Description'], 'sedan'); - expect(parsed['Budget'], '10000'); - expect(parsed['Delivery country'], 'DE'); - }); - }); - - group('carResearchPaymentLinks JSON round-trip', () { - test('encode then decode preserves all keys and values', () { - final original = { - 'BTC': 'bitcoin:abc?amount=0.1', - 'ETH': 'ethereum:def', - }; - final encoded = jsonEncode(original); - final decoded = (jsonDecode(encoded) as Map).map( - (k, v) => MapEntry(k, v as String), - ); - expect(decoded, equals(original)); - }); - }); - - group('isPendingPayment defaults false', () { - test('new ShopInBitOrderModel has isPendingPayment == false', () { - final model = ShopInBitOrderModel(); - expect(model.isPendingPayment, isFalse); - }); - }); - - group('live invoice routes to payment view', () { - test('expiresAt in the future means invoice is live', () { - final expiresAt = DateTime.now().add(const Duration(hours: 1)); - expect(expiresAt.isAfter(DateTime.now()), isTrue); - }); - }); - - group('expired invoice routes to fee view', () { - test('expiresAt in the past means invoice is expired', () { - final expiresAt = DateTime.now().subtract(const Duration(hours: 1)); - expect(expiresAt.isAfter(DateTime.now()), isFalse); - }); - }); - - group('clearing isPendingPayment preserves other fields', () { - test( - 'all other model fields unchanged after clearing isPendingPayment', - () { - final model = ShopInBitOrderModel() - ..displayName = 'Test User' - ..requestDescription = - 'Brand: BMW\nModel: X5\nCondition: new\nDescription: suv\nBudget: 50000 EUR\nDelivery country: AT' - ..carResearchInvoiceId = 'inv-123' - ..isPendingPayment = true; - model.isPendingPayment = false; - expect(model.isPendingPayment, isFalse); - expect(model.displayName, 'Test User'); - expect(model.carResearchInvoiceId, 'inv-123'); - expect(model.requestDescription, startsWith('Brand: BMW')); - }, - ); - }); - }); -} From 44042b0b83dceabb11fca650a51aee0d77c2b7a8 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 1 Jun 2026 09:23:19 -0600 Subject: [PATCH 42/90] fix cakepay order refresh so awaiters can be sure a refresh has occurred --- .../cakepay/cakepay_orders_service.dart | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/lib/services/cakepay/cakepay_orders_service.dart b/lib/services/cakepay/cakepay_orders_service.dart index 625850a0aa..7763b9b719 100644 --- a/lib/services/cakepay/cakepay_orders_service.dart +++ b/lib/services/cakepay/cakepay_orders_service.dart @@ -13,7 +13,7 @@ class CakePayOrdersService extends ChangeNotifier { static const Duration defaultPollInterval = Duration(seconds: 15); final Map _orders = {}; - final Set _inflight = {}; + final Map> _inFlight = {}; final Map _polls = {}; bool _refreshingAll = false; @@ -34,26 +34,34 @@ class CakePayOrdersService extends ChangeNotifier { return list; } - bool isRefreshing(String orderId) => _inflight.contains(orderId); + bool isRefreshing(String orderId) => _inFlight.containsKey(orderId); bool get isRefreshingAll => _refreshingAll; - /// Fetch a single order. No-ops if a fetch for [orderId] is already in - /// flight. + /// returns existing future if already in flight Future refreshOne(String orderId) async { - if (_inflight.contains(orderId)) return; - _inflight.add(orderId); + final Completer? pending = _inFlight[orderId]; + if (pending != null) return pending.future; + + final Completer completer = Completer(); + _inFlight[orderId] = completer; notifyListeners(); - try { - final resp = await CakePayService.instance.client.getOrder(orderId); - if (!resp.hasError && resp.value != null) { - _putIfChanged(resp.value!); + + unawaited(() async { + try { + final resp = await CakePayService.instance.client.getOrder(orderId); + if (!resp.hasError && resp.value != null) { + _putIfChanged(resp.value!); + } + completer.complete(); + } catch (e, s) { + completer.completeError(e, s); + } finally { + _inFlight.remove(orderId); + notifyListeners(); } - } catch (_) { - // Silently leave the cached value in place. - } finally { - _inflight.remove(orderId); - notifyListeners(); - } + }()); + + return completer.future; } /// Fetch every locally-tracked order in parallel. From 4ef3fb35302a7b85edb3a82073cdc8333e6385e8 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 1 Jun 2026 11:45:27 -0600 Subject: [PATCH 43/90] fix: record order by using named params --- lib/pages/more_view/services_view.dart | 140 +++++++++++++------------ 1 file changed, 74 insertions(+), 66 deletions(-) diff --git a/lib/pages/more_view/services_view.dart b/lib/pages/more_view/services_view.dart index 0561b07bde..7025e34dd2 100644 --- a/lib/pages/more_view/services_view.dart +++ b/lib/pages/more_view/services_view.dart @@ -31,81 +31,89 @@ class ServicesView extends ConsumerStatefulWidget { class _ServicesViewState extends ConsumerState { Future _showShopDialog() async { - final result = await showDialog<(ShopInBitSetting?, bool)>( - context: context, - barrierDismissible: true, - builder: (context) => StackDialogBase( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("ShopinBit", style: STextStyles.pageTitleH2(context)), - const SizedBox(height: 8), - RichText( - text: TextSpan( - style: STextStyles.smallMed14(context), - children: [ - const TextSpan( - text: - "Please note the following before proceeding:" - "\n\n\u2022 Minimum order amount: 1,000 EUR" - "\n\u2022 Service fee: 10% of the order total" - "\n\nBy continuing, you agree to the ShopinBit ", - ), - TextSpan( - text: "Privacy Policy", - style: STextStyles.richLink(context).copyWith(fontSize: 16), - recognizer: TapGestureRecognizer() - ..onTap = () async { - const url = - "https://api.shopinbit.com/static/policy/privacy.html"; - - await showRequestExternalLinkAndMaybeLaunch( - context, - uri: Uri.parse(url), - ); - }, - ), - const TextSpan(text: "."), - ], - ), - ), - const SizedBox(height: 20), - Row( + final result = + await showDialog<({ShopInBitSetting? settings, bool continuePressed})>( + context: context, + barrierDismissible: true, + builder: (context) => StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - onPressed: Navigator.of(context).pop, + Text("ShopinBit", style: STextStyles.pageTitleH2(context)), + const SizedBox(height: 8), + RichText( + text: TextSpan( + style: STextStyles.smallMed14(context), + children: [ + const TextSpan( + text: + "Please note the following before proceeding:" + "\n\n\u2022 Minimum order amount: 1,000 EUR" + "\n\u2022 Service fee: 10% of the order total" + "\n\nBy continuing, you agree to the ShopinBit ", + ), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 16), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/privacy.html"; + + await showRequestExternalLinkAndMaybeLaunch( + context, + uri: Uri.parse(url), + ); + }, + ), + const TextSpan(text: "."), + ], ), ), - const SizedBox(width: 8), - Expanded( - child: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: () async { - final settings = await ref - .read(pSharedDrift) - .shopInBitSettingsDao - .getCurrentSettings(); + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () async { + final settings = await ref + .read(pSharedDrift) + .shopInBitSettingsDao + .getCurrentSettings(); - if (!context.mounted) return; + if (!context.mounted) return; - Navigator.of(context).pop((true, settings)); - }, - child: Text("Continue", style: STextStyles.button(context)), - ), + Navigator.of( + context, + ).pop((settings: settings, continuePressed: true)); + }, + child: Text( + "Continue", + style: STextStyles.button(context), + ), + ), + ), + ], ), ], ), - ], - ), - ), - ); + ), + ); - if (mounted && result != null && result.$2 == true) { - final settings = result.$1; + if (mounted && result != null && result.continuePressed == true) { + final settings = result.settings; if (settings != null && settings.setupComplete) { // Returning user: straight to category selection. await Navigator.of(context).pushNamed(ShopInBitStep2.routeName); From 44531dd840ea2c4dd7d486fff57a4ed3dea119e1 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 1 Jun 2026 14:45:57 -0600 Subject: [PATCH 44/90] fix: log polling issue. Dialog isn't great here as its polling and... well... --- .../shopinbit/shopinbit_car_research_payment_view.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 18676e9f4b..eb1137bac1 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -252,7 +252,12 @@ class _ShopInBitCarResearchPaymentViewState _pollTimer?.cancel(); await _finalizePayment(); } - } catch (e) { + } catch (e, s) { + Logging.instance.e( + "ticket status polling issue", + error: e, + stackTrace: s, + ); if (mounted) { unawaited( showFloatingFlushBar( From 170cd9dd7501275a5c55740ce3f19f2449d63c05 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 1 Jun 2026 15:21:04 -0600 Subject: [PATCH 45/90] fix: empty string in response and more logging --- lib/pages/shopinbit/shopinbit_tickets_view.dart | 8 +++++++- lib/services/shopinbit/src/models/ticket.dart | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index aaecb69bb1..3f941d5d97 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -10,6 +10,7 @@ import "../../providers/global/shopin_bit_service_provider.dart"; import "../../services/shopinbit/src/models/car_research.dart"; import "../../themes/stack_colors.dart"; import "../../utilities/assets.dart"; +import "../../utilities/logger.dart"; import "../../utilities/text_styles.dart"; import "../../utilities/util.dart"; import "../../widgets/background.dart"; @@ -88,7 +89,12 @@ class _ShopInBitTicketsViewState extends ConsumerState { } } } - } catch (_) { + } catch (e, s) { + Logging.instance.e( + "_loadResumableInvoice failed", + error: e, + stackTrace: s, + ); // Leave _resumableInvoice unchanged on failure. return; } diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 52ee9fd333..db055bee9b 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -115,7 +115,7 @@ class TicketFull { final String? netPurchasePrice; final String? netShippingCosts; final String deliveryCountry; - final int vatRate; + final int? vatRate; TicketFull({ required this.id, @@ -143,7 +143,7 @@ class TicketFull { deliveryCountry: json['delivery_country'] as String? ?? (json['deliverycountry'] as String), - vatRate: _toInt(json['vat_rate']), + vatRate: int.tryParse(json['vat_rate'].toString()), ); } From c25b5cbc4f83d594821da69297ca007d6bd3bcb5 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 1 Jun 2026 15:36:50 -0600 Subject: [PATCH 46/90] fix: optimize a little bit --- lib/services/shopinbit/shopinbit_service.dart | 16 ++++++++++++---- lib/services/shopinbit/src/models/ticket.dart | 5 +++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 0bdcf906fa..1f33466a9c 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -164,6 +164,18 @@ class ShopInBitService { ) async { final int id = ref.id; try { + final ShopInBitTicket? existing = await db.shopInBitTicketsDao.getByApiId( + id, + ); + + // Terminal-state short-circuit: nothing about a closed/merged ticket + // will change server-side, so skip the three API calls entirely. + if (existing != null && + TicketState.fromString(existing.statusRaw).isTerminal) { + completer.complete(); + return; + } + // Ensure the client points at the right key for this ticket's calls. client.externalCustomerKey = customerKey; @@ -176,10 +188,6 @@ class ShopInBitService { client.getMessages(id), ).wait; - final ShopInBitTicket? existing = await db.shopInBitTicketsDao.getByApiId( - id, - ); - if (existing == null) { await _insertHydrated( ref: ref, diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index db055bee9b..237ed1c756 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -33,6 +33,11 @@ enum TicketState { ); return TicketState.unknown; } + + bool get isTerminal => switch(this) { + .closed || .closedCancelled || .merged => true, + _ => false, + } ; } class TicketRef { From 1f42db5b4ddbda8527d386cc1f23b3563946a1f9 Mon Sep 17 00:00:00 2001 From: julian Date: Mon, 1 Jun 2026 18:27:17 -0600 Subject: [PATCH 47/90] fix: add terminal states --- lib/services/shopinbit/src/models/ticket.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 237ed1c756..ca6782680d 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -34,10 +34,14 @@ enum TicketState { return TicketState.unknown; } - bool get isTerminal => switch(this) { - .closed || .closedCancelled || .merged => true, - _ => false, - } ; + bool get isTerminal => switch (this) { + .closed || + .closedCancelled || + .merged || + .pendingClose || + .refunded => true, + _ => false, + }; } class TicketRef { From a5b8a4b3021205c6a6fb5d7a9dbdfddd09001fe7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Mon, 1 Jun 2026 18:19:41 -0500 Subject: [PATCH 48/90] fix(shopinbit): open the real car ticket after the research fee, not the receipt --- .../shopinbit_car_research_payment_view.dart | 64 +++++++++---------- lib/services/shopinbit/shopinbit_service.dart | 35 ++++++++++ 2 files changed, 64 insertions(+), 35 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index eb1137bac1..e2cac8bb5b 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -9,7 +9,6 @@ import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../services/shopinbit/src/models/car_research.dart'; -import '../../services/shopinbit/src/models/ticket.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/logger.dart'; @@ -314,27 +313,42 @@ class _ShopInBitCarResearchPaymentViewState final result = logResp.value!; - // log-payment returns the partner-scoped fee receipt, which the customer - // key cannot poll. Pull the customer-facing car research ticket the - // backend created from the cached request into the local DB, then open - // it. `refreshAll` inserts it so the order-created view can read it. - await service.refreshAll(); - final realTicket = await _resolveRealTicket(result.ticketId); + // log-payment gives us the fee receipt id, which the customer key can't + // poll; the real car ticket is a separate id. Find and open it, retrying + // since it can take a beat to show up in by-customer. + int? realId; + for (int attempt = 0; attempt < 5 && realId == null; attempt++) { + realId = await service.adoptRealCarTicket(result.ticketId); + if (realId == null && attempt < 4) { + await Future.delayed(const Duration(milliseconds: 1500)); + } + } if (!mounted) return; setState(() => _flowState = _PaymentFlowState.complete); - if (realTicket != null) { + if (realId != null) { unawaited( - Navigator.of(context).pushNamed( - ShopInBitOrderCreated.routeName, - arguments: realTicket.id, - ), + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: realId), ); } else { - // Backend has not surfaced the ticket yet; the requests list will pick - // it up on its next refresh. - _popToTickets(); + // The real ticket hasn't surfaced yet; the requests list will pick it + // up on its next refresh. + await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackOkDialog( + title: "Payment received", + maxWidth: Util.isDesktop ? 500 : null, + message: + "We're finalizing your car research request. It will appear " + "in My Requests shortly.", + desktopPopRootNavigator: Util.isDesktop, + ), + ); + if (mounted) _popToTickets(); } } catch (e) { if (mounted) { @@ -353,26 +367,6 @@ class _ShopInBitCarResearchPaymentViewState } } - /// Find the customer-facing car research ticket the backend created from the - /// cached request, excluding the partner-scoped fee receipt. Returns the - /// newest match, or null if none is visible yet. - Future _resolveRealTicket(int receiptTicketId) async { - final service = ref.read(pShopinBitService); - try { - final customerKey = await service.ensureCustomerKey(); - final resp = await service.client.getTicketsByCustomer(customerKey); - if (resp.hasError || resp.value == null) return null; - - final candidates = - resp.value!.where((t) => t.id != receiptTicketId).toList() - ..sort((a, b) => b.id.compareTo(a.id)); - - return candidates.isEmpty ? null : candidates.first; - } catch (_) { - return null; - } - } - void _copyAddress(BuildContext context) { final addr = _currentAddress; if (addr.isEmpty) return; diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 1f33466a9c..85ef51cacb 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -120,6 +120,41 @@ class ShopInBitService { return ref; } + /// log-payment returns the fee *receipt* id, which the customer key can't + /// poll (403s). The real car ticket is a separate id that does show up in + /// by-customer. Grab the newest ticket we don't already track (not the + /// receipt), hydrate just that one, and return its id; null if not there yet. + Future adoptRealCarTicket(int receiptTicketId) async { + final String key = await ensureCustomerKey(); + final ApiResponse> resp = await client.getTicketsByCustomer( + key, + ); + if (resp.hasError || resp.value == null) return null; + + final Set known = (await db.shopInBitTicketsDao.getByCustomerKey( + key, + )).map((t) => t.apiTicketId).toSet(); + + final List candidates = + resp.value! + .where((t) => t.id != receiptTicketId && !known.contains(t.id)) + .toList() + ..sort((a, b) => b.id.compareTo(a.id)); + + // Newest first; the receipt 403s (no row written) so it gets skipped. + for (final TicketRef ref in candidates) { + try { + await _refreshRef(ref, key); + } catch (_) { + // try the next candidate + } + if (await db.shopInBitTicketsDao.getByApiId(ref.id) != null) { + return ref.id; + } + } + return null; + } + Future sendMessage(int apiTicketId, String message) async { final ApiResponse> resp = await client.sendMessage( apiTicketId, From 5de2257841e0abd47417f387ca70908ed203ba8b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 2 Jun 2026 09:42:08 -0500 Subject: [PATCH 49/90] fix(shopinbit): tolerate empty/missing fields when parsing API JSON --- .../shopinbit/src/models/car_research.dart | 6 +++-- .../shopinbit/src/models/message.dart | 8 ++++--- .../shopinbit/src/models/payment.dart | 19 +++++---------- lib/services/shopinbit/src/models/ticket.dart | 24 ++++++++++++------- .../shopinbit/src/models/voucher.dart | 2 +- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart index e5bf15be3b..93a1985584 100644 --- a/lib/services/shopinbit/src/models/car_research.dart +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -92,7 +92,9 @@ class CarResearchInvoice { final linksRaw = json['payment_links'] as Map? ?? {}; return CarResearchInvoice( btcpayInvoice: json['btcpay_invoice'] as String, - expiresAt: DateTime.parse(json['expires_at'] as String), + expiresAt: + DateTime.tryParse(json['expires_at']?.toString() ?? '') ?? + DateTime.now(), paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), ); } @@ -114,7 +116,7 @@ class CarResearchPaymentResult { factory CarResearchPaymentResult.fromJson(Map json) { return CarResearchPaymentResult( status: json['status'] as String, - ticketId: json['ticket_id'] as int, + ticketId: int.tryParse(json['ticket_id'].toString()) ?? 0, ticketNumber: json['ticket_number'] as String, externalCustomerKey: json['external_customer_key'] as String, ); diff --git a/lib/services/shopinbit/src/models/message.dart b/lib/services/shopinbit/src/models/message.dart index 1341322598..85c1ffabea 100644 --- a/lib/services/shopinbit/src/models/message.dart +++ b/lib/services/shopinbit/src/models/message.dart @@ -11,9 +11,11 @@ class TicketMessage { factory TicketMessage.fromJson(Map json) { return TicketMessage( - timestamp: DateTime.parse(json['timestamp'] as String), - fromAgent: json['from_agent'] as bool, - content: json['content'] as String, + timestamp: + DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? + DateTime.now(), + fromAgent: json['from_agent'] as bool? ?? false, + content: json['content'] as String? ?? '', ); } diff --git a/lib/services/shopinbit/src/models/payment.dart b/lib/services/shopinbit/src/models/payment.dart index bd0938da29..0633257663 100644 --- a/lib/services/shopinbit/src/models/payment.dart +++ b/lib/services/shopinbit/src/models/payment.dart @@ -2,7 +2,7 @@ class PaymentInfo { final String status; final String customerPrice; final String partnerPrice; - final int vatRate; + final int? vatRate; final String currency; final DateTime? rateLockedUntil; final Map paymentLinks; @@ -22,23 +22,16 @@ class PaymentInfo { factory PaymentInfo.fromJson(Map json) { final linksRaw = json['payment_links'] as Map? ?? {}; return PaymentInfo( - status: json['status'] as String, + status: (json['status'] ?? '') as String, customerPrice: (json['customer_price'] ?? '') as String, partnerPrice: (json['partner_price'] ?? '') as String, - vatRate: _toInt(json['vat_rate']), + vatRate: int.tryParse(json['vat_rate'].toString()), currency: (json['currency'] ?? 'EUR') as String, - rateLockedUntil: json['rate_locked_until'] != null - ? DateTime.parse(json['rate_locked_until'] as String) - : null, + rateLockedUntil: DateTime.tryParse( + json['rate_locked_until']?.toString() ?? '', + ), paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), due: json['due'] as String?, ); } } - -int _toInt(dynamic v) { - if (v is int) return v; - if (v is String) return int.parse(v); - if (v is double) return v.toInt(); - return 0; -} diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index ca6782680d..63ad123d6f 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -51,7 +51,10 @@ class TicketRef { TicketRef({required this.id, required this.number}); factory TicketRef.fromJson(Map json) { - return TicketRef(id: _toInt(json['id']), number: json['number'] as String); + return TicketRef( + id: _toInt(json['id']), + number: json['number']?.toString() ?? '', + ); } Map toMap() { @@ -85,15 +88,17 @@ class TicketStatus { }); factory TicketStatus.fromJson(Map json) { - final rawState = json['state'] as String; + final rawState = (json['state'] ?? '') as String; return TicketStatus( ticketId: _toInt(json['ticket_id']), state: TicketState.fromString(rawState), stateRaw: rawState, - updatedAt: DateTime.parse(json['updated_at'] as String), - lastAgentMessageAt: json['last_agent_message_at'] != null - ? DateTime.parse(json['last_agent_message_at'] as String) - : null, + updatedAt: + DateTime.tryParse(json['updated_at']?.toString() ?? '') ?? + DateTime.now(), + lastAgentMessageAt: DateTime.tryParse( + json['last_agent_message_at']?.toString() ?? '', + ), paymentInvoiceStatus: json['payment_invoice_status'] as String?, trackingLink: json['tracking_link'] as String?, ); @@ -142,7 +147,7 @@ class TicketFull { factory TicketFull.fromJson(Map json) { return TicketFull( id: _toInt(json['id']), - number: json['number'] as String, + number: json['number']?.toString() ?? '', productName: json['product_name'] as String?, customerPrice: json['customer_price'] as String?, partnerPrice: json['partner_price'] as String?, @@ -151,7 +156,8 @@ class TicketFull { netShippingCosts: json['net_shipping_costs'] as String?, deliveryCountry: json['delivery_country'] as String? ?? - (json['deliverycountry'] as String), + json['deliverycountry'] as String? ?? + '', vatRate: int.tryParse(json['vat_rate'].toString()), ); } @@ -177,5 +183,5 @@ class TicketFull { int _toInt(dynamic value) { if (value is int) return value; - return int.parse(value.toString()); + return int.tryParse(value.toString()) ?? 0; } diff --git a/lib/services/shopinbit/src/models/voucher.dart b/lib/services/shopinbit/src/models/voucher.dart index 97d048a8b4..e65b30420b 100644 --- a/lib/services/shopinbit/src/models/voucher.dart +++ b/lib/services/shopinbit/src/models/voucher.dart @@ -62,7 +62,7 @@ class VipRedemptionResult { return VipRedemptionResult( ticketId: json['ticket_id'] is int ? json['ticket_id'] as int - : int.parse(json['ticket_id'].toString()), + : int.tryParse(json['ticket_id'].toString()) ?? 0, ticketNumber: json['ticket_number'] as String, externalCustomerKey: json['external_customer_key'] as String, voucherCode: json['voucher_code'] as String, From 7ba661a4ee946148498c08e04634dd083f1afa43 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 2 Jun 2026 10:16:11 -0500 Subject: [PATCH 50/90] fix(cakepay): make refreshAll single-flight so awaiters see completion --- lib/pages/cakepay/cakepay_orders_view.dart | 4 +- .../cakepay/cakepay_orders_service.dart | 45 +++++++++++++------ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index 990f43cdb7..f844c22bb6 100644 --- a/lib/pages/cakepay/cakepay_orders_view.dart +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -31,7 +31,9 @@ class _CakePayOrdersViewState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ref.read(pCakePayOrdersService).refreshAll(); + // Fire-and-forget: refreshAll logs and propagates its own errors, so + // ignore the returned future rather than leaving it unhandled. + ref.read(pCakePayOrdersService).refreshAll().ignore(); }); } diff --git a/lib/services/cakepay/cakepay_orders_service.dart b/lib/services/cakepay/cakepay_orders_service.dart index 7763b9b719..7676caa02b 100644 --- a/lib/services/cakepay/cakepay_orders_service.dart +++ b/lib/services/cakepay/cakepay_orders_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../../utilities/logger.dart'; import 'cakepay_service.dart'; import 'src/models/order.dart'; @@ -15,7 +16,7 @@ class CakePayOrdersService extends ChangeNotifier { final Map _orders = {}; final Map> _inFlight = {}; final Map _polls = {}; - bool _refreshingAll = false; + Completer? _refreshAllCompleter; /// Current cached value for [orderId], or null if not yet fetched. CakePayOrder? get(String orderId) => _orders[orderId]; @@ -35,7 +36,7 @@ class CakePayOrdersService extends ChangeNotifier { } bool isRefreshing(String orderId) => _inFlight.containsKey(orderId); - bool get isRefreshingAll => _refreshingAll; + bool get isRefreshingAll => _refreshAllCompleter != null; /// returns existing future if already in flight Future refreshOne(String orderId) async { @@ -64,20 +65,36 @@ class CakePayOrdersService extends ChangeNotifier { return completer.future; } - /// Fetch every locally-tracked order in parallel. + /// Fetch every locally-tracked order in parallel. Returns the existing + /// future if a refresh-all is already in flight, so awaiters can be sure a + /// refresh has actually occurred rather than no-opping. Future refreshAll() async { - if (_refreshingAll) return; - _refreshingAll = true; + final Completer? pending = _refreshAllCompleter; + if (pending != null) return pending.future; + + final Completer completer = Completer(); + _refreshAllCompleter = completer; notifyListeners(); - try { - final ids = await CakePayService.instance.getOrderIds(); - await Future.wait(ids.map(refreshOne)); - } catch (_) { - // Listeners still hold whatever was cached. - } finally { - _refreshingAll = false; - notifyListeners(); - } + + unawaited(() async { + try { + final ids = await CakePayService.instance.getOrderIds(); + await Future.wait(ids.map(refreshOne)); + completer.complete(); + } catch (e, s) { + Logging.instance.e( + "CakePayOrdersService.refreshAll failed", + error: e, + stackTrace: s, + ); + completer.completeError(e, s); + } finally { + _refreshAllCompleter = null; + notifyListeners(); + } + }()); + + return completer.future; } /// Start (or join) a refcounted poll for [orderId]. The first call kicks off From f6babb46c1d22ea537d5033beefdf3c6bb0b361e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 2 Jun 2026 12:20:44 -0500 Subject: [PATCH 51/90] fix(shopinbit): stop polling a ticket once it reaches a terminal state --- lib/pages/shopinbit/shopinbit_ticket_detail.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 364407ad66..8424551d22 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -10,6 +10,7 @@ import '../../db/drift/shared_db/shared_database.dart'; import '../../models/shopinbit/shopinbit_enums.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/message.dart'; +import '../../services/shopinbit/src/models/ticket.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; @@ -70,6 +71,14 @@ class _ShopInBitTicketDetailState extends ConsumerState { Future _poll() async { await _refresh(); if (!mounted) return; + + // Stop polling once the ticket reaches a terminal state; nothing about a + // closed/merged/refunded ticket will change server-side. + final ticket = ref.read(pShopInBitTicket(_id)).asData?.value; + if (ticket != null && TicketState.fromString(ticket.statusRaw).isTerminal) { + return; + } + _pollingTimer = Timer(const Duration(seconds: 30), _poll); } From d911bfd296a6c72ff03b21e5998e328c7ed3af3c Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 2 Jun 2026 12:31:25 -0500 Subject: [PATCH 52/90] fix: log previously-swallowed errors in ShopinBit and CakePay flows --- lib/pages/cakepay/cakepay_order_view.dart | 9 ++++++++- lib/pages/shopinbit/shopinbit_offer_view.dart | 10 ++++++++-- .../shopinbit/shopinbit_payment_shared.dart | 17 ++++++++++++++--- .../shopinbit/shopinbit_settings_view.dart | 15 +++++++++++++-- .../shopinbit/shopinbit_shipping_view.dart | 7 ++++--- .../shopinbit_step4_submit.dart | 8 +++++++- lib/services/shopinbit/shopinbit_service.dart | 17 ++++++++++++++--- 7 files changed, 68 insertions(+), 15 deletions(-) diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index 1f5cead0f5..4232a4edac 100644 --- a/lib/pages/cakepay/cakepay_order_view.dart +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -16,6 +16,7 @@ import '../../services/cakepay/src/models/order.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -224,7 +225,13 @@ class _CakePayOrderViewState extends ConsumerState { Decimal.parse(option.amountFrom.toString()), fractionDigits: coin.fractionDigits, ); - } catch (_) {} + } catch (e, s) { + Logging.instance.e( + "Failed to parse CakePay order amount '${option.amountFrom}'", + error: e, + stackTrace: s, + ); + } _navigateToSendFrom( coin: coin, diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart index 54ac5bab19..a8fd750451 100644 --- a/lib/pages/shopinbit/shopinbit_offer_view.dart +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; @@ -45,8 +46,13 @@ class _ShopInBitOfferViewState extends ConsumerState { // Refresh pulls /full (offer product + price) into the ticket row, which // we then read reactively from the DB stream. await ref.read(pShopinBitService).refreshOne(widget.apiTicketId); - } catch (_) { - // Fall back to whatever the row already has. + } catch (e, s) { + Logging.instance.w( + "Failed to refresh ShopInBit offer ${widget.apiTicketId}, " + "using cached data", + error: e, + stackTrace: s, + ); } finally { if (mounted) setState(() => _loading = false); } diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index 93a6974bf8..befd4e2236 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -13,6 +13,7 @@ import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/default_eth_tokens.dart'; +import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; @@ -85,7 +86,13 @@ ShopInBitPaymentTarget parseShopInBitPaymentTarget({ Decimal.parse(amountStr), fractionDigits: fractionDigits, ); - } catch (_) {} + } catch (e, s) { + Logging.instance.e( + "Failed to parse ShopInBit payment amount '$amountStr'", + error: e, + stackTrace: s, + ); + } } return ShopInBitPaymentTarget(address: address, amount: amount); @@ -244,8 +251,12 @@ Future fetchShopInBitPaymentInfo( if (!putResp.hasError && putResp.value != null) { return putResp.value; } - } catch (_) { - // Degrade to polling-only. + } catch (e, s) { + Logging.instance.w( + "fetchShopInBitPaymentInfo failed, degrading to polling-only", + error: e, + stackTrace: s, + ); } return null; } diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index 757c771320..fd7833954c 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -10,6 +10,7 @@ import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -86,7 +87,12 @@ class _ShopInBitSettingsViewState extends ConsumerState { ), ); } - } catch (e) { + } catch (e, s) { + Logging.instance.e( + "Failed to generate ShopInBit customer key", + error: e, + stackTrace: s, + ); if (mounted) { await showDialog( context: context, @@ -131,7 +137,12 @@ class _ShopInBitSettingsViewState extends ConsumerState { ), ); } - } catch (e) { + } catch (e, s) { + Logging.instance.e( + "Failed to set ShopInBit customer key", + error: e, + stackTrace: s, + ); if (mounted) { await showDialog( context: context, diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 1f3c4125e1..234ccdf151 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -11,6 +11,7 @@ import '../../services/shopinbit/src/models/payment.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; +import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -213,12 +214,12 @@ class _ShopInBitShippingViewState extends ConsumerState { if (resp.hasError) { // Sandbox may fail here; continue anyway. - debugPrint("submitAddress failed: ${resp.exception?.message}"); + Logging.instance.w("submitAddress failed", error: resp.exception); } paymentInfo = await fetchShopInBitPaymentInfo(ref, widget.apiTicketId); - } catch (e) { - debugPrint("submitAddress threw: $e"); + } catch (e, s) { + Logging.instance.e("submitAddress threw", error: e, stackTrace: s); } finally { if (mounted) setState(() => _submitting = false); } diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart index e432f1eb89..c0fa9a8712 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart @@ -5,6 +5,7 @@ import "package:flutter/material.dart"; import "../../../models/shopinbit/shopinbit_request_draft.dart"; import "../../../services/shopinbit/shopinbit_service.dart"; import "../../../services/shopinbit/src/models/ticket.dart"; +import "../../../utilities/logger.dart"; import "../../../utilities/util.dart"; import "../../../widgets/stack_dialog.dart"; import "../shopinbit_order_created.dart"; @@ -54,7 +55,12 @@ Future submitShopInBitRequest( context, ).pushNamed(ShopInBitOrderCreated.routeName, arguments: ref.id), ); - } catch (e) { + } catch (e, s) { + Logging.instance.e( + "Failed to create ShopInBit request", + error: e, + stackTrace: s, + ); if (context.mounted) { await showDialog( context: context, diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 85ef51cacb..9f39c8a21d 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -4,6 +4,7 @@ import "package:drift/drift.dart"; import "../../db/drift/shared_db/shared_database.dart"; import "../../models/shopinbit/shopinbit_enums.dart"; +import "../../utilities/logger.dart"; import "src/api_response.dart"; import "src/client.dart"; import "src/models/message.dart"; @@ -61,7 +62,13 @@ class ShopInBitService { final ApiResponse> resp = await client.getTicketsByCustomer( key, ); - if (resp.hasError || resp.value == null) return; + if (resp.hasError || resp.value == null) { + Logging.instance.w( + "ShopInBitService.refreshAll: failed to fetch ticket list", + error: resp.exception, + ); + return; + } await Future.wait(resp.value!.map((ref) => _refreshRef(ref, key))); } @@ -145,8 +152,12 @@ class ShopInBitService { for (final TicketRef ref in candidates) { try { await _refreshRef(ref, key); - } catch (_) { - // try the next candidate + } catch (e, s) { + Logging.instance.w( + "Failed to refresh candidate ticket ${ref.id}, trying next", + error: e, + stackTrace: s, + ); } if (await db.shopInBitTicketsDao.getByApiId(ref.id) != null) { return ref.id; From a10633a21f0e78f213e1fdda5de599c17eb39cc0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 2 Jun 2026 13:11:02 -0500 Subject: [PATCH 53/90] fix(shopinbit): retry the real car ticket longer, offer a My Requests shortcut --- .../shopinbit_car_research_payment_view.dart | 105 ++++++++++++------ 1 file changed, 68 insertions(+), 37 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index e2cac8bb5b..285e96c283 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -16,6 +16,7 @@ import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/qr.dart'; @@ -215,6 +216,56 @@ class _ShopInBitCarResearchPaymentViewState }); } + /// Pop the car payment flow and land the user directly on the requests list, + /// pushing it only if it isn't already in the stack (e.g. the resume flow + /// entered from there). + void _goToMyRequests() { + final navigator = Navigator.of(context); + bool landedOnTickets = false; + navigator.popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitTicketsView.routeName) { + landedOnTickets = true; + return true; + } + return name == ServicesView.routeName || route.isFirst; + }); + if (!landedOnTickets) { + unawaited(navigator.pushNamed(ShopInBitTicketsView.routeName)); + } + } + + /// Shown when the real car ticket hasn't surfaced in time. Keeps the user + /// informed but offers a one-tap shortcut straight to My Requests rather + /// than making them dismiss and navigate there by hand. + Future _showFinalizingFallback() async { + if (!mounted) return; + final goToRequests = await showDialog( + context: context, + useRootNavigator: Util.isDesktop, + builder: (context) => StackDialog( + title: "Payment received", + message: + "We're finalizing your car research request. It will appear in " + "My Requests shortly.", + leftButton: SecondaryButton( + label: "Close", + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: "My Requests", + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ); + if (!mounted) return; + if (goToRequests == true) { + _goToMyRequests(); + } else { + _popToTickets(); + } + } + Future _pollStatus() async { try { final resp = await ref @@ -291,23 +342,9 @@ class _ShopInBitCarResearchPaymentViewState if (logResp.hasError || logResp.value == null) { // Payment is confirmed but we could not log it. The webhook will - // finalize it server side, so send the user to their requests where - // the finalized ticket will appear. - if (mounted) { - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Payment received", - maxWidth: Util.isDesktop ? 500 : null, - message: - "We're finalizing your car research request. It will " - "appear in My Requests shortly.", - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } - if (mounted) _popToTickets(); + // finalize it server side, so offer the user a shortcut to their + // requests where the finalized ticket will appear. + await _showFinalizingFallback(); return; } @@ -315,12 +352,13 @@ class _ShopInBitCarResearchPaymentViewState // log-payment gives us the fee receipt id, which the customer key can't // poll; the real car ticket is a separate id. Find and open it, retrying - // since it can take a beat to show up in by-customer. + // every 3s for a while since it can take a beat to show up in + // by-customer. int? realId; - for (int attempt = 0; attempt < 5 && realId == null; attempt++) { + for (int attempt = 0; attempt < 12 && realId == null; attempt++) { realId = await service.adoptRealCarTicket(result.ticketId); - if (realId == null && attempt < 4) { - await Future.delayed(const Duration(milliseconds: 1500)); + if (realId == null && attempt < 11) { + await Future.delayed(const Duration(seconds: 3)); } } @@ -334,23 +372,16 @@ class _ShopInBitCarResearchPaymentViewState ).pushNamed(ShopInBitOrderCreated.routeName, arguments: realId), ); } else { - // The real ticket hasn't surfaced yet; the requests list will pick it - // up on its next refresh. - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Payment received", - maxWidth: Util.isDesktop ? 500 : null, - message: - "We're finalizing your car research request. It will appear " - "in My Requests shortly.", - desktopPopRootNavigator: Util.isDesktop, - ), - ); - if (mounted) _popToTickets(); + // The real ticket hasn't surfaced yet; offer a shortcut to the + // requests list, which will pick it up on its next refresh. + await _showFinalizingFallback(); } - } catch (e) { + } catch (e, s) { + Logging.instance.e( + "Failed to process car research payment", + error: e, + stackTrace: s, + ); if (mounted) { setState(() => _flowState = _PaymentFlowState.error); await showDialog( From 0a387bda0a1246ae8afe5362b2711f6e337e862d Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 2 Jun 2026 15:46:13 -0500 Subject: [PATCH 54/90] fix(shopinbit): don't tear down the payment dialog when opening Send from --- lib/pages/shopinbit/shopinbit_payment_shared.dart | 11 ++++------- lib/pages/shopinbit/shopinbit_payment_view.dart | 1 - 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index befd4e2236..04f4913338 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -140,13 +140,13 @@ Future _pushShopInBitSendFrom({ required String address, required int apiTicketId, EthContract? tokenContract, - bool popDesktopBeforeShow = false, String? routeOnSuccessName, }) async { if (Util.isDesktop) { - if (popDesktopBeforeShow) { - Navigator.of(context, rootNavigator: true).pop(); - } + // Show the send-from dialog on top of the payment dialog. Do not pop the + // payment flow first: doing so tears down the whole nested-navigator + // dialog, so closing send-from would drop the user back to Services + // instead of returning to the payment view. await showDialog( context: context, builder: (_) => ShopInBitSendFromView( @@ -185,7 +185,6 @@ Future tryNavigateToShopInBitWalletSend({ required String address, required Amount? amount, required int apiTicketId, - bool popDesktopBeforeShow = false, String? routeOnSuccessName, }) async { if (address.isEmpty) return false; @@ -198,7 +197,6 @@ Future tryNavigateToShopInBitWalletSend({ amount: amount, address: address, apiTicketId: apiTicketId, - popDesktopBeforeShow: popDesktopBeforeShow, routeOnSuccessName: routeOnSuccessName, ); return true; @@ -219,7 +217,6 @@ Future tryNavigateToShopInBitWalletSend({ address: address, apiTicketId: apiTicketId, tokenContract: tokenContract, - popDesktopBeforeShow: popDesktopBeforeShow, routeOnSuccessName: routeOnSuccessName, ); return true; diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 97888d094d..5eba67261a 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -222,7 +222,6 @@ class _ShopInBitPaymentViewState extends ConsumerState { address: target.address, amount: target.amount, apiTicketId: widget.apiTicketId, - popDesktopBeforeShow: true, )) { return; } From 2b4d0cbcc9e69093039b86dee5a5ed1cbac34744 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 2 Jun 2026 15:52:24 -0500 Subject: [PATCH 55/90] feat(shopinbit): show a QR code for manual crypto payments --- lib/pages/shopinbit/shopinbit_payment_view.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 5eba67261a..7e436018e0 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -23,6 +23,7 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/icon_widgets/copy_icon.dart'; +import '../../widgets/qr.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; import 'shopinbit_payment_shared.dart'; @@ -287,6 +288,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { children: [ Text("$ticker Payment", style: STextStyles.pageTitleH2(context)), const SizedBox(height: 16), + Center( + child: QR(data: address, size: Util.isDesktop ? 200 : 180), + ), + const SizedBox(height: 16), GestureDetector( onTap: () { Clipboard.setData(ClipboardData(text: address)); From 25541dd30326fa8d64eb262d06618a33050cff27 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 4 Jun 2026 12:03:37 -0500 Subject: [PATCH 56/90] feat(shopinbit): after payment, nav back to specific request if known --- .../shopinbit/shopinbit_payment_view.dart | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 7e436018e0..f3b7ccf90e 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -26,7 +26,10 @@ import '../../widgets/icon_widgets/copy_icon.dart'; import '../../widgets/qr.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; +import '../more_view/services_view.dart'; import 'shopinbit_payment_shared.dart'; +import 'shopinbit_ticket_detail.dart'; +import 'shopinbit_tickets_view.dart'; class ShopInBitPaymentView extends ConsumerStatefulWidget { const ShopInBitPaymentView({ @@ -247,11 +250,43 @@ class _ShopInBitPaymentViewState extends ConsumerState { Navigator.of(context).pop(); } - void _navigateToTickets() { - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - } else { - Navigator.of(context).popUntil((route) => route.isFirst); + bool get _canReturnToRequest => widget.apiTicketId != 0; + void _backToRequest() { + final navigator = Navigator.of(context); + bool landedOnRequest = false; + navigator.popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitTicketDetail.routeName) { + landedOnRequest = true; + return true; + } + return name == ShopInBitTicketsView.routeName || + name == ServicesView.routeName || + route.isFirst; + }); + if (!landedOnRequest) { + unawaited( + navigator.pushNamed( + ShopInBitTicketDetail.routeName, + arguments: widget.apiTicketId, + ), + ); + } + } + + void _goToMyRequests() { + final navigator = Navigator.of(context); + bool landedOnTickets = false; + navigator.popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitTicketsView.routeName) { + landedOnTickets = true; + return true; + } + return name == ServicesView.routeName || route.isFirst; + }); + if (!landedOnTickets) { + unawaited(navigator.pushNamed(ShopInBitTicketsView.routeName)); } } @@ -569,8 +604,8 @@ class _ShopInBitPaymentViewState extends ConsumerState { ), SizedBox(height: isDesktop ? 16 : 12), PrimaryButton( - label: "View My Requests", - onPressed: _navigateToTickets, + label: _canReturnToRequest ? "Back to Request" : "View My Requests", + onPressed: _canReturnToRequest ? _backToRequest : _goToMyRequests, ), ], SizedBox(height: isDesktop ? 24 : 16), From 99345336115b042ffb833740333a99a4cbb69d4b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 4 Jun 2026 09:12:49 -0500 Subject: [PATCH 57/90] fix(shopinbit): retry 429s with backoff at the request chokepoint All ShopinBit requests funnel through _send, which treated 429 like any other error and let callers re-fire immediately. Add 429-aware retry there: respect a server Retry-After when present, else exponential backoff with jitter, capped at 30s. Widen the http Response to carry headers so Retry-After is readable. --- lib/networking/http.dart | 44 +++++++-- lib/services/shopinbit/src/client.dart | 118 +++++++++++++++++++------ 2 files changed, 128 insertions(+), 34 deletions(-) diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 246891da43..370e3153b4 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -14,9 +14,21 @@ class Response { final int code; final List bodyBytes; + // Lower-cased response header names mapped to their (comma-joined) values. + // Empty by default so existing callers/tests don't need to supply them. + final Map headers; + String get body => utf8.decode(bodyBytes, allowMalformed: true); - Response(this.bodyBytes, this.code); + Response(this.bodyBytes, this.code, {this.headers = const {}}); +} + +Map _headerMap(HttpClientResponse response) { + final map = {}; + response.headers.forEach((name, values) { + map[name.toLowerCase()] = values.join(', '); + }); + return map; } class HTTP { @@ -46,7 +58,11 @@ class HTTP { final response = await request.close(); - return Response(await _bodyBytes(response), response.statusCode); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); } catch (e, s) { Logging.instance.w("HTTP.get() rethrew: ", error: e, stackTrace: s); rethrow; @@ -78,7 +94,11 @@ class HTTP { request.write(body); final response = await request.close(); - return Response(await _bodyBytes(response), response.statusCode); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); } catch (e, s) { Logging.instance.w("HTTP.post() rethrew: ", error: e, stackTrace: s); rethrow; @@ -109,7 +129,11 @@ class HTTP { if (body != null) request.write(body); final response = await request.close(); - return Response(await _bodyBytes(response), response.statusCode); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); } catch (e, s) { Logging.instance.w("HTTP.put() rethrew: ", error: e, stackTrace: s); rethrow; @@ -140,7 +164,11 @@ class HTTP { request.write(body); final response = await request.close(); - return Response(await _bodyBytes(response), response.statusCode); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); } catch (e, s) { Logging.instance.w("HTTP.patch() rethrew: ", error: e, stackTrace: s); rethrow; @@ -168,7 +196,11 @@ class HTTP { } final response = await request.close(); - return Response(await _bodyBytes(response), response.statusCode); + return Response( + await _bodyBytes(response), + response.statusCode, + headers: _headerMap(response), + ); } catch (e, s) { Logging.instance.w("HTTP.delete() rethrew: ", error: e, stackTrace: s); rethrow; diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index c939c5a584..f3909eef5a 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import '../../../app_config.dart'; import '../../../networking/http.dart'; @@ -19,6 +20,10 @@ import 'token_manager.dart'; const _kTag = "ShopInBitClient"; +// 429 retry policy: up to 3 retries, backoff capped at 30s. +const int _kMaxRetries = 3; +const Duration _kMaxBackoff = Duration(seconds: 30); + class ShopInBitClient { final String accessKey; final String partnerSecret; @@ -26,6 +31,7 @@ class ShopInBitClient { final bool sandbox; final HTTP _httpClient; final TokenManager _tokenManager; + final Random _rng = Random(); String? _externalCustomerKey; @@ -578,34 +584,90 @@ class ShopInBitClient { Logging.instance.t("$_kTag $method $uri"); - switch (method) { - case 'GET': - return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy); - case 'POST': - return _httpClient.post( - url: uri, - headers: headers, - body: body != null ? _asciiSafeJson(body) : null, - proxyInfo: proxy, - ); - case 'PUT': - return _httpClient.put( - url: uri, - headers: headers, - body: body != null ? jsonEncode(body) : null, - proxyInfo: proxy, - ); - case 'PATCH': - return _httpClient.patch( - url: uri, - headers: headers, - body: body != null ? _asciiSafeJson(body) : null, - proxyInfo: proxy, - ); - case 'DELETE': - return _httpClient.delete(url: uri, headers: headers, proxyInfo: proxy); - default: - throw ApiException('Unsupported method: $method'); + Future dispatch() { + switch (method) { + case 'GET': + return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy); + case 'POST': + return _httpClient.post( + url: uri, + headers: headers, + body: body != null ? _asciiSafeJson(body) : null, + proxyInfo: proxy, + ); + case 'PUT': + return _httpClient.put( + url: uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + proxyInfo: proxy, + ); + case 'PATCH': + return _httpClient.patch( + url: uri, + headers: headers, + body: body != null ? _asciiSafeJson(body) : null, + proxyInfo: proxy, + ); + case 'DELETE': + return _httpClient.delete( + url: uri, + headers: headers, + proxyInfo: proxy, + ); + default: + throw ApiException('Unsupported method: $method'); + } + } + + // Retry on 429 (Too Many Requests) with backoff so we stop hammering the + // API the moment it tells us to. Respects a server-sent Retry-After when + // present, otherwise exponential backoff with jitter. Everything funnels + // through here, so all endpoints get this for free. + int attempt = 0; + while (true) { + final response = await dispatch(); + if (response.code != 429 || attempt >= _kMaxRetries) { + return response; + } + final Duration delay = _backoffDelay(attempt, response.headers); + Logging.instance.w( + "$_kTag $method $resolved HTTP:429, backing off " + "${delay.inMilliseconds}ms (retry ${attempt + 1}/$_kMaxRetries)", + ); + await Future.delayed(delay); + attempt++; + } + } + + /// How long to wait before retrying a 429. Prefers a sane `Retry-After` + /// header; otherwise 1s, 2s, 4s... with jitter, capped at [_kMaxBackoff]. + Duration _backoffDelay(int attempt, Map headers) { + final Duration? retryAfter = _parseRetryAfter(headers['retry-after']); + if (retryAfter != null) { + return retryAfter > _kMaxBackoff ? _kMaxBackoff : retryAfter; + } + final int base = 1000 * (1 << attempt); + final int ms = base + _rng.nextInt(500); + return ms > _kMaxBackoff.inMilliseconds + ? _kMaxBackoff + : Duration(milliseconds: ms); + } + + /// Parse a `Retry-After` value, which is either delay-seconds or an + /// HTTP-date. Returns null if absent or unparseable. + Duration? _parseRetryAfter(String? value) { + if (value == null) return null; + final String trimmed = value.trim(); + final int? seconds = int.tryParse(trimmed); + if (seconds != null) { + return seconds < 0 ? Duration.zero : Duration(seconds: seconds); + } + try { + final Duration diff = HttpDate.parse(trimmed).difference(DateTime.now()); + return diff.isNegative ? Duration.zero : diff; + } catch (_) { + return null; } } From 5caf4095964ecb324ea42c5ca23a82fcfba11bf7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 4 Jun 2026 09:45:32 -0500 Subject: [PATCH 58/90] fix(shopinbit): back off the real-car-ticket adoption retries --- .../shopinbit_car_research_payment_view.dart | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 285e96c283..7f3484365f 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -352,13 +352,20 @@ class _ShopInBitCarResearchPaymentViewState // log-payment gives us the fee receipt id, which the customer key can't // poll; the real car ticket is a separate id. Find and open it, retrying - // every 3s for a while since it can take a beat to show up in - // by-customer. + // for a while since it can take a beat to show up in by-customer. Back + // off between tries (2s, 4s, 8s... capped at 15s) so we don't hammer the + // by-customer endpoint while we wait. int? realId; - for (int attempt = 0; attempt < 12 && realId == null; attempt++) { + const int maxAttempts = 8; + for ( + int attempt = 0; + attempt < maxAttempts && realId == null; + attempt++ + ) { realId = await service.adoptRealCarTicket(result.ticketId); - if (realId == null && attempt < 11) { - await Future.delayed(const Duration(seconds: 3)); + if (realId == null && attempt < maxAttempts - 1) { + final int seconds = (1 << (attempt + 1)).clamp(2, 15).toInt(); + await Future.delayed(Duration(seconds: seconds)); } } From 5ebb52c2b537edd72794180e56dd31cc25b986e0 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 4 Jun 2026 11:02:47 -0500 Subject: [PATCH 59/90] fix(shopinbit): back off pollers on error and pause when backgrounded The payment, car-research, and ticket-detail views polled on fixed 15s/30s timers that kept firing at full rate even when a request failed, so a 429 just got ignored and re-provoked. Switch each to a self-scheduling timer that doubles its interval on failure (capped at 120s) and resets on success, and pause polling while the app is backgrounded via WidgetsBindingObserver. Previously swallowed poll errors are now logged. --- .../shopinbit_car_research_payment_view.dart | 62 +++++++++++++++--- .../shopinbit/shopinbit_payment_view.dart | 63 ++++++++++++++++--- .../shopinbit/shopinbit_ticket_detail.dart | 45 ++++++++++++- 3 files changed, 149 insertions(+), 21 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 7f3484365f..d8f78bdb41 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -42,8 +42,14 @@ class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { } class _ShopInBitCarResearchPaymentViewState - extends ConsumerState { + extends ConsumerState + with WidgetsBindingObserver { Timer? _pollTimer; + + static const Duration _kBasePollInterval = Duration(seconds: 15); + static const Duration _kMaxPollInterval = Duration(seconds: 120); + Duration _pollInterval = _kBasePollInterval; + Map? _status; _PaymentFlowState _flowState = _PaymentFlowState.idle; String _statusString = "ready_to_pay"; @@ -183,23 +189,59 @@ class _ShopInBitCarResearchPaymentViewState @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); final links = widget.invoice.paymentLinks; _methods = links.keys.map((k) => k.toUpperCase()).toList(); _addresses = links.values.toList(); // Kick off an immediate poll then start periodic polling. unawaited(_pollStatus()); - _pollTimer = Timer.periodic( - const Duration(seconds: 15), - (_) => unawaited(_pollStatus()), - ); + _scheduleNextPoll(); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _pollTimer?.cancel(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Don't poll while backgrounded; resume fresh when we come back. + if (state == AppLifecycleState.resumed) { + if (!_isTerminal && _flowState != _PaymentFlowState.finalizing) { + _pollInterval = _kBasePollInterval; + _scheduleNextPoll(); + } + } else { + _pollTimer?.cancel(); + } + } + + void _scheduleNextPoll() { + _pollTimer?.cancel(); + _pollTimer = Timer(_pollInterval, _pollTick); + } + + Duration _nextBackoff(Duration current) { + final Duration next = current * 2; + return next > _kMaxPollInterval ? _kMaxPollInterval : next; + } + + /// Periodic driver: poll once, then reschedule with backoff on failure and + /// reset on success. Stops once the flow is terminal or finalizing. + Future _pollTick() async { + final bool ok = await _pollStatus(); + if (!mounted) return; + if (_isTerminal || + _flowState == _PaymentFlowState.finalizing || + _flowState == _PaymentFlowState.complete) { + return; + } + _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval); + _scheduleNextPoll(); + } + void _popToTickets() { Navigator.of(context).popUntil((route) { final name = route.settings.name; @@ -266,7 +308,9 @@ class _ShopInBitCarResearchPaymentViewState } } - Future _pollStatus() async { + /// Fetch invoice status once and apply it. Returns false on any failure so + /// the periodic driver can back off instead of polling at full rate. + Future _pollStatus() async { try { final resp = await ref .read(pShopinBitService) @@ -283,9 +327,9 @@ class _ShopInBitCarResearchPaymentViewState ), ); } - return; + return false; } - if (!mounted) return; + if (!mounted) return true; Logging.instance.i( "CarResearch status response (payment_view): ${resp.value}", ); @@ -302,6 +346,7 @@ class _ShopInBitCarResearchPaymentViewState _pollTimer?.cancel(); await _finalizePayment(); } + return true; } catch (e, s) { Logging.instance.e( "ticket status polling issue", @@ -317,6 +362,7 @@ class _ShopInBitCarResearchPaymentViewState ), ); } + return false; } } diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index f3b7ccf90e..f3069828a1 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -15,6 +15,7 @@ import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; @@ -50,10 +51,15 @@ class ShopInBitPaymentView extends ConsumerStatefulWidget { _ShopInBitPaymentViewState(); } -class _ShopInBitPaymentViewState extends ConsumerState { +class _ShopInBitPaymentViewState extends ConsumerState + with WidgetsBindingObserver { int _selectedMethod = 0; Timer? _pollTimer; + static const Duration _kBasePollInterval = Duration(seconds: 15); + static const Duration _kMaxPollInterval = Duration(seconds: 120); + Duration _pollInterval = _kBasePollInterval; + PaymentInfo? _paymentInfo; // Derived from API payment_links keys, fallback to defaults @@ -81,6 +87,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _applyPaymentInfo(widget.paymentInfo); if (widget.apiTicketId != 0) { _startPolling(); @@ -89,10 +96,22 @@ class _ShopInBitPaymentViewState extends ConsumerState { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _pollTimer?.cancel(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (widget.apiTicketId == 0) return; + // Don't poll while backgrounded; resume fresh when we come back. + if (state == AppLifecycleState.resumed) { + if (!_isTerminal) _startPolling(); + } else { + _pollTimer?.cancel(); + } + } + void _applyPaymentInfo(PaymentInfo info) { _paymentInfo = info; final links = info.paymentLinks; @@ -104,25 +123,49 @@ class _ShopInBitPaymentViewState extends ConsumerState { void _startPolling() { _pollTimer?.cancel(); - _pollTimer = Timer.periodic( - const Duration(seconds: 15), - (_) => _pollPayment(), - ); + _pollInterval = _kBasePollInterval; + _scheduleNextPoll(); + } + + void _scheduleNextPoll() { + _pollTimer?.cancel(); + _pollTimer = Timer(_pollInterval, _pollPayment); + } + + Duration _nextBackoff(Duration current) { + final Duration next = current * 2; + return next > _kMaxPollInterval ? _kMaxPollInterval : next; } Future _pollPayment() async { + bool ok = false; try { final resp = await ref .read(pShopinBitService) .client .getPayment(widget.apiTicketId); - if (!resp.hasError && resp.value != null && mounted) { - setState(() => _applyPaymentInfo(resp.value!)); - if (_isTerminal) { - _pollTimer?.cancel(); + if (!resp.hasError && resp.value != null) { + ok = true; + if (mounted) { + setState(() => _applyPaymentInfo(resp.value!)); } } - } catch (_) {} + } catch (e, s) { + Logging.instance.w( + "ShopInBit payment poll failed", + error: e, + stackTrace: s, + ); + } + if (!mounted) return; + if (_isTerminal) { + _pollTimer?.cancel(); + return; + } + // Back off on failure (e.g. a 429), reset to base on success, so a rate + // limit slows us down instead of getting hammered every 15s. + _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval); + _scheduleNextPoll(); } Future _refreshInvoice() async { diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 8424551d22..49fac0e57e 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -13,6 +13,7 @@ import '../../services/shopinbit/src/models/message.dart'; import '../../services/shopinbit/src/models/ticket.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -38,9 +39,14 @@ class ShopInBitTicketDetail extends ConsumerStatefulWidget { _ShopInBitTicketDetailState(); } -class _ShopInBitTicketDetailState extends ConsumerState { +class _ShopInBitTicketDetailState extends ConsumerState + with WidgetsBindingObserver { late final TextEditingController _messageController; + static const Duration _kBasePollInterval = Duration(seconds: 30); + static const Duration _kMaxPollInterval = Duration(seconds: 120); + Duration _pollInterval = _kBasePollInterval; + // Optimistically-shown messages the user just sent, kept until the next // refresh folds them into the persisted ticket row. final List _pending = []; @@ -54,6 +60,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { super.initState(); _messageController = TextEditingController(); + WidgetsBinding.instance.addObserver(this); // start with a refresh right away and then start polling for updates unawaited(_refresh().then((_) => _startPolling())); @@ -61,15 +68,39 @@ class _ShopInBitTicketDetailState extends ConsumerState { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _pollingTimer?.cancel(); _pollingTimer = null; _messageController.dispose(); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Don't poll while backgrounded; resume fresh when we come back. + if (state == AppLifecycleState.resumed) { + final ticket = ref.read(pShopInBitTicket(_id)).asData?.value; + final terminal = + ticket != null && TicketState.fromString(ticket.statusRaw).isTerminal; + if (!terminal) _startPolling(); + } else { + _pollingTimer?.cancel(); + } + } + Timer? _pollingTimer; Future _poll() async { - await _refresh(); + bool ok = false; + try { + await _refresh(); + ok = true; + } catch (e, s) { + Logging.instance.w( + "ShopInBit ticket poll failed", + error: e, + stackTrace: s, + ); + } if (!mounted) return; // Stop polling once the ticket reaches a terminal state; nothing about a @@ -79,11 +110,19 @@ class _ShopInBitTicketDetailState extends ConsumerState { return; } - _pollingTimer = Timer(const Duration(seconds: 30), _poll); + // Back off on failure (e.g. a 429), reset on success. + _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval); + _pollingTimer = Timer(_pollInterval, _poll); + } + + Duration _nextBackoff(Duration current) { + final Duration next = current * 2; + return next > _kMaxPollInterval ? _kMaxPollInterval : next; } void _startPolling() { _pollingTimer?.cancel(); + _pollInterval = _kBasePollInterval; unawaited(_poll()); } From 60559c428d1221366b368c870872abb96132022a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 4 Jun 2026 11:54:32 -0500 Subject: [PATCH 60/90] fix(shopinbit): combine by-customer and car-invoice fetches getTicketsByCustomer and getCurrentCarResearchInvoices were the two read paths left outside the completer-based dedup that already guards _refreshRef, so overlapping refreshes (tickets view racing a post-action refresh, or refreshAll racing adoptRealCarTicket) each fired their own request. Wrap both in the same in-flight combined pattern and route callers through it. --- .../shopinbit/shopinbit_tickets_view.dart | 1 - lib/services/shopinbit/shopinbit_service.dart | 64 +++++++++++++++++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index 3f941d5d97..c2a5538750 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -69,7 +69,6 @@ class _ShopInBitTicketsViewState extends ConsumerState { try { final resp = await ref .read(pShopinBitService) - .client .getCurrentCarResearchInvoices(); final invoices = resp.value; if (invoices != null) { diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 9f39c8a21d..b9f7a37b11 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -7,6 +7,7 @@ import "../../models/shopinbit/shopinbit_enums.dart"; import "../../utilities/logger.dart"; import "src/api_response.dart"; import "src/client.dart"; +import "src/models/car_research.dart"; import "src/models/message.dart"; import "src/models/ticket.dart"; @@ -21,6 +22,61 @@ class ShopInBitService { final Map> _inFlight = {}; + // Combine concurrent list/invoice fetches the same way _refreshRef does, so + // overlapping refreshes (e.g. tickets view refresh racing a post-action one) + // share a single round-trip instead of each hitting the API. + Completer>>? _ticketsInFlight; + String? _ticketsInFlightKey; + Completer>>? _carInvoicesInFlight; + + /// Combined by-customer ticket list fetch. Concurrent calls for the same + /// key await the same in-flight request. + Future>> _ticketsByCustomer(String key) { + final Completer>>? pending = _ticketsInFlight; + if (pending != null && _ticketsInFlightKey == key) { + return pending.future; + } + final Completer>> completer = Completer(); + _ticketsInFlight = completer; + _ticketsInFlightKey = key; + unawaited( + client + .getTicketsByCustomer(key) + .then(completer.complete, onError: completer.completeError) + .whenComplete(() { + if (_ticketsInFlight == completer) { + _ticketsInFlight = null; + _ticketsInFlightKey = null; + } + }), + ); + return completer.future; + } + + /// Combined wrapper around the current car research invoices fetch. The + /// tickets view calls this on every refresh, so dedup keeps overlapping + /// refreshes from each firing their own request. + Future>> + getCurrentCarResearchInvoices() { + final Completer>>? pending = + _carInvoicesInFlight; + if (pending != null) return pending.future; + final Completer>> completer = + Completer(); + _carInvoicesInFlight = completer; + unawaited( + client + .getCurrentCarResearchInvoices() + .then(completer.complete, onError: completer.completeError) + .whenComplete(() { + if (_carInvoicesInFlight == completer) { + _carInvoicesInFlight = null; + } + }), + ); + return completer.future; + } + // -- Customer key -- /// Returns the most-recently-used customer key. Generates a new one if @@ -59,9 +115,7 @@ class ShopInBitService { /// New tickets are hydrated and inserted; existing tickets are patched. Future refreshAll() async { final String key = await ensureCustomerKey(); - final ApiResponse> resp = await client.getTicketsByCustomer( - key, - ); + final ApiResponse> resp = await _ticketsByCustomer(key); if (resp.hasError || resp.value == null) { Logging.instance.w( "ShopInBitService.refreshAll: failed to fetch ticket list", @@ -133,9 +187,7 @@ class ShopInBitService { /// receipt), hydrate just that one, and return its id; null if not there yet. Future adoptRealCarTicket(int receiptTicketId) async { final String key = await ensureCustomerKey(); - final ApiResponse> resp = await client.getTicketsByCustomer( - key, - ); + final ApiResponse> resp = await _ticketsByCustomer(key); if (resp.hasError || resp.value == null) return null; final Set known = (await db.shopInBitTicketsDao.getByCustomerKey( From fb6ea0c2f2cb6cd26cf9f553574a224a8214a1a7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 9 Jun 2026 13:55:43 -0500 Subject: [PATCH 61/90] fix(shopinbit): migrate car research flow to API v1.0.6 docs(shopinbit): tighten car research v1.0.6 comments --- .../shopinbit/shopinbit_car_fee_view.dart | 5 +- .../shopinbit_car_research_payment_view.dart | 66 +++++++++--------- lib/services/shopinbit/shopinbit_service.dart | 12 ++-- lib/services/shopinbit/src/client.dart | 30 +++----- .../shopinbit/src/models/car_research.dart | 68 ++++++++++++++----- 5 files changed, 106 insertions(+), 75 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 60ea3f4f6f..c297be0e6f 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -315,9 +315,8 @@ class _ShopInBitCarFeeViewState extends ConsumerState { } Future _loadFee(CarResearchInvoice invoice) async { - // Keep status call for visibility into any future API changes surfacing - // a fee field. Today the endpoint returns only {status, additional}, so - // we source the displayed amount from the BIP21 payment URIs instead. + // Still hit status for logging; it has no fee field, so the amount comes + // from the BIP21 payment URIs. try { final resp = await ref .read(pShopinBitService) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index d8f78bdb41..08c81eb627 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -50,10 +50,15 @@ class _ShopInBitCarResearchPaymentViewState static const Duration _kMaxPollInterval = Duration(seconds: 120); Duration _pollInterval = _kBasePollInterval; - Map? _status; + CarResearchInvoiceStatus? _status; _PaymentFlowState _flowState = _PaymentFlowState.idle; String _statusString = "ready_to_pay"; String? _additional; + bool _finalized = false; + // From the finalized status: the real ticket is the customer chat, the + // receipt is just the paid-fee receipt. + int? _realTicketId; + int? _receiptTicketId; List _methods = []; List _addresses = []; int _selectedMethod = 0; @@ -61,7 +66,9 @@ class _ShopInBitCarResearchPaymentViewState String get _currentAddress => _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : ""; - bool get _isTerminal => carResearchIsFinalized(_statusString, _additional); + // Trust the `finalized` flag; fall back to the status/additional heuristic. + bool get _isTerminal => + _finalized || carResearchIsFinalized(_statusString, _additional); bool get _payNowEnabled => !_isTerminal && _flowState == _PaymentFlowState.idle; @@ -145,10 +152,9 @@ class _ShopInBitCarResearchPaymentViewState } String get _displayedFee { - // API status endpoint does not expose a fee field (confirmed: returns - // only {status, additional}). Parse the amount from the BIP21 payment - // URI for the currently-selected method, fall back to the 223.00 EUR - // business-rule value if no parse succeeds. + // The status endpoint has no fee field, so parse the amount from the + // selected method's BIP21 URI, falling back to the 223.00 EUR business + // rule. final links = widget.invoice.paymentLinks; if (_selectedMethod < _methods.length) { final methodKey = _methods[_selectedMethod]; @@ -339,8 +345,13 @@ class _ShopInBitCarResearchPaymentViewState ); setState(() { _status = resp.value!; - _statusString = _status!["status"]?.toString() ?? _statusString; - _additional = _status!["additional"]?.toString(); + _statusString = _status!.status.isNotEmpty + ? _status!.status + : _statusString; + _additional = _status!.additional; + _finalized = _status!.finalized; + _realTicketId = _status!.realTicketId; + _receiptTicketId = _status!.receiptTicketId; }); if (_isTerminal) { _pollTimer?.cancel(); @@ -377,38 +388,29 @@ class _ShopInBitCarResearchPaymentViewState _pollTimer?.cancel(); final service = ref.read(pShopinBitService); - final client = service.client; try { - // Best-effort: the BTCPay webhook is the failsafe that finalizes the fee - // and creates the receipt and real car ticket even if this call fails. - final logResp = await client.logCarResearchPayment( - widget.invoice.btcpayInvoice, - ); - - if (logResp.hasError || logResp.value == null) { - // Payment is confirmed but we could not log it. The webhook will - // finalize it server side, so offer the user a shortcut to their - // requests where the finalized ticket will appear. - await _showFinalizingFallback(); - return; - } - - final result = logResp.value!; - - // log-payment gives us the fee receipt id, which the customer key can't - // poll; the real car ticket is a separate id. Find and open it, retrying - // for a while since it can take a beat to show up in by-customer. Back - // off between tries (2s, 4s, 8s... capped at 15s) so we don't hammer the - // by-customer endpoint while we wait. - int? realId; + // The finalized status usually gives us the real car ticket id (the + // customer chat), so open that. It can be null for a bit (sandbox, or + // while the ticket is still being created), so fall back to by-customer, + // using the receipt id to skip the receipt ticket. The BTCPay webhook + // creates the ticket either way. + int? realId = _realTicketId; const int maxAttempts = 8; for ( int attempt = 0; attempt < maxAttempts && realId == null; attempt++ ) { - realId = await service.adoptRealCarTicket(result.ticketId); + // Re-poll the status first in case the real ticket id has appeared. + final statusResp = await service.client.getCarResearchInvoiceStatus( + widget.invoice.btcpayInvoice, + ); + realId = statusResp.value?.realTicketId; + // Fall back to the by-customer heuristic, excluding the receipt id. + realId ??= await service.adoptRealCarTicket( + statusResp.value?.receiptTicketId ?? _receiptTicketId ?? 0, + ); if (realId == null && attempt < maxAttempts - 1) { final int seconds = (1 << (attempt + 1)).clamp(2, 15).toInt(); await Future.delayed(Duration(seconds: seconds)); diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index b9f7a37b11..0061ecae2c 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -181,10 +181,14 @@ class ShopInBitService { return ref; } - /// log-payment returns the fee *receipt* id, which the customer key can't - /// poll (403s). The real car ticket is a separate id that does show up in - /// by-customer. Grab the newest ticket we don't already track (not the - /// receipt), hydrate just that one, and return its id; null if not there yet. + /// Fallback for finding the real car research ticket when the status endpoint + /// hasn't populated real_ticket_id yet (sandbox, or briefly while the ticket + /// is being created). + /// + /// The fee receipt id can't be polled by the customer key (403s); the real + /// ticket is a separate id that shows up in by-customer. Grab the newest + /// ticket we don't already track (not the receipt), hydrate it, and return + /// its id, or null if it's not there yet. Future adoptRealCarTicket(int receiptTicketId) async { final String key = await ensureCustomerKey(); final ApiResponse> resp = await _ticketsByCustomer(key); diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index f3909eef5a..8eb3855b52 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -358,16 +358,20 @@ class ShopInBitClient { // -- Car Research Fee -- + /// Create the car research fee invoice. Both [billing] and [request] are + /// required; without a request the server returns 422 and creates nothing. + /// The stored request lets the backend build the customer-facing car ticket + /// once the fee is paid. Future> createCarResearchInvoice({ required Address billing, - CarResearchRequest? request, + required CarResearchRequest request, }) async { return _request( 'POST', '/car-research/invoice', body: { 'billing': billing.toJson(), - if (request != null) 'request': request.toJson(), + 'request': request.toJson(), if (_externalCustomerKey != null) 'external_customer_key': _externalCustomerKey, }, @@ -399,28 +403,16 @@ class ShopInBitClient { ); } - Future>> getCarResearchInvoiceStatus( + /// Poll the car research invoice status. Read-only: it never confirms + /// payment. Once [CarResearchInvoiceStatus.finalized] is true the response + /// carries the receipt and real ticket references. + Future> getCarResearchInvoiceStatus( String invoiceId, ) async { return _request( 'GET', '/car-research/invoice/$invoiceId/status', - parse: (json) => json, - ); - } - - Future> logCarResearchPayment( - String invoiceId, - ) async { - return _request( - 'POST', - '/car-research/log-payment', - body: { - 'invoice_id': invoiceId, - if (_externalCustomerKey != null) - 'external_customer_key': _externalCustomerKey, - }, - parse: CarResearchPaymentResult.fromJson, + parse: CarResearchInvoiceStatus.fromJson, ); } diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart index 93a1985584..8a8a9a7ebc 100644 --- a/lib/services/shopinbit/src/models/car_research.dart +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -57,9 +57,12 @@ class CarResearchCurrentInvoice { } } -/// Whether a car research invoice status counts as paid/finalized per the -/// ShopinBit 1.0.4 rules: Processing, Settled, or Expired with PaidLate. The -/// extra lowercase values keep older concierge-style statuses working. +/// Whether a car research invoice status counts as paid/finalized. +/// +/// Prefer the `finalized` boolean from the status endpoint (see +/// [CarResearchInvoiceStatus.finalized]). This is the fallback for the raw +/// status/additional strings: Processing, Settled, or Expired with PaidLate, +/// plus lowercase values for older concierge-style statuses. bool carResearchIsFinalized(String? status, String? additional) { final s = (status ?? '').toLowerCase().trim(); final a = (additional ?? '').toLowerCase().trim(); @@ -100,25 +103,56 @@ class CarResearchInvoice { } } -class CarResearchPaymentResult { +/// Result of GET /car-research/invoice/{invoice_id}/status. +/// +/// Read-only: it never confirms payment, so poll until [finalized] is true. +/// Once finalized it carries the created ticket references: +/// +/// * [realTicketId] / [realTicketNumber]: the customer-facing car research +/// chat. Open this for the customer after payment. +/// * [receiptTicketId] / [receiptTicketNumber]: the paid-fee receipt only; +/// do NOT use it as the active customer chat. +/// +/// The sandbox populates only the receipt references and leaves the real ticket +/// fields null, so [realTicketId] is nullable. +class CarResearchInvoiceStatus { final String status; - final int ticketId; - final String ticketNumber; - final String externalCustomerKey; + final String? additional; + final bool finalized; + final int? receiptTicketId; + final String? receiptTicketNumber; + final int? realTicketId; + final String? realTicketNumber; + final String? externalCustomerKey; - CarResearchPaymentResult({ + CarResearchInvoiceStatus({ required this.status, - required this.ticketId, - required this.ticketNumber, - required this.externalCustomerKey, + this.additional, + required this.finalized, + this.receiptTicketId, + this.receiptTicketNumber, + this.realTicketId, + this.realTicketNumber, + this.externalCustomerKey, }); - factory CarResearchPaymentResult.fromJson(Map json) { - return CarResearchPaymentResult( - status: json['status'] as String, - ticketId: int.tryParse(json['ticket_id'].toString()) ?? 0, - ticketNumber: json['ticket_number'] as String, - externalCustomerKey: json['external_customer_key'] as String, + factory CarResearchInvoiceStatus.fromJson(Map json) { + int? toIntOrNull(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is double) return v.toInt(); + return int.tryParse(v.toString()); + } + + return CarResearchInvoiceStatus( + status: json['status']?.toString() ?? '', + additional: json['additional']?.toString(), + finalized: json['finalized'] == true, + receiptTicketId: toIntOrNull(json['receipt_ticket_id']), + receiptTicketNumber: json['receipt_ticket_number']?.toString(), + realTicketId: toIntOrNull(json['real_ticket_id']), + realTicketNumber: json['real_ticket_number']?.toString(), + externalCustomerKey: json['external_customer_key']?.toString(), ); } } From 090ab33160a6e176e7ff6f8d3deebf4973ea996b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 12:11:20 -0500 Subject: [PATCH 62/90] refactor(shopinbit): consolidate poll backoff into the client --- .../shopinbit/shopinbit_car_research_payment_view.dart | 10 ++++------ lib/pages/shopinbit/shopinbit_payment_view.dart | 10 ++++------ lib/pages/shopinbit/shopinbit_ticket_detail.dart | 10 ++++------ lib/services/shopinbit/src/client.dart | 6 ++++++ 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 08c81eb627..2896158ca3 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -8,6 +8,7 @@ import '../../app_config.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; +import '../../services/shopinbit/src/client.dart'; import '../../services/shopinbit/src/models/car_research.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -229,11 +230,6 @@ class _ShopInBitCarResearchPaymentViewState _pollTimer = Timer(_pollInterval, _pollTick); } - Duration _nextBackoff(Duration current) { - final Duration next = current * 2; - return next > _kMaxPollInterval ? _kMaxPollInterval : next; - } - /// Periodic driver: poll once, then reschedule with backoff on failure and /// reset on success. Stops once the flow is terminal or finalizing. Future _pollTick() async { @@ -244,7 +240,9 @@ class _ShopInBitCarResearchPaymentViewState _flowState == _PaymentFlowState.complete) { return; } - _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval); + _pollInterval = ok + ? _kBasePollInterval + : ShopInBitClient.nextPollBackoff(_pollInterval, _kMaxPollInterval); _scheduleNextPoll(); } diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index f3069828a1..2934a97572 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -10,6 +10,7 @@ import '../../app_config.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; +import '../../services/shopinbit/src/client.dart'; import '../../services/shopinbit/src/models/payment.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.dart'; @@ -132,11 +133,6 @@ class _ShopInBitPaymentViewState extends ConsumerState _pollTimer = Timer(_pollInterval, _pollPayment); } - Duration _nextBackoff(Duration current) { - final Duration next = current * 2; - return next > _kMaxPollInterval ? _kMaxPollInterval : next; - } - Future _pollPayment() async { bool ok = false; try { @@ -164,7 +160,9 @@ class _ShopInBitPaymentViewState extends ConsumerState } // Back off on failure (e.g. a 429), reset to base on success, so a rate // limit slows us down instead of getting hammered every 15s. - _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval); + _pollInterval = ok + ? _kBasePollInterval + : ShopInBitClient.nextPollBackoff(_pollInterval, _kMaxPollInterval); _scheduleNextPoll(); } diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 49fac0e57e..2918d5413c 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -9,6 +9,7 @@ import 'package:intl/intl.dart'; import '../../db/drift/shared_db/shared_database.dart'; import '../../models/shopinbit/shopinbit_enums.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../services/shopinbit/src/client.dart'; import '../../services/shopinbit/src/models/message.dart'; import '../../services/shopinbit/src/models/ticket.dart'; import '../../themes/stack_colors.dart'; @@ -111,15 +112,12 @@ class _ShopInBitTicketDetailState extends ConsumerState } // Back off on failure (e.g. a 429), reset on success. - _pollInterval = ok ? _kBasePollInterval : _nextBackoff(_pollInterval); + _pollInterval = ok + ? _kBasePollInterval + : ShopInBitClient.nextPollBackoff(_pollInterval, _kMaxPollInterval); _pollingTimer = Timer(_pollInterval, _poll); } - Duration _nextBackoff(Duration current) { - final Duration next = current * 2; - return next > _kMaxPollInterval ? _kMaxPollInterval : next; - } - void _startPolling() { _pollingTimer?.cancel(); _pollInterval = _kBasePollInterval; diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index 8eb3855b52..42981d10fc 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -632,6 +632,12 @@ class ShopInBitClient { } } + /// Next poll interval after a failed poll: double [current], capped at [max]. + static Duration nextPollBackoff(Duration current, Duration max) { + final Duration next = current * 2; + return next > max ? max : next; + } + /// How long to wait before retrying a 429. Prefers a sane `Retry-After` /// header; otherwise 1s, 2s, 4s... with jitter, capped at [_kMaxBackoff]. Duration _backoffDelay(int attempt, Map headers) { From 77461f130489dc2bb745f64e2cdebd9fba9a0b1a Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 12:38:17 -0500 Subject: [PATCH 63/90] fix(shopinbit): drop the by-customer car ticket fallback --- lib/db/drift/shared_db/shared_database.dart | 7 ---- .../shopinbit_car_research_payment_view.dart | 36 ++-------------- lib/services/shopinbit/shopinbit_service.dart | 41 ------------------- 3 files changed, 4 insertions(+), 80 deletions(-) diff --git a/lib/db/drift/shared_db/shared_database.dart b/lib/db/drift/shared_db/shared_database.dart index ec39151fd2..70a9acad37 100644 --- a/lib/db/drift/shared_db/shared_database.dart +++ b/lib/db/drift/shared_db/shared_database.dart @@ -80,13 +80,6 @@ class ShopInBitTicketsDao extends DatabaseAccessor )..where((t) => t.apiTicketId.equals(apiTicketId))).watchSingleOrNull(); } - Future> getByCustomerKey(String customerKey) { - return (select(shopInBitTickets) - ..where((t) => t.customerKey.equals(customerKey)) - ..orderBy([(t) => OrderingTerm.desc(t.createdAt)])) - .get(); - } - /// All tickets for the active customer key, newest first. Stream> watchByCustomerKey(String customerKey) { return (select(shopInBitTickets) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 2896158ca3..e882976a0c 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -56,10 +56,8 @@ class _ShopInBitCarResearchPaymentViewState String _statusString = "ready_to_pay"; String? _additional; bool _finalized = false; - // From the finalized status: the real ticket is the customer chat, the - // receipt is just the paid-fee receipt. + // The real car ticket id (the customer chat) from the finalized status. int? _realTicketId; - int? _receiptTicketId; List _methods = []; List _addresses = []; int _selectedMethod = 0; @@ -349,7 +347,6 @@ class _ShopInBitCarResearchPaymentViewState _additional = _status!.additional; _finalized = _status!.finalized; _realTicketId = _status!.realTicketId; - _receiptTicketId = _status!.receiptTicketId; }); if (_isTerminal) { _pollTimer?.cancel(); @@ -385,35 +382,10 @@ class _ShopInBitCarResearchPaymentViewState setState(() => _flowState = _PaymentFlowState.finalizing); _pollTimer?.cancel(); - final service = ref.read(pShopinBitService); - try { - // The finalized status usually gives us the real car ticket id (the - // customer chat), so open that. It can be null for a bit (sandbox, or - // while the ticket is still being created), so fall back to by-customer, - // using the receipt id to skip the receipt ticket. The BTCPay webhook - // creates the ticket either way. - int? realId = _realTicketId; - const int maxAttempts = 8; - for ( - int attempt = 0; - attempt < maxAttempts && realId == null; - attempt++ - ) { - // Re-poll the status first in case the real ticket id has appeared. - final statusResp = await service.client.getCarResearchInvoiceStatus( - widget.invoice.btcpayInvoice, - ); - realId = statusResp.value?.realTicketId; - // Fall back to the by-customer heuristic, excluding the receipt id. - realId ??= await service.adoptRealCarTicket( - statusResp.value?.receiptTicketId ?? _receiptTicketId ?? 0, - ); - if (realId == null && attempt < maxAttempts - 1) { - final int seconds = (1 << (attempt + 1)).clamp(2, 15).toInt(); - await Future.delayed(Duration(seconds: seconds)); - } - } + // The finalized status carries the real car ticket id (the customer + // chat), so open that. The BTCPay webhook creates the ticket regardless. + final int? realId = _realTicketId; if (!mounted) return; setState(() => _flowState = _PaymentFlowState.complete); diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 0061ecae2c..107da766c3 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -181,47 +181,6 @@ class ShopInBitService { return ref; } - /// Fallback for finding the real car research ticket when the status endpoint - /// hasn't populated real_ticket_id yet (sandbox, or briefly while the ticket - /// is being created). - /// - /// The fee receipt id can't be polled by the customer key (403s); the real - /// ticket is a separate id that shows up in by-customer. Grab the newest - /// ticket we don't already track (not the receipt), hydrate it, and return - /// its id, or null if it's not there yet. - Future adoptRealCarTicket(int receiptTicketId) async { - final String key = await ensureCustomerKey(); - final ApiResponse> resp = await _ticketsByCustomer(key); - if (resp.hasError || resp.value == null) return null; - - final Set known = (await db.shopInBitTicketsDao.getByCustomerKey( - key, - )).map((t) => t.apiTicketId).toSet(); - - final List candidates = - resp.value! - .where((t) => t.id != receiptTicketId && !known.contains(t.id)) - .toList() - ..sort((a, b) => b.id.compareTo(a.id)); - - // Newest first; the receipt 403s (no row written) so it gets skipped. - for (final TicketRef ref in candidates) { - try { - await _refreshRef(ref, key); - } catch (e, s) { - Logging.instance.w( - "Failed to refresh candidate ticket ${ref.id}, trying next", - error: e, - stackTrace: s, - ); - } - if (await db.shopInBitTicketsDao.getByApiId(ref.id) != null) { - return ref.id; - } - } - return null; - } - Future sendMessage(int apiTicketId, String message) async { final ApiResponse> resp = await client.sendMessage( apiTicketId, From f01dc8c3ea3b20f5c7989978873120b0a9e8f830 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 13:43:58 -0500 Subject: [PATCH 64/90] refactor(shopinbit): drop the single-flight ticket/invoice fetch wrappers --- lib/services/shopinbit/shopinbit_service.dart | 59 ++----------------- 1 file changed, 5 insertions(+), 54 deletions(-) diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 107da766c3..4a7fe797f5 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -22,60 +22,9 @@ class ShopInBitService { final Map> _inFlight = {}; - // Combine concurrent list/invoice fetches the same way _refreshRef does, so - // overlapping refreshes (e.g. tickets view refresh racing a post-action one) - // share a single round-trip instead of each hitting the API. - Completer>>? _ticketsInFlight; - String? _ticketsInFlightKey; - Completer>>? _carInvoicesInFlight; - - /// Combined by-customer ticket list fetch. Concurrent calls for the same - /// key await the same in-flight request. - Future>> _ticketsByCustomer(String key) { - final Completer>>? pending = _ticketsInFlight; - if (pending != null && _ticketsInFlightKey == key) { - return pending.future; - } - final Completer>> completer = Completer(); - _ticketsInFlight = completer; - _ticketsInFlightKey = key; - unawaited( - client - .getTicketsByCustomer(key) - .then(completer.complete, onError: completer.completeError) - .whenComplete(() { - if (_ticketsInFlight == completer) { - _ticketsInFlight = null; - _ticketsInFlightKey = null; - } - }), - ); - return completer.future; - } - - /// Combined wrapper around the current car research invoices fetch. The - /// tickets view calls this on every refresh, so dedup keeps overlapping - /// refreshes from each firing their own request. + /// Current still-payable car research invoices for the active customer key. Future>> - getCurrentCarResearchInvoices() { - final Completer>>? pending = - _carInvoicesInFlight; - if (pending != null) return pending.future; - final Completer>> completer = - Completer(); - _carInvoicesInFlight = completer; - unawaited( - client - .getCurrentCarResearchInvoices() - .then(completer.complete, onError: completer.completeError) - .whenComplete(() { - if (_carInvoicesInFlight == completer) { - _carInvoicesInFlight = null; - } - }), - ); - return completer.future; - } + getCurrentCarResearchInvoices() => client.getCurrentCarResearchInvoices(); // -- Customer key -- @@ -115,7 +64,9 @@ class ShopInBitService { /// New tickets are hydrated and inserted; existing tickets are patched. Future refreshAll() async { final String key = await ensureCustomerKey(); - final ApiResponse> resp = await _ticketsByCustomer(key); + final ApiResponse> resp = await client.getTicketsByCustomer( + key, + ); if (resp.hasError || resp.value == null) { Logging.instance.w( "ShopInBitService.refreshAll: failed to fetch ticket list", From b9b104fdf9d95c8cb5d99932955749ca68818385 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 10 Jun 2026 13:03:03 -0600 Subject: [PATCH 65/90] fix: race condition when refreshing all shopinbit tickets when not all tickets use the same customer key. Probably introduces bugs elsewhere now though... --- .../shopinbit/shopinbit_car_fee_view.dart | 19 ++- .../shopinbit_car_research_payment_view.dart | 13 +- .../shopinbit/shopinbit_payment_shared.dart | 16 +- .../shopinbit/shopinbit_payment_view.dart | 26 ++- .../shopinbit/shopinbit_shipping_view.dart | 13 +- .../shopinbit/shopinbit_ticket_detail.dart | 9 +- .../shopinbit/shopinbit_tickets_view.dart | 69 ++++---- lib/route_generator.dart | 7 +- lib/services/shopinbit/shopinbit_service.dart | 18 +-- lib/services/shopinbit/src/api_response.dart | 3 +- lib/services/shopinbit/src/client.dart | 149 +++++++++++------- ...sted_navigator_dialog_route_generator.dart | 7 +- 12 files changed, 231 insertions(+), 118 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 60ea3f4f6f..9bb863e723 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -202,7 +202,7 @@ class _ShopInBitCarFeeViewState extends ConsumerState { if (_submitting) return; setState(() => _submitting = true); try { - await ref.read(pShopinBitService).ensureCustomerKey(); + final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); // Delivery address (always provided) final deliveryName = _splitFullName(_nameController.text); @@ -242,7 +242,11 @@ class _ShopInBitCarFeeViewState extends ConsumerState { final resp = await ref .read(pShopinBitService) .client - .createCarResearchInvoice(billing: billing, request: request); + .createCarResearchInvoice( + billing: billing, + request: request, + customerKey: customerKey, + ); if (resp.hasError || resp.value == null) { Logging.instance.e( @@ -273,14 +277,14 @@ class _ShopInBitCarFeeViewState extends ConsumerState { // `GET /car-research/invoices/current` (see the requests list). // Best-effort fee fetch; do not block navigation on fee parse failure. - await _loadFee(invoice); + await _loadFee(invoice, customerKey); if (!mounted) return; unawaited( Navigator.of(context).pushNamed( ShopInBitCarResearchPaymentView.routeName, - arguments: invoice, + arguments: (invoice: invoice, customerKey: customerKey), ), ); } catch (e, s) { @@ -314,7 +318,7 @@ class _ShopInBitCarFeeViewState extends ConsumerState { } } - Future _loadFee(CarResearchInvoice invoice) async { + Future _loadFee(CarResearchInvoice invoice, String customerKey) async { // Keep status call for visibility into any future API changes surfacing // a fee field. Today the endpoint returns only {status, additional}, so // we source the displayed amount from the BIP21 payment URIs instead. @@ -322,7 +326,10 @@ class _ShopInBitCarFeeViewState extends ConsumerState { final resp = await ref .read(pShopinBitService) .client - .getCarResearchInvoiceStatus(invoice.btcpayInvoice); + .getCarResearchInvoiceStatus( + invoice.btcpayInvoice, + customerKey: customerKey, + ); if (resp.hasError || resp.value == null) { Logging.instance.i( "CarResearch status response (car_fee_view): error " diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index eb1137bac1..f8367ab338 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -30,11 +30,16 @@ import 'shopinbit_tickets_view.dart'; enum _PaymentFlowState { idle, polling, finalizing, complete, error } class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { - const ShopInBitCarResearchPaymentView({super.key, required this.invoice}); + const ShopInBitCarResearchPaymentView({ + super.key, + required this.invoice, + required this.customerKey, + }); static const String routeName = "/shopInBitCarResearchPayment"; final CarResearchInvoice invoice; + final String customerKey; @override ConsumerState createState() => @@ -221,7 +226,10 @@ class _ShopInBitCarResearchPaymentViewState final resp = await ref .read(pShopinBitService) .client - .getCarResearchInvoiceStatus(widget.invoice.btcpayInvoice); + .getCarResearchInvoiceStatus( + widget.invoice.btcpayInvoice, + customerKey: widget.customerKey, + ); if (resp.hasError || resp.value == null) { if (mounted) { unawaited( @@ -288,6 +296,7 @@ class _ShopInBitCarResearchPaymentViewState // and creates the receipt and real car ticket even if this call fails. final logResp = await client.logCarResearchPayment( widget.invoice.btcpayInvoice, + customerKey: widget.customerKey, ); if (logResp.hasError || logResp.value == null) { diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart index 93a6974bf8..af6974d340 100644 --- a/lib/pages/shopinbit/shopinbit_payment_shared.dart +++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart @@ -4,9 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; -import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; +import '../../services/shopinbit/src/client.dart'; import '../../services/shopinbit/src/models/payment.dart'; import '../../services/wallets.dart'; import '../../themes/stack_colors.dart'; @@ -229,18 +229,24 @@ Future tryNavigateToShopInBitWalletSend({ // recovery" guidance; PUT (which regenerates) only when GET shows none. // Returns null on any failure so the view can fall back to polling. Future fetchShopInBitPaymentInfo( - WidgetRef ref, + ShopInBitClient client, int apiTicketId, + String customerKey, ) async { try { - final client = ref.read(pShopinBitService).client; - final getResp = await client.getPayment(apiTicketId); + final getResp = await client.getPayment( + apiTicketId, + customerKey: customerKey, + ); if (!getResp.hasError && getResp.value != null && getResp.value!.paymentLinks.isNotEmpty) { return getResp.value; } - final putResp = await client.putPayment(apiTicketId); + final putResp = await client.putPayment( + apiTicketId, + customerKey: customerKey, + ); if (!putResp.hasError && putResp.value != null) { return putResp.value; } diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 97888d094d..2ad82376e0 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -74,6 +74,18 @@ class _ShopInBitPaymentViewState extends ConsumerState { bool get _payNowEnabled => !_isExpiredOrInvalid && !_isTerminal; + String? _customerKeyCache; + + Future get _customerKey async { + _customerKeyCache ??= + (await ref + .read(pSharedDrift) + .shopInBitTicketsDao + .getByApiId(widget.apiTicketId))! + .customerKey; + return _customerKeyCache!; + } + @override void initState() { super.initState(); @@ -111,7 +123,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { final resp = await ref .read(pShopinBitService) .client - .getPayment(widget.apiTicketId); + .getPayment(widget.apiTicketId, customerKey: await _customerKey); if (!resp.hasError && resp.value != null && mounted) { setState(() => _applyPaymentInfo(resp.value!)); if (_isTerminal) { @@ -123,11 +135,15 @@ class _ShopInBitPaymentViewState extends ConsumerState { Future _refreshInvoice() async { _pollTimer?.cancel(); + + final customerKey = await _customerKey; + if (!mounted) return; + final resp = await showLoading( whileFuture: ref .read(pShopinBitService) .client - .putPayment(widget.apiTicketId), + .putPayment(widget.apiTicketId, customerKey: customerKey), context: context, message: "Refreshing invoice", ); @@ -140,11 +156,15 @@ class _ShopInBitPaymentViewState extends ConsumerState { Future _checkForPayment() async { _pollTimer?.cancel(); + + final customerKey = await _customerKey; + if (!mounted) return; + final resp = await showLoading( whileFuture: ref .read(pShopinBitService) .client - .getPayment(widget.apiTicketId), + .getPayment(widget.apiTicketId, customerKey: customerKey), context: context, message: "Checking for payment", ); diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 1f3c4125e1..82914b8667 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/address.dart'; import '../../services/shopinbit/src/models/payment.dart'; @@ -195,6 +196,11 @@ class _ShopInBitShippingViewState extends ConsumerState { ); } + final thisTicket = await ref + .read(pSharedDrift) + .shopInBitTicketsDao + .getByApiId(widget.apiTicketId); + final resp = await ref .read(pShopinBitService) .client @@ -209,6 +215,7 @@ class _ShopInBitShippingViewState extends ConsumerState { country: country, ), billing: billingAddress, + customerKey: thisTicket!.customerKey, ); if (resp.hasError) { @@ -216,7 +223,11 @@ class _ShopInBitShippingViewState extends ConsumerState { debugPrint("submitAddress failed: ${resp.exception?.message}"); } - paymentInfo = await fetchShopInBitPaymentInfo(ref, widget.apiTicketId); + paymentInfo = await fetchShopInBitPaymentInfo( + ref.read(pShopinBitService).client, + widget.apiTicketId, + thisTicket.customerKey, + ); } catch (e) { debugPrint("submitAddress threw: $e"); } finally { diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 364407ad66..e3af19c273 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -8,6 +8,7 @@ import 'package:intl/intl.dart'; import '../../db/drift/shared_db/shared_database.dart'; import '../../models/shopinbit/shopinbit_enums.dart'; +import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/message.dart'; import '../../themes/stack_colors.dart'; @@ -97,7 +98,13 @@ class _ShopInBitTicketDetailState extends ConsumerState { _messageController.clear(); try { - final ok = await ref.read(pShopinBitService).sendMessage(_id, text); + final thisTicket = await ref + .read(pSharedDrift) + .shopInBitTicketsDao + .getByApiId(_id); + final ok = await ref + .read(pShopinBitService) + .sendMessage(_id, text, thisTicket!.customerKey); if (ok) { // Pull the server's copy into the DB row, then drop our optimistic one. await _refresh(); diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index 3f941d5d97..87c9927f88 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -38,10 +38,10 @@ class _ShopInBitTicketsViewState extends ConsumerState { bool _refreshing = false; bool _resuming = false; - // An unfinished car research fee invoice recovered from the server, if any. + // Some unfinished car research fee invoices recovered from the server, if any. // The fee is paid before any ticket exists, so this is the only way to let // the user resume it — there is no local "pending" row anymore. - CarResearchInvoice? _resumableInvoice; + List? _resumableInvoices; @override void initState() { @@ -65,12 +65,13 @@ class _ShopInBitTicketsViewState extends ConsumerState { /// Pull the most recent still-payable car research invoice from /// `GET /car-research/invoices/current` so we can surface a "resume" entry. Future _loadResumableInvoice() async { - CarResearchInvoice? resumable; + List? resumable; try { + final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); final resp = await ref .read(pShopinBitService) .client - .getCurrentCarResearchInvoices(); + .getCurrentCarResearchInvoices(customerKey: customerKey); final invoices = resp.value; if (invoices != null) { for (final inv in invoices) { @@ -80,10 +81,13 @@ class _ShopInBitTicketsViewState extends ConsumerState { (inv.expiresAt!.isAfter(DateTime.now()) || carResearchIsFinalized(inv.status, inv.additional)); if (payable) { - resumable = CarResearchInvoice( - btcpayInvoice: inv.invoiceId, - expiresAt: inv.expiresAt!, - paymentLinks: inv.paymentLinks, + resumable ??= []; + resumable.add( + CarResearchInvoice( + btcpayInvoice: inv.invoiceId, + expiresAt: inv.expiresAt!, + paymentLinks: inv.paymentLinks, + ), ); break; } @@ -98,17 +102,20 @@ class _ShopInBitTicketsViewState extends ConsumerState { // Leave _resumableInvoice unchanged on failure. return; } - if (mounted) setState(() => _resumableInvoice = resumable); + if (mounted) setState(() => _resumableInvoices = resumable); } Future _resumeFlow(CarResearchInvoice invoice) async { if (_resuming) return; setState(() => _resuming = true); try { - await Navigator.of(context).pushNamed( - ShopInBitCarResearchPaymentView.routeName, - arguments: invoice, - ); + final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); + if (mounted) { + await Navigator.of(context).pushNamed( + ShopInBitCarResearchPaymentView.routeName, + arguments: (invoice: invoice, customerKey: customerKey), + ); + } } finally { if (mounted) setState(() => _resuming = false); } @@ -118,7 +125,7 @@ class _ShopInBitTicketsViewState extends ConsumerState { required BuildContext context, required bool isDesktop, required List tickets, - required CarResearchInvoice? resumable, + required List? resumable, }) { if (resumable == null && tickets.isEmpty) { return [ @@ -136,20 +143,22 @@ class _ShopInBitTicketsViewState extends ConsumerState { final children = []; if (resumable != null) { - children.add( - RoundedContainer( - color: Theme.of(context).extension()!.popupBG, - onPressed: _resuming ? null : () => unawaited(_resumeFlow(resumable)), - child: _RequestRow( - title: "Car Research (In Progress)", - subtitle: _resuming - ? "Opening your car research payment..." - : "Tap to continue your car research payment", - badgeText: "Resume", - badgeColor: Theme.of( - context, - ).extension()!.accentColorYellow, - loading: _resuming, + children.addAll( + resumable.map( + (e) => RoundedContainer( + color: Theme.of(context).extension()!.popupBG, + onPressed: _resuming ? null : () => unawaited(_resumeFlow(e)), + child: _RequestRow( + title: "Car Research (In Progress)", + subtitle: _resuming + ? "Opening your car research payment..." + : "Tap to continue your car research payment", + badgeText: "Resume", + badgeColor: Theme.of( + context, + ).extension()!.accentColorYellow, + loading: _resuming, + ), ), ), ); @@ -192,7 +201,7 @@ class _ShopInBitTicketsViewState extends ConsumerState { final isDesktop = Util.isDesktop; final tickets = ref.watch(pShopInBitTickets).asData?.value ?? const []; - final resumable = _resumableInvoice; + final resumables = _resumableInvoices; return ConditionalParent( condition: isDesktop, @@ -272,7 +281,7 @@ class _ShopInBitTicketsViewState extends ConsumerState { context: context, isDesktop: isDesktop, tickets: tickets, - resumable: resumable, + resumable: resumables, ), ], ), diff --git a/lib/route_generator.dart b/lib/route_generator.dart index a685528412..cbe2a1134c 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -1242,10 +1242,13 @@ class RouteGenerator { return _routeError("${settings.name} invalid args: ${args.toString()}"); case ShopInBitCarResearchPaymentView.routeName: - if (args is CarResearchInvoice) { + if (args is ({CarResearchInvoice invoice, String customerKey})) { return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => ShopInBitCarResearchPaymentView(invoice: args), + builder: (_) => ShopInBitCarResearchPaymentView( + invoice: args.invoice, + customerKey: args.customerKey, + ), settings: RouteSettings(name: settings.name), ); } diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 1f33466a9c..fc6c72c173 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -29,7 +29,6 @@ class ShopInBitService { final ShopInBitSetting? current = await db.shopInBitSettingsDao .getCurrentSettings(); if (current != null) { - client.externalCustomerKey = current.customerKey; await db.shopInBitSettingsDao.touch(current.customerKey); return current.customerKey; } @@ -48,7 +47,6 @@ class ShopInBitService { /// settings. The UI filters tickets by the active key. Future useCustomerKey(String key) async { await db.shopInBitSettingsDao.upsert(key); - client.externalCustomerKey = key; return key; } @@ -120,10 +118,15 @@ class ShopInBitService { return ref; } - Future sendMessage(int apiTicketId, String message) async { + Future sendMessage( + int apiTicketId, + String message, + String customerKey, + ) async { final ApiResponse> resp = await client.sendMessage( apiTicketId, message, + customerKey: customerKey, ); if (resp.hasError) return false; unawaited(refreshOne(apiTicketId)); @@ -176,16 +179,13 @@ class ShopInBitService { return; } - // Ensure the client points at the right key for this ticket's calls. - client.externalCustomerKey = customerKey; - final ApiResponse fullResp; final ApiResponse statusResp; final ApiResponse> messagesResp; (fullResp, statusResp, messagesResp) = await ( - client.getTicketFull(id), - client.getTicketStatus(id), - client.getMessages(id), + client.getTicketFull(id, customerKey: customerKey), + client.getTicketStatus(id, customerKey: customerKey), + client.getMessages(id, customerKey: customerKey), ).wait; if (existing == null) { diff --git a/lib/services/shopinbit/src/api_response.dart b/lib/services/shopinbit/src/api_response.dart index a1e9135063..623afc34cf 100644 --- a/lib/services/shopinbit/src/api_response.dart +++ b/lib/services/shopinbit/src/api_response.dart @@ -3,8 +3,9 @@ import 'api_exception.dart'; class ApiResponse { final T? value; final ApiException? exception; + final String? customerKey; - ApiResponse({this.value, this.exception}); + ApiResponse({this.value, this.exception, this.customerKey}); bool get hasError => exception != null; diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index c939c5a584..80ee6dc688 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -27,10 +27,6 @@ class ShopInBitClient { final HTTP _httpClient; final TokenManager _tokenManager; - String? _externalCustomerKey; - - set externalCustomerKey(String? key) => _externalCustomerKey = key; - ShopInBitClient({ required this.accessKey, required this.partnerSecret, @@ -38,8 +34,7 @@ class ShopInBitClient { this.sandbox = false, String? externalCustomerKey, HTTP? httpClient, - }) : _externalCustomerKey = externalCustomerKey, - _httpClient = httpClient ?? const HTTP(), + }) : _httpClient = httpClient ?? const HTTP(), _tokenManager = TokenManager( accessKey: accessKey, partnerSecret: partnerSecret, @@ -66,7 +61,7 @@ class ShopInBitClient { return _request( 'GET', '/generate-key', - needsCustomerKey: false, + customerKey: null, parse: (json) { return json['external_customer_key'] as String; }, @@ -74,19 +69,14 @@ class ShopInBitClient { } Future>> getHealth() async { - return _request( - 'GET', - '/health', - needsCustomerKey: false, - parse: (json) => json, - ); + return _request('GET', '/health', customerKey: null, parse: (json) => json); } Future>>> getCountries() async { return _requestRaw( 'GET', '/meta/countries', - needsCustomerKey: false, + customerKey: null, needsAuth: false, parse: (body) { final decoded = jsonDecode(body); @@ -127,22 +117,31 @@ class ShopInBitClient { number: json['ticket_number'].toString(), ); }, + customerKey: externalCustomerKey, ); } - Future> getTicketStatus(int ticketId) async { + Future> getTicketStatus( + int ticketId, { + required String customerKey, + }) async { return _request( 'GET', '/tickets/$ticketId/status', parse: TicketStatus.fromJson, + customerKey: customerKey, ); } - Future> getTicketFull(int ticketId) async { + Future> getTicketFull( + int ticketId, { + required String customerKey, + }) async { return _request( 'GET', '/tickets/$ticketId/full', parse: TicketFull.fromJson, + customerKey: customerKey, ); } @@ -158,6 +157,7 @@ class ShopInBitClient { .map((e) => TicketRef.fromJson(e as Map)) .toList(); }, + customerKey: customerKey, ); } @@ -165,17 +165,22 @@ class ShopInBitClient { Future>> sendMessage( int ticketId, - String message, - ) async { + String message, { + required String customerKey, + }) async { return _request( 'POST', '/tickets/$ticketId/messages', body: {'message': message}, parse: (json) => json, + customerKey: customerKey, ); } - Future>> getMessages(int ticketId) async { + Future>> getMessages( + int ticketId, { + required String customerKey, + }) async { return _request( 'GET', '/tickets/$ticketId/messages', @@ -185,6 +190,7 @@ class ShopInBitClient { .map((e) => TicketMessage.fromJson(e as Map)) .toList(); }, + customerKey: customerKey, ); } @@ -194,12 +200,14 @@ class ShopInBitClient { int ticketId, { required String message, required List> attachments, + required String customerKey, }) async { return _request( 'POST', '/tickets/$ticketId/attachments', body: {'message': message, 'attachments': attachments}, parse: (json) => json, + customerKey: customerKey, ); } @@ -211,6 +219,7 @@ class ShopInBitClient { /// [useQueryAuth] = true to append token and customer_key as query params. Future> getAttachmentUrl( String attachmentPath, { + String? customerKey, bool useQueryAuth = false, }) async { try { @@ -221,8 +230,7 @@ class ShopInBitClient { uri = uri.replace( queryParameters: { 'token': token, - if (_externalCustomerKey != null) - 'customer_key': _externalCustomerKey!, + if (customerKey != null) 'customer_key': customerKey, }, ); } @@ -235,13 +243,16 @@ class ShopInBitClient { } /// Download an attachment from `/attachment-proxy/`. - Future> getAttachment(String attachmentPath) async { + Future> getAttachment( + String attachmentPath, { + String? customerKey, + }) async { try { final token = await _tokenManager.getValidToken(); final resolved = _resolvePath('/attachment-proxy/$attachmentPath'); final uri = Uri.parse('$baseUrl$resolved'); Logging.instance.t("$_kTag GET $uri"); - final headers = _headers(token); + final headers = _headers(token, customerKey: customerKey); final response = await _httpClient.get( url: uri, headers: headers, @@ -279,6 +290,7 @@ class ShopInBitClient { Future>> submitAddress( int ticketId, { required Address shipping, + required String customerKey, Address? billing, }) async { return _request( @@ -286,6 +298,7 @@ class ShopInBitClient { '/tickets/$ticketId/address', body: {'shipping': shipping.toJson(), 'billing': billing?.toJson()}, parse: (json) => json, + customerKey: customerKey, ); } @@ -295,11 +308,15 @@ class ShopInBitClient { /// and any view that just wants to show the current invoice; per ShopinBit /// 1.0.4 this endpoint is read-only and will not create or regenerate the /// invoice. Call [putPayment] for that. - Future> getPayment(int ticketId) async { + Future> getPayment( + int ticketId, { + required String customerKey, + }) async { return _request( 'GET', '/tickets/$ticketId/payment', parse: PaymentInfo.fromJson, + customerKey: customerKey, ); } @@ -308,23 +325,31 @@ class ShopInBitClient { /// shipping/billing, seen the Terms & Conditions, and explicitly clicked /// PAY NOW. Repeated calls regenerate the invoice and invalidate any in- /// flight payment. - Future> putPayment(int ticketId) async { + Future> putPayment( + int ticketId, { + required String customerKey, + }) async { return _request( 'PUT', '/tickets/$ticketId/payment', parse: PaymentInfo.fromJson, + customerKey: customerKey, ); } // -- Vouchers -- /// Pre-check a voucher code (does not consume usage or create a ticket). - Future> checkVoucher(String code) async { + Future> checkVoucher( + String code, { + required String customerKey, + }) async { return _request( 'GET', '/vouchers/validate', query: {'code': code}, parse: VoucherInfo.fromJson, + customerKey: customerKey, ); } @@ -334,6 +359,7 @@ class ShopInBitClient { required String customerPseudonym, required String serviceType, required String comment, + required String customerKey, String? deliveryCountry, }) async { return _request( @@ -347,6 +373,7 @@ class ShopInBitClient { if (deliveryCountry != null) 'delivery_country': deliveryCountry, }, parse: VipRedemptionResult.fromJson, + customerKey: customerKey, ); } @@ -354,6 +381,7 @@ class ShopInBitClient { Future> createCarResearchInvoice({ required Address billing, + required String customerKey, CarResearchRequest? request, }) async { return _request( @@ -362,17 +390,17 @@ class ShopInBitClient { body: { 'billing': billing.toJson(), if (request != null) 'request': request.toJson(), - if (_externalCustomerKey != null) - 'external_customer_key': _externalCustomerKey, + 'external_customer_key': customerKey, }, parse: CarResearchInvoice.fromJson, + customerKey: customerKey, ); } /// Unresolved car research invoices for the current partner/customer pair. /// Used to recover a fee payment the user started but did not finish. Future>> - getCurrentCarResearchInvoices() async { + getCurrentCarResearchInvoices({required String customerKey}) async { return _requestRaw( 'GET', '/car-research/invoices/current', @@ -390,31 +418,33 @@ class ShopInBitClient { ) .toList(); }, + customerKey: customerKey, ); } Future>> getCarResearchInvoiceStatus( - String invoiceId, - ) async { + String invoiceId, { + required String customerKey, + }) async { return _request( 'GET', '/car-research/invoice/$invoiceId/status', parse: (json) => json, + customerKey: customerKey, ); } Future> logCarResearchPayment( - String invoiceId, - ) async { + String invoiceId, { + + required String customerKey, + }) async { return _request( 'POST', '/car-research/log-payment', - body: { - 'invoice_id': invoiceId, - if (_externalCustomerKey != null) - 'external_customer_key': _externalCustomerKey, - }, + body: {'invoice_id': invoiceId, 'external_customer_key': customerKey}, parse: CarResearchPaymentResult.fromJson, + customerKey: customerKey, ); } @@ -428,6 +458,8 @@ class ShopInBitClient { String? environment, String? expirationTime, int? ticketId, + + required String customerKey, }) async { return _request( 'POST', @@ -442,6 +474,7 @@ class ShopInBitClient { if (ticketId != null) 'ticketId': ticketId, }, parse: (json) => json, + customerKey: customerKey, ); } @@ -451,7 +484,7 @@ class ShopInBitClient { return _request( 'GET', '/partners/webhooks', - needsCustomerKey: false, + customerKey: null, parse: (json) { if (json.containsKey('webhooks')) { return (json['webhooks'] as List) @@ -469,7 +502,7 @@ class ShopInBitClient { return _request( 'POST', '/partners/webhooks', - needsCustomerKey: false, + customerKey: null, body: {'webhook_url': webhookUrl, 'event_types': eventTypes}, parse: (json) => json, ); @@ -481,7 +514,7 @@ class ShopInBitClient { return _request( 'POST', '/partners/webhooks/$webhookId/rotate', - needsCustomerKey: false, + customerKey: null, parse: (json) => json, ); } @@ -490,7 +523,7 @@ class ShopInBitClient { return _request( 'DELETE', '/partners/webhooks/$webhookId', - needsCustomerKey: false, + customerKey: null, parse: (_) {}, ); } @@ -499,23 +532,27 @@ class ShopInBitClient { Future>> sandboxSetState( int ticketId, - String state, - ) async { + String state, { + required String customerKey, + }) async { return _request( 'POST', '/sandbox/state/$ticketId/$state', parse: (json) => json, + customerKey: customerKey, ); } Future>> sandboxSetPayment( int ticketId, - String status, - ) async { + String status, { + required String customerKey, + }) async { return _request( 'POST', '/sandbox/payment/$ticketId/$status', parse: (json) => json, + customerKey: customerKey, ); } @@ -542,14 +579,14 @@ class ShopInBitClient { return '/sandbox$path'; } - Map _headers(String token, {bool needsCustomerKey = true}) { + Map _headers(String token, {String? customerKey}) { final h = { 'Authorization': 'Bearer $token', 'Content-Type': 'application/json', 'Accept': 'application/json', }; - if (needsCustomerKey && _externalCustomerKey != null) { - h['External-Customer-Key'] = _externalCustomerKey!; + if (customerKey != null) { + h['External-Customer-Key'] = customerKey; } return h; } @@ -559,7 +596,7 @@ class ShopInBitClient { String path, { Map? body, Map? query, - bool needsCustomerKey = true, + required String? customerKey, bool needsAuth = true, }) async { final resolved = _resolvePath(path); @@ -570,7 +607,7 @@ class ShopInBitClient { final Map headers; if (needsAuth) { final token = await _tokenManager.getValidToken(); - headers = _headers(token, needsCustomerKey: needsCustomerKey); + headers = _headers(token, customerKey: customerKey); } else { headers = {'Accept': 'application/json'}; } @@ -632,7 +669,7 @@ class ShopInBitClient { String path, { Map? body, Map? query, - bool needsCustomerKey = true, + required String? customerKey, required T Function(Map) parse, }) async { try { @@ -641,7 +678,7 @@ class ShopInBitClient { path, body: body, query: query, - needsCustomerKey: needsCustomerKey, + customerKey: customerKey, ); final resolved = _resolvePath(path); @@ -652,7 +689,7 @@ class ShopInBitClient { return ApiResponse(value: parse({})); } final json = jsonDecode(response.body) as Map; - return ApiResponse(value: parse(json)); + return ApiResponse(value: parse(json), customerKey: customerKey); } else { Logging.instance.w( "$_kTag $method $resolved HTTP:${response.code} " @@ -682,7 +719,7 @@ class ShopInBitClient { String path, { Map? body, Map? query, - bool needsCustomerKey = true, + required String? customerKey, bool needsAuth = true, required T Function(String) parse, }) async { @@ -692,7 +729,7 @@ class ShopInBitClient { path, body: body, query: query, - needsCustomerKey: needsCustomerKey, + customerKey: customerKey, needsAuth: needsAuth, ); diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart index 74e272c779..eed491dc41 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -112,9 +112,12 @@ abstract final class NestedNavigatorDialogRouteGenerator { ); case ShopInBitCarResearchPaymentView.routeName: - if (args is CarResearchInvoice) { + if (args is ({CarResearchInvoice invoice, String customerKey})) { return getRoute( - builder: (_) => ShopInBitCarResearchPaymentView(invoice: args), + builder: (_) => ShopInBitCarResearchPaymentView( + invoice: args.invoice, + customerKey: args.customerKey, + ), settings: RouteSettings(name: settings.name), ); } From 716c6e33d004e9d848b484aa299df2e2d844cb35 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 14:15:01 -0500 Subject: [PATCH 66/90] chore(shopinbit): clean up merge lints --- lib/pages/shopinbit/shopinbit_shipping_view.dart | 2 +- lib/services/shopinbit/shopinbit_service.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 2c95511f4d..ef560cfb2f 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -227,7 +227,7 @@ class _ShopInBitShippingViewState extends ConsumerState { paymentInfo = await fetchShopInBitPaymentInfo( ref.read(pShopinBitService).client, widget.apiTicketId, - thisTicket!.customerKey, + thisTicket.customerKey, ); } catch (e, s) { Logging.instance.e("submitAddress threw", error: e, stackTrace: s); diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index e7b4c4264c..b40c1b3488 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -7,7 +7,6 @@ import "../../models/shopinbit/shopinbit_enums.dart"; import "../../utilities/logger.dart"; import "src/api_response.dart"; import "src/client.dart"; -import "src/models/car_research.dart"; import "src/models/message.dart"; import "src/models/ticket.dart"; From d1457a08cce5fb4624bf4b02b891dcd976db90cd Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 14:26:40 -0500 Subject: [PATCH 67/90] fix(shopinbit): regenerate expired invoice via PUT ?retry=true --- lib/pages/shopinbit/shopinbit_payment_view.dart | 6 +++++- lib/services/shopinbit/src/client.dart | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 43a5c99144..c6b5b4b97a 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -188,7 +188,11 @@ class _ShopInBitPaymentViewState extends ConsumerState whileFuture: ref .read(pShopinBitService) .client - .putPayment(widget.apiTicketId, customerKey: customerKey), + .putPayment( + widget.apiTicketId, + customerKey: customerKey, + retry: true, + ), context: context, message: "Refreshing invoice", ); diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index e5c15a9ecc..19915cb1ba 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -331,13 +331,17 @@ class ShopInBitClient { /// shipping/billing, seen the Terms & Conditions, and explicitly clicked /// PAY NOW. Repeated calls regenerate the invoice and invalidate any in- /// flight payment. + /// Create a payment invoice, or regenerate an expired/invalid one with + /// [retry] = true (spec: PUT ...?retry=true). Future> putPayment( int ticketId, { required String customerKey, + bool retry = false, }) async { return _request( 'PUT', '/tickets/$ticketId/payment', + query: retry ? const {'retry': 'true'} : null, parse: PaymentInfo.fromJson, customerKey: customerKey, ); From 85e8b39467c2c466ad8122200c3d0c85dc52a659 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 14:42:31 -0500 Subject: [PATCH 68/90] fix(shopinbit): re-authenticate once on HTTP 401 --- lib/services/shopinbit/src/client.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index 19915cb1ba..9bda1bc668 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -607,7 +607,7 @@ class ShopInBitClient { if (query != null && query.isNotEmpty) { uri = uri.replace(queryParameters: query); } - final Map headers; + Map headers; if (needsAuth) { final token = await _tokenManager.getValidToken(); headers = _headers(token, customerKey: customerKey); @@ -659,8 +659,21 @@ class ShopInBitClient { // present, otherwise exponential backoff with jitter. Everything funnels // through here, so all endpoints get this for free. int attempt = 0; + bool reauthed = false; while (true) { final response = await dispatch(); + // A 401 means the bearer token is stale/expired: invalidate it, + // re-authenticate once, and retry before surfacing the error. + if (response.code == 401 && needsAuth && !reauthed) { + reauthed = true; + _tokenManager.invalidate(); + final token = await _tokenManager.getValidToken(); + headers = _headers(token, customerKey: customerKey); + Logging.instance.w( + "$_kTag $method $resolved HTTP:401, re-authenticating", + ); + continue; + } if (response.code != 429 || attempt >= _kMaxRetries) { return response; } From c5f200158cc35aa24b7c4723ba01ef067c1db1a1 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 14:48:43 -0500 Subject: [PATCH 69/90] fix(shopinbit): recover car-research invoices within the +24h grace --- lib/pages/shopinbit/shopinbit_tickets_view.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index 87c9927f88..7b185f9ec9 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -78,7 +78,11 @@ class _ShopInBitTicketsViewState extends ConsumerState { final payable = inv.expiresAt != null && inv.paymentLinks.isNotEmpty && - (inv.expiresAt!.isAfter(DateTime.now()) || + // Spec: expired unresolved invoices stay recoverable until + // expires_at + 24h. + (inv.expiresAt! + .add(const Duration(hours: 24)) + .isAfter(DateTime.now()) || carResearchIsFinalized(inv.status, inv.additional)); if (payable) { resumable ??= []; From 0d54cd92422e03c791fdfb8ff5deae7fad80e9f5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 15:04:24 -0500 Subject: [PATCH 70/90] fix(shopinbit): treat an empty 2xx body as an error --- lib/services/shopinbit/src/client.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index 9bda1bc668..9d817dbcf1 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -764,7 +764,13 @@ class ShopInBitClient { if (response.code >= 200 && response.code < 300) { Logging.instance.t("$_kTag $method $resolved HTTP:${response.code}"); if (response.body.isEmpty) { - return ApiResponse(value: parse({})); + // An empty 2xx body would make object parsers fabricate placeholder + // objects (e.g. a ticket with id 0); surface it as an error instead. + return ApiResponse( + exception: ApiException( + "Empty response body for $method $resolved", + ), + ); } final json = jsonDecode(response.body) as Map; return ApiResponse(value: parse(json), customerKey: customerKey); From 1e820fd7a99405fe6bee33188bce38d4fe3bb146 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 15:17:15 -0500 Subject: [PATCH 71/90] fix(shopinbit): keep car-research expiresAt null on parse failure --- lib/services/shopinbit/src/models/car_research.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart index 8a8a9a7ebc..70e61b3b6c 100644 --- a/lib/services/shopinbit/src/models/car_research.dart +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -82,12 +82,12 @@ bool carResearchIsFinalized(String? status, String? additional) { class CarResearchInvoice { final String btcpayInvoice; - final DateTime expiresAt; + final DateTime? expiresAt; final Map paymentLinks; CarResearchInvoice({ required this.btcpayInvoice, - required this.expiresAt, + this.expiresAt, required this.paymentLinks, }); @@ -95,9 +95,9 @@ class CarResearchInvoice { final linksRaw = json['payment_links'] as Map? ?? {}; return CarResearchInvoice( btcpayInvoice: json['btcpay_invoice'] as String, - expiresAt: - DateTime.tryParse(json['expires_at']?.toString() ?? '') ?? - DateTime.now(), + // Null rather than defaulting to now(): a missing/garbled date should + // not make a fresh invoice look already-expired. + expiresAt: DateTime.tryParse(json['expires_at']?.toString() ?? ''), paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), ); } From 860bd1abcc975332b11a1d8d3df9554144bd87ac Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 16:51:27 -0500 Subject: [PATCH 72/90] fix(shopinbit): surface parse errors for required ticket fields and cleaning --- lib/services/shopinbit/src/models/ticket.dart | 36 +++++++------------ .../shopinbit/src/models/voucher.dart | 2 +- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 63ad123d6f..174b6acc03 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -14,10 +14,6 @@ enum TicketState { closed('CLOSED'), closedCancelled('CLOSED/CANCELLED'), merged('MERGED'), - // Sentinel for any state string the API returns that this client does not - // recognise (e.g. the API added a new state, or renamed an existing one). - // Callers must handle this explicitly: treat as "do not trust", do not - // overwrite previously known good state with it. unknown('UNKNOWN'); final String value; @@ -51,10 +47,7 @@ class TicketRef { TicketRef({required this.id, required this.number}); factory TicketRef.fromJson(Map json) { - return TicketRef( - id: _toInt(json['id']), - number: json['number']?.toString() ?? '', - ); + return TicketRef(id: _toInt(json['id']), number: json['number'] as String); } Map toMap() { @@ -68,9 +61,6 @@ class TicketRef { class TicketStatus { final int ticketId; final TicketState state; - // The raw 'state' string returned by the API. Preserved verbatim so that - // unknown / renamed states can be re-derived later via a client update, - // rather than being lost to TicketState.unknown. final String stateRaw; final DateTime updatedAt; final DateTime? lastAgentMessageAt; @@ -88,17 +78,15 @@ class TicketStatus { }); factory TicketStatus.fromJson(Map json) { - final rawState = (json['state'] ?? '') as String; + final rawState = json['state'] as String; return TicketStatus( ticketId: _toInt(json['ticket_id']), state: TicketState.fromString(rawState), stateRaw: rawState, - updatedAt: - DateTime.tryParse(json['updated_at']?.toString() ?? '') ?? - DateTime.now(), - lastAgentMessageAt: DateTime.tryParse( - json['last_agent_message_at']?.toString() ?? '', - ), + updatedAt: DateTime.parse(json['updated_at'] as String), + lastAgentMessageAt: json['last_agent_message_at'] != null + ? DateTime.parse(json['last_agent_message_at'] as String) + : null, paymentInvoiceStatus: json['payment_invoice_status'] as String?, trackingLink: json['tracking_link'] as String?, ); @@ -147,7 +135,7 @@ class TicketFull { factory TicketFull.fromJson(Map json) { return TicketFull( id: _toInt(json['id']), - number: json['number']?.toString() ?? '', + number: json['number'] as String, productName: json['product_name'] as String?, customerPrice: json['customer_price'] as String?, partnerPrice: json['partner_price'] as String?, @@ -155,9 +143,7 @@ class TicketFull { netPurchasePrice: json['net_purchase_price'] as String?, netShippingCosts: json['net_shipping_costs'] as String?, deliveryCountry: - json['delivery_country'] as String? ?? - json['deliverycountry'] as String? ?? - '', + (json['delivery_country'] ?? json['deliverycountry']) as String, vatRate: int.tryParse(json['vat_rate'].toString()), ); } @@ -183,5 +169,9 @@ class TicketFull { int _toInt(dynamic value) { if (value is int) return value; - return int.tryParse(value.toString()) ?? 0; + final parsed = int.tryParse(value.toString()); + if (parsed == null) { + throw FormatException("ShopInBit: expected an integer, got '$value'"); + } + return parsed; } diff --git a/lib/services/shopinbit/src/models/voucher.dart b/lib/services/shopinbit/src/models/voucher.dart index e65b30420b..97d048a8b4 100644 --- a/lib/services/shopinbit/src/models/voucher.dart +++ b/lib/services/shopinbit/src/models/voucher.dart @@ -62,7 +62,7 @@ class VipRedemptionResult { return VipRedemptionResult( ticketId: json['ticket_id'] is int ? json['ticket_id'] as int - : int.tryParse(json['ticket_id'].toString()) ?? 0, + : int.parse(json['ticket_id'].toString()), ticketNumber: json['ticket_number'] as String, externalCustomerKey: json['external_customer_key'] as String, voucherCode: json['voucher_code'] as String, From 8e33585b5e9c2adcf6a1bdec317f60457edddb30 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 16:57:26 -0500 Subject: [PATCH 73/90] fix(shopinbit): require remaining required fields across models --- lib/services/shopinbit/src/client.dart | 2 +- lib/services/shopinbit/src/models/car_research.dart | 8 ++++---- lib/services/shopinbit/src/models/message.dart | 8 +++----- lib/services/shopinbit/src/models/payment.dart | 8 ++++---- lib/services/shopinbit/src/models/voucher.dart | 2 +- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index 9d817dbcf1..c475cfec2c 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -120,7 +120,7 @@ class ShopInBitClient { id: json['ticket_id'] is int ? json['ticket_id'] as int : int.parse(json['ticket_id'].toString()), - number: json['ticket_number'].toString(), + number: json['ticket_number'] as String, ); }, customerKey: externalCustomerKey, diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart index 70e61b3b6c..895b1f14f2 100644 --- a/lib/services/shopinbit/src/models/car_research.dart +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -47,11 +47,11 @@ class CarResearchCurrentInvoice { final createdRaw = json['created_at'] as String?; return CarResearchCurrentInvoice( invoiceId: json['invoice_id'] as String, - status: json['status'] as String? ?? '', + status: json['status'] as String, additional: json['additional'] as String?, expiresAt: expiresRaw == null ? null : DateTime.tryParse(expiresRaw), paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), - hasRequestPayload: json['has_request_payload'] as bool? ?? false, + hasRequestPayload: json['has_request_payload'] as bool, createdAt: createdRaw == null ? null : DateTime.tryParse(createdRaw), ); } @@ -145,9 +145,9 @@ class CarResearchInvoiceStatus { } return CarResearchInvoiceStatus( - status: json['status']?.toString() ?? '', + status: json['status'] as String, additional: json['additional']?.toString(), - finalized: json['finalized'] == true, + finalized: json['finalized'] as bool, receiptTicketId: toIntOrNull(json['receipt_ticket_id']), receiptTicketNumber: json['receipt_ticket_number']?.toString(), realTicketId: toIntOrNull(json['real_ticket_id']), diff --git a/lib/services/shopinbit/src/models/message.dart b/lib/services/shopinbit/src/models/message.dart index 85c1ffabea..1341322598 100644 --- a/lib/services/shopinbit/src/models/message.dart +++ b/lib/services/shopinbit/src/models/message.dart @@ -11,11 +11,9 @@ class TicketMessage { factory TicketMessage.fromJson(Map json) { return TicketMessage( - timestamp: - DateTime.tryParse(json['timestamp']?.toString() ?? '') ?? - DateTime.now(), - fromAgent: json['from_agent'] as bool? ?? false, - content: json['content'] as String? ?? '', + timestamp: DateTime.parse(json['timestamp'] as String), + fromAgent: json['from_agent'] as bool, + content: json['content'] as String, ); } diff --git a/lib/services/shopinbit/src/models/payment.dart b/lib/services/shopinbit/src/models/payment.dart index 0633257663..05c8648bca 100644 --- a/lib/services/shopinbit/src/models/payment.dart +++ b/lib/services/shopinbit/src/models/payment.dart @@ -22,11 +22,11 @@ class PaymentInfo { factory PaymentInfo.fromJson(Map json) { final linksRaw = json['payment_links'] as Map? ?? {}; return PaymentInfo( - status: (json['status'] ?? '') as String, - customerPrice: (json['customer_price'] ?? '') as String, - partnerPrice: (json['partner_price'] ?? '') as String, + status: json['status'] as String, + customerPrice: json['customer_price'] as String, + partnerPrice: json['partner_price'] as String, vatRate: int.tryParse(json['vat_rate'].toString()), - currency: (json['currency'] ?? 'EUR') as String, + currency: json['currency'] as String, rateLockedUntil: DateTime.tryParse( json['rate_locked_until']?.toString() ?? '', ), diff --git a/lib/services/shopinbit/src/models/voucher.dart b/lib/services/shopinbit/src/models/voucher.dart index 97d048a8b4..fa7e9a47eb 100644 --- a/lib/services/shopinbit/src/models/voucher.dart +++ b/lib/services/shopinbit/src/models/voucher.dart @@ -29,7 +29,7 @@ class VoucherInfo { factory VoucherInfo.fromJson(Map json) { return VoucherInfo( - valid: json['valid'] as bool? ?? false, + valid: json['valid'] as bool, voucherCode: json['voucher_code'] as String?, discountAmount: (json['discount_amount'] as num?)?.toDouble(), voucherType: json['voucher_type'] as String?, From e61d8400f63f6d8a4e01831b171b29ac1b9e1078 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 20:18:35 -0500 Subject: [PATCH 74/90] chore(shopinbit): address review on car-research finalize --- .../shopinbit/shopinbit_car_research_payment_view.dart | 3 +-- lib/services/shopinbit/src/models/ticket.dart | 6 +----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 808884fb69..0e7751840e 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -388,14 +388,13 @@ class _ShopInBitCarResearchPaymentViewState } setState(() => _flowState = _PaymentFlowState.finalizing); - _pollTimer?.cancel(); try { // The finalized status carries the real car ticket id (the customer // chat), so open that. The BTCPay webhook creates the ticket regardless. + // The caller (_pollStatus) cancels the poll timer before calling this. final int? realId = _realTicketId; - if (!mounted) return; setState(() => _flowState = _PaymentFlowState.complete); if (realId != null) { diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 174b6acc03..0a3a386fa0 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -169,9 +169,5 @@ class TicketFull { int _toInt(dynamic value) { if (value is int) return value; - final parsed = int.tryParse(value.toString()); - if (parsed == null) { - throw FormatException("ShopInBit: expected an integer, got '$value'"); - } - return parsed; + return int.parse(value.toString()); } From 0119c48fb5764e086940bf3e2884945df3b4ef52 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 10 Jun 2026 21:17:37 -0500 Subject: [PATCH 75/90] fix(shopinbit): handle no_payment_required as fully covered --- .../shopinbit/shopinbit_payment_view.dart | 47 ++++++++++++++++++- .../shopinbit/shopinbit_shipping_view.dart | 6 ++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index c6b5b4b97a..ee51597c63 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -76,6 +76,9 @@ class _ShopInBitPaymentViewState extends ConsumerState bool get _isExpiredOrInvalid => _status == 'expired' || _status == 'invalid'; + // Voucher/credit fully covers the amount: no wallet options, nothing to pay. + bool get _isNoPaymentRequired => _status == 'no_payment_required'; + bool get _isTerminal => const { 'paid', 'paid_over', @@ -83,7 +86,8 @@ class _ShopInBitPaymentViewState extends ConsumerState 'payment_processing', }.contains(_status); - bool get _payNowEnabled => !_isExpiredOrInvalid && !_isTerminal; + bool get _payNowEnabled => + !_isExpiredOrInvalid && !_isTerminal && !_isNoPaymentRequired; String? _customerKeyCache; @@ -673,9 +677,48 @@ class _ShopInBitPaymentViewState extends ConsumerState onPressed: _canReturnToRequest ? _backToRequest : _goToMyRequests, ), ], + if (_isNoPaymentRequired) ...[ + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.checkCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "No payment required. Your order is fully covered.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + ), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + PrimaryButton( + label: _canReturnToRequest ? "Back to Request" : "View My Requests", + onPressed: _canReturnToRequest ? _backToRequest : _goToMyRequests, + ), + ], SizedBox(height: isDesktop ? 24 : 16), // Coin list (replaces tab selector + QR + address + global button) - if (!_isExpiredOrInvalid) ...coinRows, + if (!_isExpiredOrInvalid && !_isNoPaymentRequired) ...coinRows, ], ); diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index ef560cfb2f..c7419efaa7 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -237,7 +237,11 @@ class _ShopInBitShippingViewState extends ConsumerState { if (!mounted) return; - if (paymentInfo == null || paymentInfo.paymentLinks.isEmpty) { + // no_payment_required legitimately has empty payment_links (voucher/credit + // covers it): open the payment view, which shows a "covered" state. + if (paymentInfo == null || + (paymentInfo.paymentLinks.isEmpty && + paymentInfo.status != 'no_payment_required')) { // No live invoice; don't open a payment view with empty addresses. await _showPaymentLoadError( "We couldn't load the payment details for this order. " From 6b7d9bcb1a17380b9e63093864ab1c5fe080b122 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Jun 2026 09:48:12 -0500 Subject: [PATCH 76/90] fix(shopinbit): only mark car research complete once the ticket exists --- .../shopinbit_car_research_payment_view.dart | 57 +++++-------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 0e7751840e..03c47cc1c0 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -28,7 +28,7 @@ import 'shopinbit_order_created.dart'; import 'shopinbit_payment_shared.dart'; import 'shopinbit_tickets_view.dart'; -enum _PaymentFlowState { idle, polling, finalizing, complete, error } +enum _PaymentFlowState { idle, polling, finalizing, complete } class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { const ShopInBitCarResearchPaymentView({ @@ -382,52 +382,23 @@ class _ShopInBitCarResearchPaymentViewState Future _finalizePayment() async { if (_flowState == _PaymentFlowState.finalizing || - _flowState == _PaymentFlowState.complete || - _flowState == _PaymentFlowState.error) { + _flowState == _PaymentFlowState.complete) { return; } - setState(() => _flowState = _PaymentFlowState.finalizing); - - try { - // The finalized status carries the real car ticket id (the customer - // chat), so open that. The BTCPay webhook creates the ticket regardless. - // The caller (_pollStatus) cancels the poll timer before calling this. - final int? realId = _realTicketId; - - setState(() => _flowState = _PaymentFlowState.complete); - - if (realId != null) { - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: realId), - ); - } else { - // The real ticket hasn't surfaced yet; offer a shortcut to the - // requests list, which will pick it up on its next refresh. - await _showFinalizingFallback(); - } - } catch (e, s) { - Logging.instance.e( - "Failed to process car research payment", - error: e, - stackTrace: s, - ); - if (mounted) { - setState(() => _flowState = _PaymentFlowState.error); - await showDialog( - context: context, - useRootNavigator: Util.isDesktop, - builder: (context) => StackOkDialog( - title: "Failed to process car research payment", - maxWidth: Util.isDesktop ? 500 : null, - message: e.toString(), - desktopPopRootNavigator: Util.isDesktop, - ), - ); - } + final int? realId = _realTicketId; + if (realId == null) { + setState(() => _flowState = _PaymentFlowState.finalizing); + await _showFinalizingFallback(); + return; } + + setState(() => _flowState = _PaymentFlowState.complete); + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: realId), + ); } void _copyAddress(BuildContext context) { From adc937fbb1b3638d61dd7a26d75e1008e50dc713 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Jun 2026 10:25:22 -0500 Subject: [PATCH 77/90] docs(cakepay): drop misleading comment on refreshAll ignore --- lib/pages/cakepay/cakepay_orders_view.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index f844c22bb6..097c6c32e0 100644 --- a/lib/pages/cakepay/cakepay_orders_view.dart +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -31,8 +31,6 @@ class _CakePayOrdersViewState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - // Fire-and-forget: refreshAll logs and propagates its own errors, so - // ignore the returned future rather than leaving it unhandled. ref.read(pCakePayOrdersService).refreshAll().ignore(); }); } From 65542e9e391690a20e178b1ffebcdffafba85421 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Jun 2026 10:38:04 -0500 Subject: [PATCH 78/90] fix(cakepay): log refreshAll errors without propagating them --- lib/pages/cakepay/cakepay_orders_view.dart | 2 +- lib/services/cakepay/cakepay_orders_service.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index 097c6c32e0..990f43cdb7 100644 --- a/lib/pages/cakepay/cakepay_orders_view.dart +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -31,7 +31,7 @@ class _CakePayOrdersViewState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ref.read(pCakePayOrdersService).refreshAll().ignore(); + ref.read(pCakePayOrdersService).refreshAll(); }); } diff --git a/lib/services/cakepay/cakepay_orders_service.dart b/lib/services/cakepay/cakepay_orders_service.dart index 7676caa02b..8d5ad8de71 100644 --- a/lib/services/cakepay/cakepay_orders_service.dart +++ b/lib/services/cakepay/cakepay_orders_service.dart @@ -87,7 +87,7 @@ class CakePayOrdersService extends ChangeNotifier { error: e, stackTrace: s, ); - completer.completeError(e, s); + completer.complete(); } finally { _refreshAllCompleter = null; notifyListeners(); From a201981fab30e89699435842d0cf5696347b1166 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Jun 2026 10:58:47 -0500 Subject: [PATCH 79/90] fix(cakepay): show a flushbar when pull-to-refresh fails --- lib/pages/cakepay/cakepay_orders_view.dart | 20 +++++++++++++++++-- .../cakepay/cakepay_orders_service.dart | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index 990f43cdb7..4a1e39ae12 100644 --- a/lib/pages/cakepay/cakepay_orders_view.dart +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../notifications/show_flush_bar.dart'; import '../../providers/global/cakepay_orders_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -31,7 +34,7 @@ class _CakePayOrdersViewState extends ConsumerState { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ref.read(pCakePayOrdersService).refreshAll(); + ref.read(pCakePayOrdersService).refreshAll().ignore(); }); } @@ -152,7 +155,20 @@ class _CakePayOrdersViewState extends ConsumerState { } } - Future onRefresh() => ref.read(pCakePayOrdersService).refreshAll(); + Future onRefresh() async { + try { + await ref.read(pCakePayOrdersService).refreshAll(); + } catch (_) { + if (!context.mounted) return; + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not refresh orders", + context: context, + ), + ); + } + } final body = RefreshControl( onRefresh: onRefresh, diff --git a/lib/services/cakepay/cakepay_orders_service.dart b/lib/services/cakepay/cakepay_orders_service.dart index 8d5ad8de71..7676caa02b 100644 --- a/lib/services/cakepay/cakepay_orders_service.dart +++ b/lib/services/cakepay/cakepay_orders_service.dart @@ -87,7 +87,7 @@ class CakePayOrdersService extends ChangeNotifier { error: e, stackTrace: s, ); - completer.complete(); + completer.completeError(e, s); } finally { _refreshAllCompleter = null; notifyListeners(); From 496d998e5fd109f6cbcfc70ce2e06d7ddf098b06 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 11 Jun 2026 10:24:35 -0600 Subject: [PATCH 80/90] fix: always show/log error in orders view and ensure its propagated from refreshAll --- lib/pages/cakepay/cakepay_orders_view.dart | 44 +++++++++++-------- .../cakepay/cakepay_orders_service.dart | 8 +--- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index 4a1e39ae12..2db794293c 100644 --- a/lib/pages/cakepay/cakepay_orders_view.dart +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -8,6 +8,7 @@ import '../../notifications/show_flush_bar.dart'; import '../../providers/global/cakepay_orders_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; +import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -29,12 +30,34 @@ class CakePayOrdersView extends ConsumerStatefulWidget { } class _CakePayOrdersViewState extends ConsumerState { + Future _refresh() async { + try { + await ref.read(pCakePayOrdersService).refreshAll(); + } catch (e, s) { + Logging.instance.e( + "$runtimeType._refresh failed", + error: e, + stackTrace: s, + ); + + if (!mounted) return; + + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not refresh orders", + context: context, + ), + ); + } + } + @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - ref.read(pCakePayOrdersService).refreshAll().ignore(); + unawaited(_refresh()); }); } @@ -155,23 +178,8 @@ class _CakePayOrdersViewState extends ConsumerState { } } - Future onRefresh() async { - try { - await ref.read(pCakePayOrdersService).refreshAll(); - } catch (_) { - if (!context.mounted) return; - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Could not refresh orders", - context: context, - ), - ); - } - } - final body = RefreshControl( - onRefresh: onRefresh, + onRefresh: _refresh, child: ListView( shrinkWrap: true, physics: const AlwaysScrollableScrollPhysics(), @@ -204,7 +212,7 @@ class _CakePayOrdersViewState extends ConsumerState { children: [ RefreshButton( isRefreshing: isRefreshing, - onPressed: onRefresh, + onPressed: _refresh, ), const SizedBox(width: 8), const DesktopDialogCloseButton(), diff --git a/lib/services/cakepay/cakepay_orders_service.dart b/lib/services/cakepay/cakepay_orders_service.dart index 7676caa02b..fd9083bd46 100644 --- a/lib/services/cakepay/cakepay_orders_service.dart +++ b/lib/services/cakepay/cakepay_orders_service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import '../../utilities/logger.dart'; import 'cakepay_service.dart'; import 'src/models/order.dart'; @@ -67,7 +66,7 @@ class CakePayOrdersService extends ChangeNotifier { /// Fetch every locally-tracked order in parallel. Returns the existing /// future if a refresh-all is already in flight, so awaiters can be sure a - /// refresh has actually occurred rather than no-opping. + /// refresh has actually occurred. Future refreshAll() async { final Completer? pending = _refreshAllCompleter; if (pending != null) return pending.future; @@ -82,11 +81,6 @@ class CakePayOrdersService extends ChangeNotifier { await Future.wait(ids.map(refreshOne)); completer.complete(); } catch (e, s) { - Logging.instance.e( - "CakePayOrdersService.refreshAll failed", - error: e, - stackTrace: s, - ); completer.completeError(e, s); } finally { _refreshAllCompleter = null; From d541d20ab1ce3ded87d8fba1381c6a04eb62cfa7 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Jun 2026 11:10:38 -0500 Subject: [PATCH 81/90] fix(shopinbit): remove pointless mounted check and verbose comments --- .../shopinbit/shopinbit_car_research_payment_view.dart | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 03c47cc1c0..4232d432b5 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -265,9 +265,6 @@ class _ShopInBitCarResearchPaymentViewState }); } - /// Pop the car payment flow and land the user directly on the requests list, - /// pushing it only if it isn't already in the stack (e.g. the resume flow - /// entered from there). void _goToMyRequests() { final navigator = Navigator.of(context); bool landedOnTickets = false; @@ -284,11 +281,7 @@ class _ShopInBitCarResearchPaymentViewState } } - /// Shown when the real car ticket hasn't surfaced in time. Keeps the user - /// informed but offers a one-tap shortcut straight to My Requests rather - /// than making them dismiss and navigate there by hand. Future _showFinalizingFallback() async { - if (!mounted) return; final goToRequests = await showDialog( context: context, useRootNavigator: Util.isDesktop, From 2f8328e4ddc53e5d762ac9bf7823e45647bef131 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 11 Jun 2026 11:31:57 -0500 Subject: [PATCH 82/90] fix(shopinbit): remove unused customerKey field from ApiResponse --- lib/services/shopinbit/src/api_response.dart | 3 +-- lib/services/shopinbit/src/client.dart | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/services/shopinbit/src/api_response.dart b/lib/services/shopinbit/src/api_response.dart index 623afc34cf..a1e9135063 100644 --- a/lib/services/shopinbit/src/api_response.dart +++ b/lib/services/shopinbit/src/api_response.dart @@ -3,9 +3,8 @@ import 'api_exception.dart'; class ApiResponse { final T? value; final ApiException? exception; - final String? customerKey; - ApiResponse({this.value, this.exception, this.customerKey}); + ApiResponse({this.value, this.exception}); bool get hasError => exception != null; diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index c475cfec2c..f5c46c389b 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -773,7 +773,7 @@ class ShopInBitClient { ); } final json = jsonDecode(response.body) as Map; - return ApiResponse(value: parse(json), customerKey: customerKey); + return ApiResponse(value: parse(json)); } else { Logging.instance.w( "$_kTag $method $resolved HTTP:${response.code} " From d3aa32e6eefb219b5081d6da77b08e1da7e891bd Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 11 Jun 2026 12:29:06 -0600 Subject: [PATCH 83/90] chore: ensure expected field parsing fails ungracefully --- .../shopinbit/src/models/car_research.dart | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart index 895b1f14f2..99501ef74f 100644 --- a/lib/services/shopinbit/src/models/car_research.dart +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -43,16 +43,16 @@ class CarResearchCurrentInvoice { factory CarResearchCurrentInvoice.fromJson(Map json) { final linksRaw = json['payment_links'] as Map? ?? {}; - final expiresRaw = json['expires_at'] as String?; - final createdRaw = json['created_at'] as String?; + final expiresRaw = json['expires_at'] as String; + final createdRaw = json['created_at'] as String; return CarResearchCurrentInvoice( invoiceId: json['invoice_id'] as String, status: json['status'] as String, additional: json['additional'] as String?, - expiresAt: expiresRaw == null ? null : DateTime.tryParse(expiresRaw), + expiresAt: DateTime.parse(expiresRaw), paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), hasRequestPayload: json['has_request_payload'] as bool, - createdAt: createdRaw == null ? null : DateTime.tryParse(createdRaw), + createdAt: DateTime.parse(createdRaw), ); } } @@ -82,12 +82,12 @@ bool carResearchIsFinalized(String? status, String? additional) { class CarResearchInvoice { final String btcpayInvoice; - final DateTime? expiresAt; + final DateTime expiresAt; final Map paymentLinks; CarResearchInvoice({ required this.btcpayInvoice, - this.expiresAt, + required this.expiresAt, required this.paymentLinks, }); @@ -95,9 +95,7 @@ class CarResearchInvoice { final linksRaw = json['payment_links'] as Map? ?? {}; return CarResearchInvoice( btcpayInvoice: json['btcpay_invoice'] as String, - // Null rather than defaulting to now(): a missing/garbled date should - // not make a fresh invoice look already-expired. - expiresAt: DateTime.tryParse(json['expires_at']?.toString() ?? ''), + expiresAt: DateTime.parse(json['expires_at'] as String), paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), ); } @@ -123,7 +121,7 @@ class CarResearchInvoiceStatus { final String? receiptTicketNumber; final int? realTicketId; final String? realTicketNumber; - final String? externalCustomerKey; + final String externalCustomerKey; CarResearchInvoiceStatus({ required this.status, @@ -133,26 +131,35 @@ class CarResearchInvoiceStatus { this.receiptTicketNumber, this.realTicketId, this.realTicketNumber, - this.externalCustomerKey, + required this.externalCustomerKey, }); factory CarResearchInvoiceStatus.fromJson(Map json) { - int? toIntOrNull(dynamic v) { - if (v == null) return null; - if (v is int) return v; - if (v is double) return v.toInt(); - return int.tryParse(v.toString()); - } - return CarResearchInvoiceStatus( status: json['status'] as String, additional: json['additional']?.toString(), finalized: json['finalized'] as bool, - receiptTicketId: toIntOrNull(json['receipt_ticket_id']), - receiptTicketNumber: json['receipt_ticket_number']?.toString(), - realTicketId: toIntOrNull(json['real_ticket_id']), - realTicketNumber: json['real_ticket_number']?.toString(), - externalCustomerKey: json['external_customer_key']?.toString(), + receiptTicketId: json['receipt_ticket_id'] as int?, + receiptTicketNumber: json['receipt_ticket_number'] as String?, + realTicketId: json['real_ticket_id'] as int?, + realTicketNumber: json['real_ticket_number'] as String?, + externalCustomerKey: json['external_customer_key'] as String, ); } + + Map toMap() { + return { + "status": status, + "additional": additional, + "finalized": finalized, + "receipt_ticket_id": receiptTicketId, + "receipt_ticket_number": receiptTicketNumber, + "real_ticket_id": realTicketId, + "real_ticket_number": realTicketNumber, + "external_customer_key": externalCustomerKey, + }; + } + + @override + String toString() => toMap().toString(); } From a1ccc0cd96b5a5d881ed6fb0d1e1b354cbf3f906 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 11 Jun 2026 13:49:46 -0600 Subject: [PATCH 84/90] fix: ensure ticket gets stored on car invoice polling ticket created/found --- .../shopinbit_car_research_payment_view.dart | 68 +++++++++++++++++-- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 4232d432b5..1978f3ef81 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; +import '../../db/drift/shared_db/shared_database.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; @@ -312,13 +313,12 @@ class _ShopInBitCarResearchPaymentViewState /// the periodic driver can back off instead of polling at full rate. Future _pollStatus() async { try { - final resp = await ref - .read(pShopinBitService) - .client - .getCarResearchInvoiceStatus( - widget.invoice.btcpayInvoice, - customerKey: widget.customerKey, - ); + final service = ref.read(pShopinBitService); + + final resp = await service.client.getCarResearchInvoiceStatus( + widget.invoice.btcpayInvoice, + customerKey: widget.customerKey, + ); if (resp.hasError || resp.value == null) { if (mounted) { unawaited( @@ -332,6 +332,60 @@ class _ShopInBitCarResearchPaymentViewState } return false; } + + final apiTicketId = resp.value!.realTicketId; + if (apiTicketId != null) { + // we may not have the ticket in the db yet. Lets check + final ticket = await service.db.shopInBitTicketsDao.getByApiId( + apiTicketId, + ); + + // not found, so lets fix that + if (ticket == null) { + final invoiceStatus = resp.value!; + + final response = await service.client.getTicketFull( + apiTicketId, + customerKey: invoiceStatus.externalCustomerKey, + ); + + if (response.hasError || response.value == null) { + Logging.instance.e( + "$runtimeType get full ticket for car failed", + error: response.exception, + stackTrace: .current, + ); + } else { + final fullTicket = response.value!; + + // TODO: clean this up a bit some day but for now... + await service.db.transaction(() async { + // get ticket again to ensure this is an atomic insert operation + // in the db transaction + final ticket = await service.db.shopInBitTicketsDao.getByApiId( + apiTicketId, + ); + + if (ticket == null) { + // insert bare minimum - will be updated automatically later + await service.db.shopInBitTicketsDao.insertTicket( + ShopInBitTicketsCompanion.insert( + apiTicketId: apiTicketId, + customerKey: invoiceStatus.externalCustomerKey, + ticketNumber: invoiceStatus.realTicketNumber!, + category: .car, + requestDescription: fullTicket.productName ?? "", + deliveryCountry: fullTicket.deliveryCountry, + status: .pending, + statusRaw: "NEW", + ), + ); + } + }); + } + } + } + if (!mounted) return true; Logging.instance.i( "CarResearch status response (payment_view): ${resp.value}", From 7dbea236ed1c7c025b3315923cd9a88a61df000e Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 11 Jun 2026 14:01:49 -0600 Subject: [PATCH 85/90] fix: API was updated to show ticket type so we can now filter receipts out --- lib/services/shopinbit/shopinbit_service.dart | 6 +++++- lib/services/shopinbit/src/models/ticket.dart | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index b40c1b3488..0c41d6a3f2 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -67,7 +67,11 @@ class ShopInBitService { ); return; } - await Future.wait(resp.value!.map((ref) => _refreshRef(ref, key))); + await Future.wait( + resp.value! + .where((e) => !e.isKnownReceipt) + .map((ref) => _refreshRef(ref, key)), + ); } /// Refresh a single ticket. The row must already exist; use this for diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 0a3a386fa0..df898b6214 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -44,14 +44,25 @@ class TicketRef { final int id; final String number; - TicketRef({required this.id, required this.number}); + /// [kind] is nullable for backwards compat only + final String? kind; + + /// True only when [kind] explicitly marks this as a receipt ticket. + /// False does not rule it out, since legacy tickets have a null [kind]. + bool get isKnownReceipt => kind == "receipt"; + + TicketRef({required this.id, required this.number, this.kind}); factory TicketRef.fromJson(Map json) { - return TicketRef(id: _toInt(json['id']), number: json['number'] as String); + return TicketRef( + id: _toInt(json['id']), + number: json['number'] as String, + kind: json['ticket_kind'] as String?, + ); } Map toMap() { - return {"id": id, "number": number}; + return {"id": id, "number": number, "kind": kind}; } @override From a8e74ffe699eeedd2a1b53f7e471eb42cc148b01 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 11 Jun 2026 14:11:35 -0600 Subject: [PATCH 86/90] fix: reduce pointless API calls --- lib/services/shopinbit/shopinbit_service.dart | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 0c41d6a3f2..6b02485b4c 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -190,31 +190,44 @@ class ShopInBitService { return; } - final ApiResponse fullResp; - final ApiResponse statusResp; - final ApiResponse> messagesResp; - (fullResp, statusResp, messagesResp) = await ( - client.getTicketFull(id, customerKey: customerKey), - client.getTicketStatus(id, customerKey: customerKey), - client.getMessages(id, customerKey: customerKey), - ).wait; - - if (existing == null) { - await _insertHydrated( - ref: ref, - customerKey: customerKey, - full: fullResp.value, - status: statusResp.value, - messages: messagesResp.value, + // get status first. If it fails there is no reason to make the remaining + // two API calls + final statusResp = await client.getTicketStatus( + id, + customerKey: customerKey, + ); + + if (statusResp.exception?.statusCode == 403) { + Logging.instance.w( + "$runtimeType._refreshBody status call permission denied. " + "Ignoring ticket.", ); } else { - await _patchExisting( - existing: existing, - full: fullResp.value, - status: statusResp.value, - messages: messagesResp.value, - ); + final ApiResponse fullResp; + final ApiResponse> messagesResp; + (fullResp, messagesResp) = await ( + client.getTicketFull(id, customerKey: customerKey), + client.getMessages(id, customerKey: customerKey), + ).wait; + + if (existing == null) { + await _insertHydrated( + ref: ref, + customerKey: customerKey, + full: fullResp.value, + status: statusResp.value, + messages: messagesResp.value, + ); + } else { + await _patchExisting( + existing: existing, + full: fullResp.value, + status: statusResp.value, + messages: messagesResp.value, + ); + } } + completer.complete(); } catch (e, s) { completer.completeError(e, s); From d00a101abc0bb8da416fa6f81027584f12a4e7b3 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 18 Jun 2026 10:29:23 -0600 Subject: [PATCH 87/90] chore: disable shopinbit sandbox --- lib/providers/global/shopin_bit_service_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/global/shopin_bit_service_provider.dart b/lib/providers/global/shopin_bit_service_provider.dart index d2e5a49d77..102a59f043 100644 --- a/lib/providers/global/shopin_bit_service_provider.dart +++ b/lib/providers/global/shopin_bit_service_provider.dart @@ -11,7 +11,7 @@ final pShopinBitService = Provider( client: ShopInBitClient( accessKey: kShopInBitAccessKey, partnerSecret: kShopInBitPartnerSecret, - sandbox: true, // TODO set to false in prod + sandbox: false, ), db: ref.watch(pSharedDrift), ), From 72cff2bfe2cb91313c378865af406cfb5aa0e1fd Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 18 Jun 2026 15:30:44 -0600 Subject: [PATCH 88/90] Revert "chore: disable shopinbit sandbox" This reverts commit d00a101abc0bb8da416fa6f81027584f12a4e7b3. --- lib/providers/global/shopin_bit_service_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/global/shopin_bit_service_provider.dart b/lib/providers/global/shopin_bit_service_provider.dart index 102a59f043..d2e5a49d77 100644 --- a/lib/providers/global/shopin_bit_service_provider.dart +++ b/lib/providers/global/shopin_bit_service_provider.dart @@ -11,7 +11,7 @@ final pShopinBitService = Provider( client: ShopInBitClient( accessKey: kShopInBitAccessKey, partnerSecret: kShopInBitPartnerSecret, - sandbox: false, + sandbox: true, // TODO set to false in prod ), db: ref.watch(pSharedDrift), ), From 6da8d2fe19128f524bc784871779d2542ef767e9 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Thu, 18 Jun 2026 15:43:18 -0500 Subject: [PATCH 89/90] feat(shopinbit): keep polling ticket state & messages of terminal tickets --- lib/pages/shopinbit/shopinbit_ticket_detail.dart | 13 +------------ lib/services/shopinbit/shopinbit_service.dart | 8 -------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 20968a0b76..16944a1b68 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -12,7 +12,6 @@ import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/client.dart'; import '../../services/shopinbit/src/models/message.dart'; -import '../../services/shopinbit/src/models/ticket.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/logger.dart'; @@ -81,10 +80,7 @@ class _ShopInBitTicketDetailState extends ConsumerState void didChangeAppLifecycleState(AppLifecycleState state) { // Don't poll while backgrounded; resume fresh when we come back. if (state == AppLifecycleState.resumed) { - final ticket = ref.read(pShopInBitTicket(_id)).asData?.value; - final terminal = - ticket != null && TicketState.fromString(ticket.statusRaw).isTerminal; - if (!terminal) _startPolling(); + _startPolling(); } else { _pollingTimer?.cancel(); } @@ -105,13 +101,6 @@ class _ShopInBitTicketDetailState extends ConsumerState } if (!mounted) return; - // Stop polling once the ticket reaches a terminal state; nothing about a - // closed/merged/refunded ticket will change server-side. - final ticket = ref.read(pShopInBitTicket(_id)).asData?.value; - if (ticket != null && TicketState.fromString(ticket.statusRaw).isTerminal) { - return; - } - // Back off on failure (e.g. a 429), reset on success. _pollInterval = ok ? _kBasePollInterval diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index 6b02485b4c..8ed057f6ac 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -182,14 +182,6 @@ class ShopInBitService { id, ); - // Terminal-state short-circuit: nothing about a closed/merged ticket - // will change server-side, so skip the three API calls entirely. - if (existing != null && - TicketState.fromString(existing.statusRaw).isTerminal) { - completer.complete(); - return; - } - // get status first. If it fails there is no reason to make the remaining // two API calls final statusResp = await client.getTicketStatus( From 5c7f7717d7ca8d9460af70aaf3241e690984da47 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 23 Jun 2026 08:29:52 -0600 Subject: [PATCH 90/90] fix(shopinbit): desktop first run dialog --- .../desktop_shopin_bit_first_run.dart | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart index 9f39165864..ba16219323 100644 --- a/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart +++ b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import '../../../../pages/shopinbit/shopinbit_step_2.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../widgets/desktop/primary_button.dart'; -import '../../../../widgets/desktop/secondary_button.dart'; import '../../../../widgets/dialogs/s_dialog.dart'; class DesktopShopinBitFirstRun extends StatelessWidget { @@ -15,7 +14,7 @@ class DesktopShopinBitFirstRun extends StatelessWidget { Widget build(BuildContext context) { return SDialog( child: SizedBox( - width: 580, + width: 500, child: Padding( padding: const EdgeInsets.all(32), child: Column( @@ -30,29 +29,25 @@ class DesktopShopinBitFirstRun extends StatelessWidget { TextSpan( text: "Please note the following before proceeding:" - "\n\n\u2022 Minimum order amount: 1,000 EUR" - "\n\u2022 Service fee: 10% of the order total", + "\n\n \u2022 Minimum order amount: 1,000 EUR" + "\n \u2022 Service fee: 10% of the order total", ), ], ), ), - const SizedBox(height: 32), + const SizedBox(height: 48), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - SecondaryButton( - width: 220, - buttonHeight: ButtonHeight.l, - label: "Cancel", - onPressed: Navigator.of(context).pop, - ), - PrimaryButton( - width: 220, - buttonHeight: ButtonHeight.l, - label: "Continue", - onPressed: () => Navigator.of( - context, - ).pushReplacementNamed(ShopInBitStep2.routeName), + const Spacer(), + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.l, + label: "Continue", + onPressed: () => Navigator.of( + context, + ).pushReplacementNamed(ShopInBitStep2.routeName), + ), ), ], ),