refactor(cakepay,shopinbit): non-blocking loads, reactive detail views
What changed, and why it matters
This commit is a user-interface refactor for two third-party order/payment features (CakePay and ShopInBit). It moves order loading and polling out of the UI screens into dedicated background services, adds pull-to-refresh and refresh buttons, and removes full-screen loading spinners that previously blocked the app. There is no direct evidence in the commit that it fixes a security vulnerability; it appears aimed at improving responsiveness and fixing UI lock-ups.
Treat as a routine UX/refactor commit. No immediate security action is indicated from the diff alone. If reviewing for security, focus on whether the new services correctly cancel timers on widget disposal and whether silent exception handling could hide API error conditions; these are reliability concerns rather than confirmed vulnerabilities.
Security signals we found
Refactor only: no new network trust boundaries, no auth changes, no input validation changes
Background polling services introduced with refcounting and disposal
Exceptions swallowed silently in service layer (pre-existing pattern, not new)
No mention of CVE, security bug, or researcher attribution in commit or supplied references
Evidence from the diff
The diff introduces ChangeNotifier-based services (CakePayOrdersService, ShopInBitOrdersService) and Riverpod providers to cache order/ticket state, perform background polling, and notify widgets only when data changes. UI pages are rewritten to consume these providers, replacing synchronous blocking loads (showLoading) with reactive rebuilds, RefreshIndicator/RefreshControl, and header refresh buttons. Polling is refcounted and cancelled on disposal. The code still swallows exceptions and continues to use existing API clients and models unchanged. No cryptographic, authentication, or authorization changes are visible.
Changed components
lib/pages/cakepay/cakepay_order_view.dartlib/pages/cakepay/cakepay_orders_view.dartlib/pages/shopinbit/shopinbit_ticket_detail.dartlib/pages/shopinbit/shopinbit_tickets_view.dartlib/providers/global/cakepay_orders_provider.dartlib/providers/global/shopin_bit_orders_provider.dartlib/services/cakepay/cakepay_orders_service.dartlib/services/shopinbit/shopinbit_orders_service.dartlib/widgets/refresh_control.dartInspect captured patch +827 / −487
diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart
index ba7fb46..f10581e 100644
--- a/lib/pages/cakepay/cakepay_order_view.dart
+++ b/lib/pages/cakepay/cakepay_order_view.dart
@@ -7,14 +7,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app_config.dart';
import '../../notifications/show_flush_bar.dart';
+import '../../providers/global/cakepay_orders_provider.dart';
import '../../providers/providers.dart';
import '../../route_generator.dart';
-import '../../services/cakepay/cakepay_service.dart';
+import '../../services/cakepay/cakepay_orders_service.dart';
import '../../services/cakepay/src/models/order.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/amount/amount.dart';
import '../../utilities/assets.dart';
-import '../../utilities/show_loading.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../wallets/crypto_currency/crypto_currency.dart';
@@ -26,6 +26,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/qr.dart';
+import '../../widgets/refresh_control.dart';
import '../../widgets/rounded_white_container.dart';
import '../wallet_view/transaction_views/transaction_details_view.dart';
import 'cakepay_send_from_view.dart';
@@ -42,56 +43,62 @@ class CakePayOrderView extends ConsumerStatefulWidget {
}
class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
- late CakePayOrder _order;
- Timer? _pollTimer;
+ late final CakePayOrdersService _ordersService;
Timer? _countdownTimer;
- Duration _timeRemaining = Duration.zero;
+ int? _countdownExpiration;
int _selectedPaymentMethod = 0;
+ bool _polling = false;
@override
void initState() {
super.initState();
- _order = widget.order;
-
- // TODO: _loadOrder already locked up the ui previously, this just puts a
- // nicer loading ui in place
- WidgetsBinding.instance.addPostFrameCallback((_) => _loadOrder());
- _pollTimer = Timer.periodic(
- const Duration(seconds: 15),
- (_) => _loadOrder(),
- );
+ _ordersService = ref.read(pCakePayOrdersService);
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ _polling = true;
+ _ordersService.startPolling(widget.order.orderId);
+ });
}
@override
void dispose() {
- _pollTimer?.cancel();
+ if (_polling) {
+ _ordersService.stopPolling(widget.order.orderId);
+ }
_countdownTimer?.cancel();
super.dispose();
}
- void _startCountdown() {
+ void _ensureCountdown(int? expirationTime) {
+ if (expirationTime == null) {
+ if (_countdownTimer != null) {
+ _countdownTimer?.cancel();
+ _countdownTimer = null;
+ _countdownExpiration = null;
+ }
+ return;
+ }
+ if (_countdownExpiration == expirationTime && _countdownTimer != null) {
+ return;
+ }
+ _countdownExpiration = expirationTime;
_countdownTimer?.cancel();
- _updateTimeRemaining();
- _countdownTimer = Timer.periodic(
- const Duration(seconds: 1),
- (_) => _updateTimeRemaining(),
- );
+ _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
+ if (!mounted) return;
+ final remaining = _computeRemaining(expirationTime);
+ if (remaining <= Duration.zero) {
+ _countdownTimer?.cancel();
+ _countdownTimer = null;
+ _countdownExpiration = null;
+ }
+ setState(() {});
+ });
}
- void _updateTimeRemaining() {
- if (_order.expirationTime == null) return;
- final expiresAt = DateTime.fromMillisecondsSinceEpoch(
- _order.expirationTime!,
- );
+ Duration _computeRemaining(int expirationTime) {
+ final expiresAt = DateTime.fromMillisecondsSinceEpoch(expirationTime);
final remaining = expiresAt.difference(DateTime.now());
- if (mounted) {
- setState(() {
- _timeRemaining = remaining.isNegative ? Duration.zero : remaining;
- });
- }
- if (remaining.isNegative) {
- _countdownTimer?.cancel();
- }
+ return remaining.isNegative ? Duration.zero : remaining;
}
String _formatDuration(Duration d) {
@@ -214,41 +221,6 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
);
}
- Future<void> _loadOrder() async {
- await showLoading(
- context: context,
- message: "Updating order...",
- whileFutureAlt: _loadOrderHelper,
- rootNavigator: Util.isDesktop,
- );
- }
-
- Future<void> _loadOrderHelper() async {
- final resp = await CakePayService.instance.client.getOrder(
- widget.order.orderId,
- );
- if (mounted) {
- setState(() {
- if (!resp.hasError && resp.value != null) {
- _order = resp.value!;
- if (_isTerminal(_order.status)) {
- _pollTimer?.cancel();
- _countdownTimer?.cancel();
- } else if (_order.expirationTime != null) {
- _startCountdown();
- }
- }
- });
- }
- }
-
- bool _isTerminal(CakePayOrderStatus status) {
- return status == CakePayOrderStatus.complete ||
- status == CakePayOrderStatus.expired ||
- status == CakePayOrderStatus.failed ||
- status == CakePayOrderStatus.refunded;
- }
-
/// Whether the order has received payment and is being processed or
/// is already complete. Payment UI should be hidden for these.
bool _isPaidOrBeyond(CakePayOrderStatus status) {
@@ -330,7 +302,13 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
- final order = _order;
+ final service = ref.watch(pCakePayOrdersService);
+ final order = service.get(widget.order.orderId) ?? widget.order;
+ final isRefreshing = service.isRefreshing(widget.order.orderId);
+ _ensureCountdown(order.expirationTime);
+ final remaining = order.expirationTime == null
+ ? Duration.zero
+ : _computeRemaining(order.expirationTime!);
final paymentOptions = order.paymentOptions;
final details = <Widget>[
@@ -373,6 +351,7 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
},
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Order ID",
@@ -380,18 +359,23 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
? STextStyles.desktopTextExtraExtraSmall(context)
: STextStyles.itemSubtitle12(context),
),
- Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- SelectableText(
- order.orderId,
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.titleBold12(context),
- ),
- const SizedBox(width: 6),
- IconCopyButton(data: order.orderId),
- ],
+ const SizedBox(width: 8),
+ Flexible(
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Flexible(
+ child: SelectableText(
+ order.orderId,
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ ),
+ const SizedBox(width: 6),
+ IconCopyButton(data: order.orderId),
+ ],
+ ),
),
],
),
@@ -524,7 +508,7 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
// Expiration countdown.
if (order.expirationTime != null) {
- final isExpired = _timeRemaining == Duration.zero;
+ final isExpired = remaining == Duration.zero;
details.add(
RoundedWhiteContainer(
child: Row(
@@ -537,7 +521,7 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
: STextStyles.itemSubtitle12(context),
),
Text(
- _formatDuration(_timeRemaining),
+ _formatDuration(remaining),
style:
(isDesktop
? STextStyles.desktopTextExtraExtraSmall(context)
@@ -547,7 +531,7 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
? Theme.of(
context,
).extension<StackColors>()!.accentColorRed
- : _timeRemaining.inMinutes < 5
+ : remaining.inMinutes < 5
? Theme.of(
context,
).extension<StackColors>()!.accentColorOrange
@@ -840,17 +824,33 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
details.add(SizedBox(height: isDesktop ? 8 : 6));
}
- final content = SingleChildScrollView(
+ final scrollable = SingleChildScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: details,
),
);
- return _scaffold(isDesktop: isDesktop, child: content);
+ final content = RefreshControl(
+ onRefresh: () => service.refreshOne(widget.order.orderId),
+ child: scrollable,
+ );
+
+ return _scaffold(
+ isDesktop: isDesktop,
+ isRefreshing: isRefreshing,
+ onRefresh: () => service.refreshOne(widget.order.orderId),
+ child: content,
+ );
}
- Widget _scaffold({required bool isDesktop, required Widget child}) {
+ Widget _scaffold({
+ required bool isDesktop,
+ required bool isRefreshing,
+ required Future<void> Function() onRefresh,
+ required Widget child,
+ }) {
return ConditionalParent(
condition: isDesktop,
builder: (child) => SDialog(
@@ -866,9 +866,19 @@ class _CakePayOrderViewState extends ConsumerState<CakePayOrderView> {
padding: const EdgeInsets.only(left: 32),
child: Text("Order", style: STextStyles.desktopH3(context)),
),
- DesktopDialogCloseButton(
- onPressedOverride: () =>
- confirmCloseNestedNavigatorDialog(context),
+ Row(
+ mainAxisSize: .min,
+ children: [
+ RefreshButton(
+ isRefreshing: isRefreshing,
+ onPressed: () => onRefresh(),
+ ),
+ const SizedBox(width: 8),
+ DesktopDialogCloseButton(
+ onPressedOverride: () =>
+ confirmCloseNestedNavigatorDialog(context),
+ ),
+ ],
),
],
),
diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart
index f808fcf..0e47608 100644
--- a/lib/pages/cakepay/cakepay_orders_view.dart
+++ b/lib/pages/cakepay/cakepay_orders_view.dart
@@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../services/cakepay/cakepay_service.dart';
-import '../../services/cakepay/src/models/order.dart';
+import '../../providers/global/cakepay_orders_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';
@@ -11,61 +10,156 @@ 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/refresh_control.dart';
import '../../widgets/rounded_container.dart';
import 'cakepay_order_view.dart';
-class CakePayOrdersView extends StatefulWidget {
+class CakePayOrdersView extends ConsumerStatefulWidget {
const CakePayOrdersView({super.key});
static const String routeName = "/cakePayOrders";
@override
- State<CakePayOrdersView> createState() => _CakePayOrdersViewState();
+ ConsumerState<CakePayOrdersView> createState() => _CakePayOrdersViewState();
}
-class _CakePayOrdersViewState extends State<CakePayOrdersView> {
- List<CakePayOrder> _orders = [];
-
+class _CakePayOrdersViewState extends ConsumerState<CakePayOrdersView> {
@override
void initState() {
super.initState();
- WidgetsBinding.instance.addPostFrameCallback((_) => _syncFromApi());
- }
-
- Future<void> _syncFromApi() async {
- await showLoading(
- context: context,
- message: "Loading orders...",
- whileFutureAlt: _syncFromApiHelper,
- rootNavigator: Util.isDesktop,
- );
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ ref.read(pCakePayOrdersService).refreshAll();
+ });
}
- Future<void> _syncFromApiHelper() async {
- try {
- final orderIds = await CakePayService.instance.getOrderIds();
- final results = <CakePayOrder>[];
-
- for (final id in orderIds) {
- final resp = await CakePayService.instance.client.getOrder(id);
- if (!resp.hasError && resp.value != null) {
- results.add(resp.value!);
- }
- }
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
+ final service = ref.watch(pCakePayOrdersService);
+ final orders = service.all;
+ final isRefreshing = service.isRefreshingAll;
- if (mounted) {
- setState(() {
- _orders = results;
- });
+ final orderItems = <Widget>[];
+ if (orders.isEmpty) {
+ orderItems.add(const SizedBox(height: 80));
+ orderItems.add(
+ Center(
+ child: Text(
+ isRefreshing ? "Loading orders..." : "No orders yet",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ ),
+ );
+ } else {
+ for (var i = 0; i < orders.length; i++) {
+ final order = orders[i];
+ if (i > 0) orderItems.add(SizedBox(height: isDesktop ? 16 : 12));
+ orderItems.add(
+ RoundedContainer(
+ padding: EdgeInsets.all(isDesktop ? 16 : 12),
+ borderColor: isDesktop
+ ? Theme.of(context).extension<StackColors>()!.textFieldDefaultBG
+ : null,
+ color: Theme.of(context).extension<StackColors>()!.popupBG,
+ onPressed: () {
+ Navigator.of(
+ context,
+ ).pushNamed(CakePayOrderView.routeName, arguments: order);
+ },
+ child: Row(
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ order.orderId.length > 8
+ ? "${order.orderId.substring(0, 8)}..."
+ : order.orderId,
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(8),
+ color: order.status
+ .color(
+ Theme.of(context).extension<StackColors>()!,
+ )
+ .withValues(alpha: 0.2),
+ ),
+ child: Text(
+ order.status.label,
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(
+ context,
+ )
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(
+ color: order.status.color(
+ Theme.of(
+ context,
+ ).extension<StackColors>()!,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ if (order.amountUsd != null) ...[
+ const SizedBox(height: 4),
+ Text(
+ "\$${order.amountUsd} USD",
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle1,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ SizedBox(width: isDesktop ? 16 : 8),
+ Icon(
+ Icons.chevron_right,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle1,
+ ),
+ ],
+ ),
+ ),
+ );
}
- } catch (_) {
- // Fall back to empty list — no local cache to fall back on
}
- }
- @override
- Widget build(BuildContext context) {
- final isDesktop = Util.isDesktop;
+ Future<void> onRefresh() => ref.read(pCakePayOrdersService).refreshAll();
+
+ final body = RefreshControl(
+ onRefresh: onRefresh,
+ child: ListView(
+ shrinkWrap: true,
+ physics: const AlwaysScrollableScrollPhysics(),
+ primary: isDesktop ? false : null,
+ padding: isDesktop ? const EdgeInsets.only(bottom: 32, top: 8) : null,
+ children: orderItems,
+ ),
+ );
return ConditionalParent(
condition: isDesktop,
@@ -73,7 +167,7 @@ class _CakePayOrdersViewState extends State<CakePayOrdersView> {
child: SizedBox(
width: 580,
child: Column(
- mainAxisSize: .min,
+ mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -85,7 +179,17 @@ class _CakePayOrdersViewState extends State<CakePayOrdersView> {
style: STextStyles.desktopH3(context),
),
),
- const DesktopDialogCloseButton(),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ RefreshButton(
+ isRefreshing: isRefreshing,
+ onPressed: onRefresh,
+ ),
+ const SizedBox(width: 8),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
],
),
Flexible(
@@ -116,123 +220,7 @@ class _CakePayOrdersViewState extends State<CakePayOrdersView> {
),
),
),
- child: _orders.isEmpty
- ? Center(
- child: Text(
- "No orders yet",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- )
- : ListView.separated(
- shrinkWrap: isDesktop,
- primary: isDesktop ? false : null,
- itemCount: _orders.length,
- padding: isDesktop ? const .only(bottom: 32, top: 16) : null,
- separatorBuilder: (_, __) =>
- SizedBox(height: isDesktop ? 16 : 12),
- itemBuilder: (context, index) {
- final order = _orders[index];
- return RoundedContainer(
- padding: .all(Util.isDesktop ? 16 : 12),
- borderColor: Util.isDesktop
- ? Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG
- : null,
- color: Theme.of(context).extension<StackColors>()!.popupBG,
- onPressed: () {
- Navigator.of(
- context,
- ).pushNamed(CakePayOrderView.routeName, arguments: order);
- },
- child: Row(
- children: [
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- mainAxisAlignment:
- MainAxisAlignment.spaceBetween,
- children: [
- Text(
- order.orderId.length > 8
- ? "${order.orderId.substring(0, 8)}..."
- : order.orderId,
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.titleBold12(context),
- ),
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8,
- vertical: 2,
- ),
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(8),
- color: order.status
- .color(
- Theme.of(
- context,
- ).extension<StackColors>()!,
- )
- .withValues(alpha: 0.2),
- ),
- child: Text(
- order.status.label,
- style:
- (isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- )
- : STextStyles.itemSubtitle12(
- context,
- ))
- .copyWith(
- color: order.status.color(
- Theme.of(
- context,
- ).extension<StackColors>()!,
- ),
- ),
- ),
- ),
- ],
- ),
- if (order.amountUsd != null) ...[
- const SizedBox(height: 4),
- Text(
- "\$${order.amountUsd} USD",
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- )
- : STextStyles.itemSubtitle12(
- context,
- ).copyWith(
- color: Theme.of(context)
- .extension<StackColors>()!
- .textSubtitle1,
- ),
- ),
- ],
- ],
- ),
- ),
- SizedBox(width: isDesktop ? 16 : 8),
- Icon(
- Icons.chevron_right,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle1,
- ),
- ],
- ),
- );
- },
- ),
+ child: body,
),
);
}
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
index 59b704a..dc5127c 100644
--- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -8,7 +8,9 @@ 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';
+import '../../services/shopinbit/shopinbit_orders_service.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
@@ -17,9 +19,8 @@ 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/nested_navigator_dialog/nested_navigator_dialog.dart';
import '../../widgets/dialogs/s_dialog.dart';
-import '../../widgets/loading_indicator.dart';
+import '../../widgets/refresh_control.dart';
import '../../widgets/rounded_container.dart';
import '../../widgets/rounded_white_container.dart';
import 'shopinbit_offer_view.dart';
@@ -38,90 +39,43 @@ class ShopInBitTicketDetail extends ConsumerStatefulWidget {
class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
late final TextEditingController _messageController;
+ late final ShopInBitOrdersService _ordersService;
+ late final ShopInBitOrderModel _model;
+ bool _polling = false;
bool _sending = false;
- bool _loading = false;
bool _retrying = false;
- Timer? _pollTimer;
@override
void initState() {
super.initState();
_messageController = TextEditingController();
- if (widget.model.apiTicketId != 0) {
- _loadFromApi();
- if (!_isCarResearch) {
- _pollTimer = Timer.periodic(
- const Duration(seconds: 30),
- (_) => _loadFromApi(),
+ _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,
);
- }
+ });
}
}
@override
void dispose() {
- _pollTimer?.cancel();
+ if (_polling) {
+ _ordersService.stopPolling(_model.apiTicketId);
+ }
_messageController.dispose();
super.dispose();
}
- bool get _isCarResearch => widget.model.category == ShopInBitCategory.car;
-
- Future<void> _loadFromApi() async {
- setState(() => _loading = true);
- try {
- final client = ref.read(pShopinBitService).client;
- final id = widget.model.apiTicketId;
-
- final messagesResp = await client.getMessages(id);
- final statusResp = await client.getTicketStatus(id);
-
- if (!messagesResp.hasError && messagesResp.value != null) {
- final apiMessages = messagesResp.value!;
- widget.model.clearMessages();
- for (final m in apiMessages) {
- widget.model.addMessage(
- ShopInBitMessage(
- text: m.content,
- timestamp: m.timestamp,
- isFromUser: !m.fromAgent,
- ),
- );
- }
- }
-
- if (!statusResp.hasError && statusResp.value != null) {
- widget.model.status = ShopInBitOrderModel.statusFromTicketState(
- statusResp.value!.state,
- );
- }
+ bool get _isCarResearch => _model.category == ShopInBitCategory.car;
- if (widget.model.status == ShopInBitOrderStatus.offerAvailable &&
- (widget.model.offerProductName == null ||
- widget.model.offerPrice == null)) {
- final offerResp = await client.getTicketFull(id);
- if (!offerResp.hasError && offerResp.value != null) {
- final t = offerResp.value!;
- widget.model.setOffer(
- productName: t.productName,
- price: t.customerPrice,
- );
- }
- }
-
- final db = ref.read(pSharedDrift);
- unawaited(
- db
- .into(db.shopInBitTickets)
- .insertOnConflictUpdate(widget.model.toCompanion()),
- );
- } catch (_) {
- // Silently fall back to local data
- } finally {
- if (mounted) setState(() => _loading = false);
- }
- }
+ Future<void> _refresh() => _ordersService.refreshOne(_model.apiTicketId);
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
@@ -131,25 +85,25 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
_messageController.clear();
// Add optimistic local message
- widget.model.addMessage(
+ _model.addMessage(
ShopInBitMessage(text: text, timestamp: DateTime.now(), isFromUser: true),
);
setState(() {});
try {
- if (widget.model.apiTicketId != 0) {
+ if (_model.apiTicketId != 0) {
await ref
.read(pShopinBitService)
.client
- .sendMessage(widget.model.apiTicketId, text);
- // Reload messages from API to get accurate state
- await _loadFromApi();
+ .sendMessage(_model.apiTicketId, text);
+ // Pull fresh state from the API via the service so the watcher updates.
+ await _refresh();
}
final db = ref.read(pSharedDrift);
unawaited(
db
.into(db.shopInBitTickets)
- .insertOnConflictUpdate(widget.model.toCompanion()),
+ .insertOnConflictUpdate(_model.toCompanion()),
);
} catch (_) {
// Keep optimistic local message
@@ -163,7 +117,7 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
setState(() => _retrying = true);
try {
- final model = widget.model;
+ final model = _model;
final customerKey = await ref.read(pShopinBitService).ensureCustomerKey();
final comment =
"${model.requestDescription}\n\n"
@@ -388,7 +342,9 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
- final model = widget.model;
+ final service = ref.watch(pShopInBitOrdersService);
+ final model = service.get(_model.apiTicketId) ?? _model;
+ final isRefreshing = service.isRefreshing(_model.apiTicketId);
final statusBar = Padding(
padding: .only(bottom: isDesktop ? 12 : 8),
@@ -482,6 +438,17 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
)
: const SizedBox.shrink();
+ final chatList = ListView.builder(
+ reverse: true,
+ padding: const EdgeInsets.all(8),
+ physics: const AlwaysScrollableScrollPhysics(),
+ itemCount: model.messages.length,
+ itemBuilder: (context, index) {
+ final message = model.messages[model.messages.length - 1 - index];
+ return _chatBubble(message, isDesktop);
+ },
+ );
+
final chatArea = Expanded(
child: ConditionalParent(
condition: Util.isDesktop,
@@ -490,22 +457,7 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
color: Theme.of(context).extension<StackColors>()!.textFieldActiveBG,
child: child,
),
- child: Stack(
- children: [
- ListView.builder(
- reverse: true,
- padding: const EdgeInsets.all(8),
- itemCount: model.messages.length,
- itemBuilder: (context, index) {
- final message =
- model.messages[model.messages.length - 1 - index];
- return _chatBubble(message, isDesktop);
- },
- ),
- // TODO: fix loading from locking everything up
- if (_loading) const LoadingIndicator(width: 24, height: 24),
- ],
- ),
+ child: RefreshControl(onRefresh: _refresh, child: chatList),
),
);
@@ -588,8 +540,7 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
: const SizedBox.shrink();
final retryButton =
- widget.model.needsCreateRequest &&
- widget.model.category == ShopInBitCategory.car
+ model.needsCreateRequest && model.category == ShopInBitCategory.car
? Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: PrimaryButton(
@@ -634,9 +585,16 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail> {
style: STextStyles.desktopH3(context),
),
),
- DesktopDialogCloseButton(
- onPressedOverride: () =>
- confirmCloseNestedNavigatorDialog(context),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ RefreshButton(
+ isRefreshing: isRefreshing,
+ onPressed: _refresh,
+ ),
+ const SizedBox(width: 8),
+ const DesktopDialogCloseButton(),
+ ],
),
],
),
diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart
index c71782a..3b4daa7 100644
--- a/lib/pages/shopinbit/shopinbit_tickets_view.dart
+++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart
@@ -8,11 +8,10 @@ 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_service_provider.dart";
+import "../../providers/global/shopin_bit_orders_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";
@@ -20,6 +19,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/refresh_control.dart";
import "../../widgets/rounded_container.dart";
import "shopinbit_car_fee_view.dart";
import "shopinbit_car_research_payment_view.dart";
@@ -39,6 +39,7 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
List<ShopInBitOrderModel> _tickets = [];
ShopInBitTicket? _pendingTicket;
StreamSubscription<List<ShopInBitTicket>>? _ticketsSub;
+ bool _refreshing = false;
@override
void initState() {
@@ -54,7 +55,7 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
.toList();
});
});
- WidgetsBinding.instance.addPostFrameCallback((_) => _syncFromApi());
+ WidgetsBinding.instance.addPostFrameCallback((_) => _refresh());
}
@override
@@ -63,6 +64,16 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
super.dispose();
}
+ Future<void> _refresh() async {
+ if (_refreshing) return;
+ if (mounted) setState(() => _refreshing = true);
+ try {
+ await ref.read(pShopInBitOrdersService).refreshAll();
+ } finally {
+ if (mounted) setState(() => _refreshing = false);
+ }
+ }
+
void _resumeFlow(ShopInBitTicket pending) {
final model = ShopInBitOrderModel.fromDriftRow(pending);
final expiresAt = pending.carResearchExpiresAt;
@@ -110,75 +121,6 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
}
}
- Future<void> _syncFromApi() async {
- await showLoading(
- context: context,
- message: "Loading requests...",
- whileFutureAlt: _syncFromApiHelper,
- rootNavigator: Util.isDesktop,
- );
- }
-
- Future<void> _syncFromApiHelper() async {
- try {
- final service = ref.read(pShopinBitService);
- final customerKey = await service.ensureCustomerKey();
- final resp = await service.client.getTicketsByCustomer(customerKey);
-
- if (resp.hasError || resp.value == null) return;
-
- for (final ticketRef in resp.value!) {
- final localIdx = _tickets.indexWhere(
- (t) => t.apiTicketId == ticketRef.id,
- );
- if (localIdx < 0) continue;
-
- // Car research tickets return 403 on /tickets/:id/* endpoints.
- // if (_tickets[localIdx].category == ShopInBitCategory.car) continue;
-
- final statusResp = await service.client.getTicketStatus(ticketRef.id);
- if (statusResp.hasError || statusResp.value == null) continue;
-
- _tickets[localIdx].status = ShopInBitOrderModel.statusFromTicketState(
- statusResp.value!.state,
- );
-
- if (_tickets[localIdx].status == ShopInBitOrderStatus.offerAvailable &&
- (_tickets[localIdx].offerProductName == null ||
- _tickets[localIdx].offerPrice == null)) {
- final offerResp = await service.client.getTicketFull(ticketRef.id);
- if (!offerResp.hasError && offerResp.value != null) {
- _tickets[localIdx].setOffer(
- productName: offerResp.value!.productName,
- price: offerResp.value!.customerPrice,
- );
- }
- }
-
- final msgsResp = await service.client.getMessages(ticketRef.id);
- if (!msgsResp.hasError && msgsResp.value != null) {
- _tickets[localIdx].clearMessages();
- for (final m in msgsResp.value!) {
- _tickets[localIdx].addMessage(
- ShopInBitMessage(
- text: m.content,
- timestamp: m.timestamp,
- isFromUser: !m.fromAgent,
- ),
- );
- }
- }
-
- final db = ref.read(pSharedDrift);
- await db
- .into(db.shopInBitTickets)
- .insertOnConflictUpdate(_tickets[localIdx].toCompanion());
- }
- } catch (_) {
- // Fall back to local data — stream listener still has whatever was last persisted.
- }
- }
-
static String _categoryLabel(ShopInBitCategory? category) =>
switch (category) {
ShopInBitCategory.concierge => "Concierge",
@@ -187,6 +129,73 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
null => "",
};
+ List<Widget> _buildListChildren({
+ required BuildContext context,
+ required bool isDesktop,
+ required ShopInBitTicket? pending,
+ required bool hasTickets,
+ }) {
+ if (pending == null && !hasTickets) {
+ return [
+ const SizedBox(height: 80),
+ Center(
+ child: Text(
+ _refreshing ? "Loading requests..." : "No requests yet",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.itemSubtitle(context),
+ ),
+ ),
+ ];
+ }
+
+ final children = <Widget>[];
+ if (pending != null) {
+ children.add(
+ RoundedContainer(
+ color: Theme.of(context).extension<StackColors>()!.popupBG,
+ onPressed: () => _resumeFlow(pending),
+ child: _RequestRow(
+ title: "Car Research (In Progress)",
+ subtitle: "Tap to continue your car research payment",
+ badgeText: "Resume",
+ badgeColor: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorYellow,
+ ),
+ ),
+ );
+ if (hasTickets) children.add(SizedBox(height: isDesktop ? 16 : 12));
+ }
+ 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(
+ padding: EdgeInsets.all(isDesktop ? 16 : 12),
+ borderColor: isDesktop
+ ? Theme.of(context).extension<StackColors>()!.textFieldDefaultBG
+ : null,
+ color: Theme.of(context).extension<StackColors>()!.popupBG,
+ onPressed: () => Navigator.of(
+ context,
+ ).pushNamed(ShopInBitTicketDetail.routeName, arguments: ticket),
+ child: _RequestRow(
+ title: ticket.ticketId ?? "N/A",
+ subtitle:
+ "${_categoryLabel(ticket.category)} • "
+ "${ticket.requestDescription}",
+ badgeText: ticket.status.label,
+ badgeColor: ticket.status.getColor(
+ Theme.of(context).extension<StackColors>()!,
+ ),
+ ),
+ ),
+ );
+ }
+ return children;
+ }
+
@override
Widget build(BuildContext context) {
final isDesktop = Util.isDesktop;
@@ -211,7 +220,17 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
style: STextStyles.desktopH3(context),
),
),
- const DesktopDialogCloseButton(),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ RefreshButton(
+ isRefreshing: _refreshing,
+ onPressed: _refresh,
+ ),
+ const SizedBox(width: 8),
+ const DesktopDialogCloseButton(),
+ ],
+ ),
],
),
Flexible(
@@ -250,76 +269,21 @@ class _ShopInBitTicketsViewState extends ConsumerState<ShopInBitTicketsView> {
),
),
),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- mainAxisSize: .min,
- children: [
- if (pending == null && !hasTickets)
- Center(
- child: Text(
- "No requests yet",
- style: Util.isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- )
- else ...[
- if (pending != null) ...[
- RoundedContainer(
- color: Theme.of(context).extension<StackColors>()!.popupBG,
- onPressed: () => _resumeFlow(pending),
- child: _RequestRow(
- title: "Car Research (In Progress)",
- subtitle: "Tap to continue your car research payment",
- badgeText: "Resume",
- badgeColor: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorYellow,
- ),
- ),
- if (hasTickets) SizedBox(height: isDesktop ? 16 : 12),
- ],
- if (hasTickets)
- ListView.separated(
- shrinkWrap: true,
- primary: isDesktop ? false : null,
- itemCount: _tickets.length,
- separatorBuilder: (_, __) =>
- SizedBox(height: isDesktop ? 16 : 12),
- itemBuilder: (context, index) {
- final ticket = _tickets[index];
-
- return RoundedContainer(
- padding: .all(Util.isDesktop ? 16 : 12),
- borderColor: Util.isDesktop
- ? Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG
- : null,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.popupBG,
- onPressed: () => Navigator.of(context).pushNamed(
- ShopInBitTicketDetail.routeName,
- arguments: ticket,
- ),
- child: _RequestRow(
- title: ticket.ticketId ?? "N/A",
- subtitle:
- "${_categoryLabel(ticket.category)} \u2022 ${ticket.requestDescription}",
- badgeText: ticket.status.label,
- badgeColor: ticket.status.getColor(
- Theme.of(context).extension<StackColors>()!,
- ),
- ),
- );
- },
- ),
-
- // // TODO: fix loading from locking everything up
- // if (_syncing) const LoadingIndicator(width: 24, height: 24),
+ child: RefreshControl(
+ onRefresh: _refresh,
+ child: ListView(
+ shrinkWrap: true,
+ physics: const AlwaysScrollableScrollPhysics(),
+ primary: isDesktop ? false : null,
+ children: [
+ ..._buildListChildren(
+ context: context,
+ isDesktop: isDesktop,
+ pending: pending,
+ hasTickets: hasTickets,
+ ),
],
- ],
+ ),
),
),
);
diff --git a/lib/providers/global/cakepay_orders_provider.dart b/lib/providers/global/cakepay_orders_provider.dart
new file mode 100644
index 0000000..6f68348
--- /dev/null
+++ b/lib/providers/global/cakepay_orders_provider.dart
@@ -0,0 +1,7 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../services/cakepay/cakepay_orders_service.dart';
+
+final pCakePayOrdersService = ChangeNotifierProvider<CakePayOrdersService>(
+ (ref) => CakePayOrdersService(),
+);
diff --git a/lib/providers/global/shopin_bit_orders_provider.dart b/lib/providers/global/shopin_bit_orders_provider.dart
new file mode 100644
index 0000000..2e46a7e
--- /dev/null
+++ b/lib/providers/global/shopin_bit_orders_provider.dart
@@ -0,0 +1,9 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../services/shopinbit/shopinbit_orders_service.dart';
+import 'shopin_bit_service_provider.dart';
+
+final pShopInBitOrdersService = ChangeNotifierProvider<ShopInBitOrdersService>(
+ (ref) =>
+ ShopInBitOrdersService(shopInBitService: ref.read(pShopinBitService)),
+);
diff --git a/lib/services/cakepay/cakepay_orders_service.dart b/lib/services/cakepay/cakepay_orders_service.dart
new file mode 100644
index 0000000..625850a
--- /dev/null
+++ b/lib/services/cakepay/cakepay_orders_service.dart
@@ -0,0 +1,150 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import 'cakepay_service.dart';
+import 'src/models/order.dart';
+
+/// Holds an in-memory cache of CakePay orders, refreshes them in the
+/// background, and notifies listeners only when something actually changed.
+///
+/// Modelled on `PriceService` — see `lib/services/price_service.dart`.
+class CakePayOrdersService extends ChangeNotifier {
+ static const Duration defaultPollInterval = Duration(seconds: 15);
+
+ final Map<String, CakePayOrder> _orders = {};
+ final Set<String> _inflight = {};
+ final Map<String, _Poll> _polls = {};
+ bool _refreshingAll = false;
+
+ /// Current cached value for [orderId], or null if not yet fetched.
+ CakePayOrder? get(String orderId) => _orders[orderId];
+
+ /// Snapshot of all cached orders, sorted by `createdAt` descending.
+ List<CakePayOrder> get all {
+ final list = _orders.values.toList();
+ list.sort((a, b) {
+ final ac = a.createdAt;
+ final bc = b.createdAt;
+ if (ac == null && bc == null) return 0;
+ if (ac == null) return 1;
+ if (bc == null) return -1;
+ return bc.compareTo(ac);
+ });
+ return list;
+ }
+
+ bool isRefreshing(String orderId) => _inflight.contains(orderId);
+ bool get isRefreshingAll => _refreshingAll;
+
+ /// Fetch a single order. No-ops if a fetch for [orderId] is already in
+ /// flight.
+ Future<void> refreshOne(String orderId) async {
+ if (_inflight.contains(orderId)) return;
+ _inflight.add(orderId);
+ notifyListeners();
+ try {
+ final resp = await CakePayService.instance.client.getOrder(orderId);
+ if (!resp.hasError && resp.value != null) {
+ _putIfChanged(resp.value!);
+ }
+ } catch (_) {
+ // Silently leave the cached value in place.
+ } finally {
+ _inflight.remove(orderId);
+ notifyListeners();
+ }
+ }
+
+ /// Fetch every locally-tracked order in parallel.
+ Future<void> refreshAll() async {
+ if (_refreshingAll) return;
+ _refreshingAll = true;
+ notifyListeners();
+ try {
+ final ids = await CakePayService.instance.getOrderIds();
+ await Future.wait(ids.map(refreshOne));
+ } catch (_) {
+ // Listeners still hold whatever was cached.
+ } finally {
+ _refreshingAll = false;
+ notifyListeners();
+ }
+ }
+
+ /// Start (or join) a refcounted poll for [orderId]. The first call kicks off
+ /// an immediate refresh and creates the timer; subsequent calls just bump
+ /// the refcount. Each call must be paired with [stopPolling].
+ void startPolling(String orderId, {Duration interval = defaultPollInterval}) {
+ final existing = _polls[orderId];
+ if (existing != null) {
+ existing.refs += 1;
+ return;
+ }
+ final poll = _Poll(refs: 1, timer: null);
+ _polls[orderId] = poll;
+ // Immediate fetch.
+ unawaited(refreshOne(orderId));
+ poll.timer = Timer.periodic(interval, (_) {
+ final cached = _orders[orderId];
+ if (cached != null && _isTerminal(cached.status)) {
+ _cancel(orderId);
+ return;
+ }
+ unawaited(refreshOne(orderId));
+ });
+ }
+
+ void stopPolling(String orderId) {
+ final poll = _polls[orderId];
+ if (poll == null) return;
+ poll.refs -= 1;
+ if (poll.refs <= 0) {
+ _cancel(orderId);
+ }
+ }
+
+ void _cancel(String orderId) {
+ _polls.remove(orderId)?.timer?.cancel();
+ }
+
+ void _putIfChanged(CakePayOrder order) {
+ final existing = _orders[order.orderId];
+ if (existing == null || !_equals(existing, order)) {
+ _orders[order.orderId] = order;
+ }
+ }
+
+ static bool _isTerminal(CakePayOrderStatus s) =>
+ s == CakePayOrderStatus.complete ||
+ s == CakePayOrderStatus.expired ||
+ s == CakePayOrderStatus.failed ||
+ s == CakePayOrderStatus.refunded;
+
+ static bool _equals(CakePayOrder a, CakePayOrder b) {
+ return a.orderId == b.orderId &&
+ a.status == b.status &&
+ a.amountUsd == b.amountUsd &&
+ a.expirationTime == b.expirationTime &&
+ a.invoiceTime == b.invoiceTime &&
+ a.commission == b.commission &&
+ a.markupPercent == b.markupPercent &&
+ a.createdAt == b.createdAt &&
+ a.externalOrderId == b.externalOrderId;
+ }
+
+ @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_orders_service.dart b/lib/services/shopinbit/shopinbit_orders_service.dart
new file mode 100644
index 0000000..8651b4a
--- /dev/null
+++ b/lib/services/shopinbit/shopinbit_orders_service.dart
@@ -0,0 +1,196 @@
+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<int, ShopInBitOrderModel> _tickets = {};
+ final Set<int> _inflight = {};
+ final Map<int, _Poll> _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<void> 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,
+ );
+ if (model.status != newStatus) {
+ 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<void> 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<Future<void>> 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/widgets/refresh_control.dart b/lib/widgets/refresh_control.dart
new file mode 100644
index 0000000..f11b249
--- /dev/null
+++ b/lib/widgets/refresh_control.dart
@@ -0,0 +1,58 @@
+import 'package:flutter/material.dart';
+
+import '../themes/stack_colors.dart';
+import '../utilities/util.dart';
+import 'custom_buttons/app_bar_icon_button.dart';
+
+/// Wraps a scrollable [child] with a [RefreshIndicator] on mobile. On
+/// desktop, returns [child] unchanged — desktop screens place a
+/// [RefreshButton] in their dialog header instead.
+class RefreshControl extends StatelessWidget {
+ const RefreshControl({
+ super.key,
+ required this.onRefresh,
+ required this.child,
+ });
+
+ final Future<void> Function() onRefresh;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ if (Util.isDesktop) return child;
+ return RefreshIndicator(onRefresh: onRefresh, child: child);
+ }
+}
+
+/// Circular icon button for desktop screens. Shows a spinner while
+/// [isRefreshing] is true; otherwise a refresh icon. Disabled while
+/// refreshing so taps don't stack overlapping requests.
+class RefreshButton extends StatelessWidget {
+ const RefreshButton({
+ super.key,
+ required this.onPressed,
+ required this.isRefreshing,
+ this.tooltip = "Refresh",
+ });
+
+ final VoidCallback onPressed;
+ final bool isRefreshing;
+ final String tooltip;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = Theme.of(context).extension<StackColors>()!.textDark;
+ return AppBarIconButton(
+ tooltip: tooltip,
+ semanticsLabel: tooltip,
+ onPressed: isRefreshing ? null : onPressed,
+ icon: isRefreshing
+ ? SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2, color: color),
+ )
+ : Icon(Icons.refresh, color: color, size: 20),
+ );
+ }
+}
Why this scored 20/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.