feat(shopinbit): save and restore pending car research state
What changed, and why it matters
This commit adds a 'save and resume' feature for an in-app car-research purchase flow. It stores the user's progress locally so they can continue later instead of losing it if they close the screen. There is no security bug or fix visible in the change.
No security action required; this is a normal feature commit. Routine code review and QA for the resume flow are sufficient.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch persists a pending ‘ShopinBit’ car-research invoice state to the local Isar database using a sentinel ticketId ‘pending-car-research’ and restores shipping fields when the fee view is re-opened. It also prompts the user when another car research flow is already pending, offering to resume or start fresh. No cryptographic, authentication, authorization, or input-sanitization changes are present.
Changed components
lib/pages/shopinbit/shopinbit_car_fee_view.dartlib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/pages/shopinbit/shopinbit_step_4.dartInspect captured patch +105 / −49
diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart
index 8db1d9a..7f69137 100644
--- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart
@@ -1,9 +1,11 @@
import 'dart:async';
+import 'dart:convert';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
+import '../../db/isar/main_db.dart';
import '../../models/shopinbit/shopinbit_order_model.dart';
import '../../notifications/show_flush_bar.dart';
import '../../services/shopinbit/shopinbit_service.dart';
@@ -93,10 +95,14 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
@override
void initState() {
super.initState();
- _nameController = TextEditingController();
- _streetController = TextEditingController();
- _cityController = TextEditingController();
- _postalCodeController = TextEditingController();
+ _nameController = TextEditingController(text: widget.model.shippingName);
+ _streetController = TextEditingController(
+ text: widget.model.shippingStreet,
+ );
+ _cityController = TextEditingController(text: widget.model.shippingCity);
+ _postalCodeController = TextEditingController(
+ text: widget.model.shippingPostalCode,
+ );
_nameFocusNode = FocusNode();
_streetFocusNode = FocusNode();
_cityFocusNode = FocusNode();
@@ -124,6 +130,11 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
}
_fetchCountries();
+
+ // Pre-select country on resume if model already has a shipping country.
+ if (widget.model.shippingCountry.isNotEmpty) {
+ _selectedCountryIso = widget.model.shippingCountry;
+ }
}
@override
@@ -168,14 +179,11 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
Future<void> _fetchCountries() async {
setState(() => _loadingCountries = true);
try {
- final resp =
- await ShopInBitService.instance.client.getCountries();
+ final resp = await ShopInBitService.instance.client.getCountries();
if (resp.hasError || resp.value == null) return;
_countries = resp.value!;
if (_selectedCountryIso != null &&
- !_countries.any(
- (c) => c['iso'] == _selectedCountryIso,
- )) {
+ !_countries.any((c) => c['iso'] == _selectedCountryIso)) {
_selectedCountryIso = null;
}
} catch (_) {
@@ -245,8 +253,7 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
unawaited(
showFloatingFlushBar(
type: FlushBarType.warning,
- message:
- resp.exception?.message ?? "Failed to create invoice",
+ message: resp.exception?.message ?? "Failed to create invoice",
context: context,
),
);
@@ -256,6 +263,15 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
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);
+ await MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket());
+
// Best-effort fee fetch; do not block navigation on fee parse failure.
await _loadFee(invoice);
@@ -339,7 +355,9 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
final parsed = _parseBip21Amount(entry.value);
if (parsed != null && parsed.isNotEmpty) {
if (mounted) {
- setState(() => _displayedFee = "$parsed ${entry.key.toUpperCase()}");
+ setState(
+ () => _displayedFee = "$parsed ${entry.key.toUpperCase()}",
+ );
}
return;
}
@@ -347,8 +365,8 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
} catch (_) {
// Leave placeholder in place.
}
- // No parse succeeded — leave the existing "223.00 EUR" business-rule
- // placeholder in place rather than showing "—".
+ // No parse succeeded: leave the existing "223.00 EUR" business-rule
+ // placeholder in place rather than showing "--".
}
Widget _buildField({
@@ -398,9 +416,7 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
required bool isDesktop,
}) {
return ClipRRect(
- borderRadius: BorderRadius.circular(
- Constants.size.circularBorderRadius,
- ),
+ borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius),
child: DropdownButtonHideUnderline(
child: DropdownButton2<String>(
value: value,
@@ -411,12 +427,10 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
child: Text(
c['label'] as String,
style: isDesktop
- ? STextStyles.desktopTextExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textFieldActiveText,
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveText,
)
: STextStyles.w500_14(context),
),
@@ -433,9 +447,9 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
_loadingCountries ? "Loading countries..." : hint,
style: isDesktop
? STextStyles.desktopTextExtraSmall(context).copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textFieldDefaultSearchIconLeft,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldDefaultSearchIconLeft,
)
: STextStyles.fieldLabel(context),
),
@@ -457,9 +471,9 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
Assets.svg.chevronDown,
width: 12,
height: 6,
- color: Theme.of(context)
- .extension<StackColors>()!
- .textFieldActiveSearchIconRight,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
),
),
),
@@ -497,9 +511,7 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
.where((c) => c['iso'] == item.value)
.map((c) => c['label'] as String)
.firstOrNull;
- return label?.toLowerCase().contains(
- searchValue.toLowerCase(),
- ) ??
+ return label?.toLowerCase().contains(searchValue.toLowerCase()) ??
false;
},
),
@@ -726,9 +738,7 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
horizontal: 32,
vertical: 16,
),
- child: SingleChildScrollView(
- child: content,
- ),
+ child: SingleChildScrollView(child: content),
),
),
],
@@ -745,12 +755,11 @@ class _ShopInBitCarFeeViewState extends State<ShopInBitCarFeeView> {
}
},
child: Scaffold(
- backgroundColor:
- Theme.of(context).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.background,
appBar: AppBar(
- leading: AppBarBackButton(
- onPressed: _popToStep2,
- ),
+ leading: AppBarBackButton(onPressed: _popToStep2),
title: Text("ShopinBit", style: STextStyles.navBarTitle(context)),
),
body: SafeArea(
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index 7bbe5a9..c66ef5e 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -172,7 +172,7 @@ class _ShopInBitCarResearchPaymentViewState
}
}
- // No compatible wallet coin found — surface an info flushbar and keep
+ // 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
// "CHECK FOR PAYMENT" button.
unawaited(
@@ -477,11 +477,13 @@ class _ShopInBitCarResearchPaymentViewState
return;
}
- // Step 4: Persist request ticket
+ // Step 4: Persist request ticket and clear pending payment state
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;
await MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket());
// Step 5: Update fee receipt — mark createRequest as done
@@ -650,7 +652,7 @@ class _ShopInBitCarResearchPaymentViewState
? Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Text(
- _methods.isEmpty ? "—" : _methods.first,
+ _methods.isEmpty ? "" : _methods.first,
textAlign: TextAlign.center,
style: isDesktop
? STextStyles.desktopTextExtraExtraSmall(context)
diff --git a/lib/pages/shopinbit/shopinbit_step_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart
index 2288aed..778cc8c 100644
--- a/lib/pages/shopinbit/shopinbit_step_4.dart
+++ b/lib/pages/shopinbit/shopinbit_step_4.dart
@@ -29,6 +29,7 @@ import '../exchange_view/sub_widgets/step_row.dart';
import 'shopinbit_step_3.dart';
import 'shopinbit_car_fee_view.dart';
import 'shopinbit_order_created.dart';
+import 'shopinbit_tickets_view.dart';
class ShopInBitStep4 extends StatefulWidget {
const ShopInBitStep4({super.key, required this.model});
@@ -527,7 +528,7 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
} else {
widget.model.requestDescription = _descriptionController.text.trim();
}
- // Travel doesn't collect delivery country — use departure country or "DE"
+ // Travel doesn't collect delivery country: use departure country or "DE"
// as a default since the API requires the field.
if (widget.model.category == ShopInBitCategory.travel) {
widget.model.deliveryCountry = "DE";
@@ -536,6 +537,49 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
}
if (widget.model.category == ShopInBitCategory.car) {
+ // Block if another car research flow is already in progress.
+ final existingPending = MainDB.instance
+ .getShopInBitTickets()
+ .where((t) => t.isPendingPayment)
+ .toList();
+
+ if (existingPending.isNotEmpty && mounted) {
+ final resumePrevious = await showDialog<bool>(
+ context: context,
+ barrierDismissible: false,
+ builder: (ctx) => AlertDialog(
+ title: const Text("In-Progress Car Research"),
+ content: const Text(
+ "You have an unfinished car research payment. "
+ "Would you like to resume it or start a new search?",
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(true),
+ child: const Text("Resume Previous"),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: const Text("Start New"),
+ ),
+ ],
+ ),
+ );
+
+ if (resumePrevious == true && mounted) {
+ setState(() => _submitting = false);
+ unawaited(
+ Navigator.of(context).pushNamedAndRemoveUntil(
+ ShopInBitTicketsView.routeName,
+ (route) => route.isFirst,
+ ),
+ );
+ return;
+ }
+ }
+
+ if (!mounted) return;
+
if (Util.isDesktop) {
Navigator.of(context, rootNavigator: true).pop();
unawaited(
@@ -546,9 +590,10 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
);
} else {
unawaited(
- Navigator.of(
- context,
- ).pushNamed(ShopInBitCarFeeView.routeName, arguments: widget.model),
+ Navigator.of(context).pushNamed(
+ ShopInBitCarFeeView.routeName,
+ arguments: widget.model,
+ ),
);
}
return;
@@ -561,7 +606,7 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
assert(
widget.model.category != null,
- 'Step 4 reached with null category — Step 2 must set category before reaching Step 4',
+ 'Step 4 reached with null category: Step 2 must set category before reaching Step 4',
);
// API service_type: travel requests use "concierge" because the
@@ -2178,7 +2223,7 @@ class _ShopInBitStep4State extends State<ShopInBitStep4> {
),
),
- // Travel doesn't need delivery country — destinations are in the form.
+ // Travel doesn't need delivery country: destinations are in the form.
SizedBox(height: isDesktop ? 16 : 12),
_buildPrivacyCheckbox(isDesktop),
SizedBox(height: isDesktop ? 16 : 12),
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.