From 48517de06b199fb7c6e68d67830edb2ba0987059 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Tue, 26 May 2026 16:19:27 -0500 Subject: [PATCH 01/47] 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 fe4818412..a1f9b8be8 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/47] 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 efa997e64..246891da4 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 eea1f437c..23afcb31c 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 a1f9b8be8..ad695e241 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/47] 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 23afcb31c..38895fd6f 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/47] 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 8186a6658..3f2f48d08 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/47] 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 a4b36b2ab..8580f0473 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/47] 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 8580f0473..886ba4c8c 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/47] 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 98946c14d..544a554c2 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 ccebcb30b..298e29369 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 dc5127c57..6251f624e 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 71445aedd..8226f81bc 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 235aca453..eb0fa8ef9 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/47] 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 6251f624e..fa374fe3c 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/47] 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 a24059c8c..46ffd5a3b 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/47] 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 eb0fa8ef9..f625a63fe 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/47] 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 0d3d3a14a..40c366d61 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 000000000..d15e22ee2 --- /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 38895fd6f..f9216cfff 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 298e29369..597656f68 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/47] 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 40c366d61..a7f43dced 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/47] 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 a7f43dced..484fed721 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 d15e22ee2..fab7f8954 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 f9216cfff..2ade2f5d4 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/47] 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 aa693c7d8..892d3b681 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 0e476089f..990f43cdb 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 2ade2f5d4..fce38927a 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 1ce525f25..b02d5f19f 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 fa374fe3c..65a0b8e19 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/47] 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 484fed721..af854f788 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 fab7f8954..c70f2531e 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 fce38927a..e876a7120 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/47] 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 544a554c2..64e90d1a7 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 c70f2531e..bd6aade79 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 e876a7120..589e6f247 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 597656f68..4dc567de5 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 2a42a8af7..df03fd581 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 f625a63fe..e5561b4e2 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/47] 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 589e6f247..6ea6d7f8f 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/47] 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 4dc567de5..344ebd856 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/47] 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 344ebd856..d62e9b432 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/47] 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 6ea6d7f8f..af80536f1 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/47] 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 af854f788..349b045ea 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/47] 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 349b045ea..fc24f8c43 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/47] 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 3f2f48d08..8b4b2805b 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/47] 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 886ba4c8c..d9574f697 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/47] 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 d9574f697..bb92bcd9a 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/47] 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 65a0b8e19..597c8bbc5 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/47] 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 9e0eedae6..ed95f8c99 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/47] 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 892d3b681..1f5cead0f 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/47] 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 8b4b2805b..d6741e363 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/47] 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 af80536f1..8f4105c9e 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 d62e9b432..88be18d18 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 df03fd581..9c98d6fb1 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 e5561b4e2..f97e6f2b2 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/47] 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 ad695e241..1ca819785 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 ea1eceb0d..e5bf15be3 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/47] 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 d6741e363..1606d4ae0 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/47] 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 fc24f8c43..138b331f8 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 1313d6032..773c0f478 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/47] 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 8226f81bc..b267e3890 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/47] 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 597c8bbc5..e2eebf84f 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/47] 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 65569890d..a83bccbc4 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/47] 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 b267e3890..0f02a30f2 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/47] 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 a83bccbc4..65569890d 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/47] 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 64e90d1a7..b1594930b 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 88be18d18..adb089240 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 9c98d6fb1..42a6c0eba 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 f97e6f2b2..045a289eb 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/47] 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 24f9f95a7..7d6bae012 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 6c8607609..bf3d6c662 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/47] 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 fa6f94e53..ec39151fd 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 28c4c3991..67c9b7af6 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 e4c32532e..4438f9019 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 450053a20..54d8bd5dc 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 000000000..eca8d63b3 --- /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 14b530475..000000000 --- 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 000000000..49be9fa6c --- /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 f99bfa92a..0561b07bd 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 675765340..cb4ec42a6 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 7b71dc8c6..2062ef3f9 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 1606d4ae0..60ea3f4f6 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 138b331f8..18676e9f4 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 a6f85da13..2587a17fe 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 b1594930b..54ac5bab1 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 9680519e0..0389a24d7 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 bd6aade79..93a6974bf 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 8f4105c9e..97888d094 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 d2c08e26b..3e6bbeff1 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 bb92bcd9a..757c77132 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 b02d5f19f..6e6ba90fb 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 adb089240..1f3c4125e 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 a1fa23694..000000000 --- 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 2a4d9f09f..742f198e7 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 d5cae9d60..eb997111e 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 605d6e22c..80cf61316 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 e2eebf84f..364407ad6 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 0f02a30f2..aaecb69bb 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 0cb651182..767d57cbe 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 1c191cd72..669070c42 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 82b862ea8..000000000 --- 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 ed95f8c99..e432f1eb8 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 e110eab53..780e4c00a 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 d5a81094d..3ae138b90 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 16e791d92..9f3916586 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 2e46a7e76..000000000 --- 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 9f9c422e6..d2e5a49d7 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 42a6c0eba..a68552841 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 204bb25c3..000000000 --- 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 af9825e2f..0bdcf906f 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 1ca819785..c939c5a58 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 2048b72c2..134132259 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 773c0f478..52ee9fd33 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 045a289eb..74e272c77 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 a7a491f5b..5a7636276 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 96aeaf72e..2636a70d6 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 c62d8cc0c..2d46a6cb2 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 b68fd5b64..000000000 --- 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/47] 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 625850a0a..7763b9b71 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/47] 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 0561b07bd..7025e34dd 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/47] 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 18676e9f4..eb1137bac 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/47] 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 aaecb69bb..3f941d5d9 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 52ee9fd33..db055bee9 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/47] 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 0bdcf906f..1f33466a9 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 db055bee9..237ed1c75 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/47] 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 237ed1c75..ca6782680 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 {