diff --git a/crypto_plugins/flutter_libmwc b/crypto_plugins/flutter_libmwc index cb444bf93c..c8db22aed2 160000 --- a/crypto_plugins/flutter_libmwc +++ b/crypto_plugins/flutter_libmwc @@ -1 +1 @@ -Subproject commit cb444bf93c4c6e5305a3fb94641f42d908c199e9 +Subproject commit c8db22aed2c50aa1e95dfc532abb0a4961c543d7 diff --git a/lib/db/drift/shared_db/shared_database.g.dart b/lib/db/drift/shared_db/shared_database.g.dart index 24a3c83510..28c4c39910 100644 --- a/lib/db/drift/shared_db/shared_database.g.dart +++ b/lib/db/drift/shared_db/shared_database.g.dart @@ -538,6 +538,17 @@ class $ShopInBitTicketsTable extends ShopInBitTickets ).withConverter( $ShopInBitTicketsTable.$converterstatus, ); + static const VerificationMeta _statusRawMeta = const VerificationMeta( + 'statusRaw', + ); + @override + late final GeneratedColumn statusRaw = GeneratedColumn( + 'status_raw', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _requestDescriptionMeta = const VerificationMeta('requestDescription'); @override @@ -762,6 +773,7 @@ class $ShopInBitTicketsTable extends ShopInBitTickets displayName, category, status, + statusRaw, requestDescription, deliveryCountry, offerProductName, @@ -813,6 +825,12 @@ class $ShopInBitTicketsTable extends ShopInBitTickets } else if (isInserting) { context.missing(_displayNameMeta); } + if (data.containsKey('status_raw')) { + context.handle( + _statusRawMeta, + statusRaw.isAcceptableOrUnknown(data['status_raw']!, _statusRawMeta), + ); + } if (data.containsKey('request_description')) { context.handle( _requestDescriptionMeta, @@ -1020,6 +1038,10 @@ class $ShopInBitTicketsTable extends ShopInBitTickets data['${effectivePrefix}status'], )!, ), + statusRaw: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status_raw'], + ), requestDescription: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}request_description'], @@ -1121,6 +1143,7 @@ class ShopInBitTicket extends DataClass implements Insertable { final String displayName; final ShopInBitCategory category; final ShopInBitOrderStatus status; + final String? statusRaw; final String requestDescription; final String deliveryCountry; final String? offerProductName; @@ -1145,6 +1168,7 @@ class ShopInBitTicket extends DataClass implements Insertable { required this.displayName, required this.category, required this.status, + this.statusRaw, required this.requestDescription, required this.deliveryCountry, this.offerProductName, @@ -1180,6 +1204,9 @@ class ShopInBitTicket extends DataClass implements Insertable { $ShopInBitTicketsTable.$converterstatus.toSql(status), ); } + if (!nullToAbsent || statusRaw != null) { + map['status_raw'] = Variable(statusRaw); + } map['request_description'] = Variable(requestDescription); map['delivery_country'] = Variable(deliveryCountry); if (!nullToAbsent || offerProductName != null) { @@ -1228,6 +1255,9 @@ class ShopInBitTicket extends DataClass implements Insertable { displayName: Value(displayName), category: Value(category), status: Value(status), + statusRaw: statusRaw == null && nullToAbsent + ? const Value.absent() + : Value(statusRaw), requestDescription: Value(requestDescription), deliveryCountry: Value(deliveryCountry), offerProductName: offerProductName == null && nullToAbsent @@ -1278,6 +1308,7 @@ class ShopInBitTicket extends DataClass implements Insertable { status: $ShopInBitTicketsTable.$converterstatus.fromJson( serializer.fromJson(json['status']), ), + statusRaw: serializer.fromJson(json['statusRaw']), requestDescription: serializer.fromJson( json['requestDescription'], ), @@ -1323,6 +1354,7 @@ class ShopInBitTicket extends DataClass implements Insertable { 'status': serializer.toJson( $ShopInBitTicketsTable.$converterstatus.toJson(status), ), + 'statusRaw': serializer.toJson(statusRaw), 'requestDescription': serializer.toJson(requestDescription), 'deliveryCountry': serializer.toJson(deliveryCountry), 'offerProductName': serializer.toJson(offerProductName), @@ -1356,6 +1388,7 @@ class ShopInBitTicket extends DataClass implements Insertable { String? displayName, ShopInBitCategory? category, ShopInBitOrderStatus? status, + Value statusRaw = const Value.absent(), String? requestDescription, String? deliveryCountry, Value offerProductName = const Value.absent(), @@ -1380,6 +1413,7 @@ class ShopInBitTicket extends DataClass implements Insertable { displayName: displayName ?? this.displayName, category: category ?? this.category, status: status ?? this.status, + statusRaw: statusRaw.present ? statusRaw.value : this.statusRaw, requestDescription: requestDescription ?? this.requestDescription, deliveryCountry: deliveryCountry ?? this.deliveryCountry, offerProductName: offerProductName.present @@ -1420,6 +1454,7 @@ class ShopInBitTicket extends DataClass implements Insertable { : this.displayName, 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, @@ -1483,6 +1518,7 @@ class ShopInBitTicket extends DataClass implements Insertable { ..write('displayName: $displayName, ') ..write('category: $category, ') ..write('status: $status, ') + ..write('statusRaw: $statusRaw, ') ..write('requestDescription: $requestDescription, ') ..write('deliveryCountry: $deliveryCountry, ') ..write('offerProductName: $offerProductName, ') @@ -1512,6 +1548,7 @@ class ShopInBitTicket extends DataClass implements Insertable { displayName, category, status, + statusRaw, requestDescription, deliveryCountry, offerProductName, @@ -1540,6 +1577,7 @@ class ShopInBitTicket extends DataClass implements Insertable { other.displayName == this.displayName && other.category == this.category && other.status == this.status && + other.statusRaw == this.statusRaw && other.requestDescription == this.requestDescription && other.deliveryCountry == this.deliveryCountry && other.offerProductName == this.offerProductName && @@ -1566,6 +1604,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { final Value displayName; final Value category; final Value status; + final Value statusRaw; final Value requestDescription; final Value deliveryCountry; final Value offerProductName; @@ -1591,6 +1630,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { this.displayName = 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.offerProductName = const Value.absent(), @@ -1617,6 +1657,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { required String displayName, required ShopInBitCategory category, required ShopInBitOrderStatus status, + this.statusRaw = const Value.absent(), required String requestDescription, required String deliveryCountry, this.offerProductName = const Value.absent(), @@ -1658,6 +1699,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { Expression? displayName, Expression? category, Expression? status, + Expression? statusRaw, Expression? requestDescription, Expression? deliveryCountry, Expression? offerProductName, @@ -1684,6 +1726,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { if (displayName != null) 'display_name': displayName, 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 (offerProductName != null) 'offer_product_name': offerProductName, @@ -1717,6 +1760,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { Value? displayName, Value? category, Value? status, + Value? statusRaw, Value? requestDescription, Value? deliveryCountry, Value? offerProductName, @@ -1743,6 +1787,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { displayName: displayName ?? this.displayName, category: category ?? this.category, status: status ?? this.status, + statusRaw: statusRaw ?? this.statusRaw, requestDescription: requestDescription ?? this.requestDescription, deliveryCountry: deliveryCountry ?? this.deliveryCountry, offerProductName: offerProductName ?? this.offerProductName, @@ -1786,6 +1831,9 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { $ShopInBitTicketsTable.$converterstatus.toSql(status.value), ); } + if (statusRaw.present) { + map['status_raw'] = Variable(statusRaw.value); + } if (requestDescription.present) { map['request_description'] = Variable(requestDescription.value); } @@ -1864,6 +1912,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { ..write('displayName: $displayName, ') ..write('category: $category, ') ..write('status: $status, ') + ..write('statusRaw: $statusRaw, ') ..write('requestDescription: $requestDescription, ') ..write('deliveryCountry: $deliveryCountry, ') ..write('offerProductName: $offerProductName, ') @@ -2230,6 +2279,7 @@ typedef $$ShopInBitTicketsTableCreateCompanionBuilder = required String displayName, required ShopInBitCategory category, required ShopInBitOrderStatus status, + Value statusRaw, required String requestDescription, required String deliveryCountry, Value offerProductName, @@ -2257,6 +2307,7 @@ typedef $$ShopInBitTicketsTableUpdateCompanionBuilder = Value displayName, Value category, Value status, + Value statusRaw, Value requestDescription, Value deliveryCountry, Value offerProductName, @@ -2314,6 +2365,11 @@ class $$ShopInBitTicketsTableFilterComposer builder: (column) => ColumnWithTypeConverterFilters(column), ); + ColumnFilters get statusRaw => $composableBuilder( + column: $table.statusRaw, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get requestDescription => $composableBuilder( column: $table.requestDescription, builder: (column) => ColumnFilters(column), @@ -2444,6 +2500,11 @@ class $$ShopInBitTicketsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get statusRaw => $composableBuilder( + column: $table.statusRaw, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get requestDescription => $composableBuilder( column: $table.requestDescription, builder: (column) => ColumnOrderings(column), @@ -2563,6 +2624,9 @@ class $$ShopInBitTicketsTableAnnotationComposer GeneratedColumnWithTypeConverter get status => $composableBuilder(column: $table.status, builder: (column) => column); + GeneratedColumn get statusRaw => + $composableBuilder(column: $table.statusRaw, builder: (column) => column); + GeneratedColumn get requestDescription => $composableBuilder( column: $table.requestDescription, builder: (column) => column, @@ -2697,6 +2761,7 @@ class $$ShopInBitTicketsTableTableManager Value displayName = 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 offerProductName = const Value.absent(), @@ -2723,6 +2788,7 @@ class $$ShopInBitTicketsTableTableManager displayName: displayName, category: category, status: status, + statusRaw: statusRaw, requestDescription: requestDescription, deliveryCountry: deliveryCountry, offerProductName: offerProductName, @@ -2750,6 +2816,7 @@ class $$ShopInBitTicketsTableTableManager required String displayName, required ShopInBitCategory category, required ShopInBitOrderStatus status, + Value statusRaw = const Value.absent(), required String requestDescription, required String deliveryCountry, Value offerProductName = const Value.absent(), @@ -2775,6 +2842,7 @@ class $$ShopInBitTicketsTableTableManager displayName: displayName, category: category, status: status, + statusRaw: statusRaw, requestDescription: requestDescription, deliveryCountry: deliveryCountry, offerProductName: offerProductName, 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 b8afcc969f..450053a20e 100644 --- a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart +++ b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart @@ -12,6 +12,7 @@ class ShopInBitTickets extends Table { IntColumn get category => intEnum()(); IntColumn get status => intEnum()(); + TextColumn get statusRaw => text().nullable()(); TextColumn get requestDescription => text()(); TextColumn get deliveryCountry => text()(); diff --git a/lib/electrumx_rpc/electrumx_client.dart b/lib/electrumx_rpc/electrumx_client.dart index abcb01296a..94b650b103 100644 --- a/lib/electrumx_rpc/electrumx_client.dart +++ b/lib/electrumx_rpc/electrumx_client.dart @@ -117,6 +117,8 @@ class ElectrumXClient { final Mutex _torConnectingLock = Mutex(); bool _requireMutex = false; + final _adapterMutex = Mutex(); + ElectrumXClient({ required String host, required int port, @@ -219,111 +221,115 @@ class ElectrumXClient { } Future checkElectrumAdapter() async { - ({InternetAddress host, int port})? proxyInfo; - - if (AppConfig.hasFeature(AppFeature.tor)) { - // If we're supposed to use Tor... - if (_prefs.useTor) { - // But Tor isn't running... - if (_torService.status != TorConnectionStatus.connected) { - // And the killswitch isn't set... - if (!_prefs.torKillSwitch) { - // Then we'll just proceed and connect to ElectrumX through - // clearnet at the bottom of this function. - Logging.instance.w( - "Tor preference set but Tor is not enabled, killswitch not set," - " connecting to Electrum adapter through clearnet", - ); + await _adapterMutex.protect(() async { + ({InternetAddress host, int port})? proxyInfo; + + if (AppConfig.hasFeature(AppFeature.tor)) { + // If we're supposed to use Tor... + if (_prefs.useTor) { + // But Tor isn't running... + if (_torService.status != TorConnectionStatus.connected) { + // And the killswitch isn't set... + if (!_prefs.torKillSwitch) { + // Then we'll just proceed and connect to ElectrumX through + // clearnet at the bottom of this function. + Logging.instance.w( + "Tor preference set but Tor is not enabled, killswitch not set," + " connecting to Electrum adapter through clearnet", + ); + } else { + // ... But if the killswitch is set, then we throw an exception. + throw Exception( + "Tor preference and killswitch set but Tor is not enabled, " + "not connecting to Electrum adapter", + ); + // TODO [prio=low]: Try to start Tor. + } } else { - // ... But if the killswitch is set, then we throw an exception. - throw Exception( - "Tor preference and killswitch set but Tor is not enabled, " - "not connecting to Electrum adapter", + // Get the proxy info from the TorService. + proxyInfo = _torService.getProxyInfo(); + } + + if (netType == TorPlainNetworkOption.clear) { + _electrumAdapterChannel = null; + await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, ); - // TODO [prio=low]: Try to start Tor. } } else { - // Get the proxy info from the TorService. - proxyInfo = _torService.getProxyInfo(); - } - - if (netType == TorPlainNetworkOption.clear) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - ); - } - } else { - if (netType == TorPlainNetworkOption.tor) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove( - cryptoCurrency: cryptoCurrency, - ); + if (netType == TorPlainNetworkOption.tor) { + _electrumAdapterChannel = null; + await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, + ); + } } } - } - // If the current ElectrumAdapterClient is closed, create a new one. - if (getElectrumAdapter() != null && getElectrumAdapter()!.peer.isClosed) { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove(cryptoCurrency: cryptoCurrency); - } - - final String useHost; - final int usePort; - final bool useUseSSL; - - if (currentFailoverIndex == -1) { - useHost = host; - usePort = port; - useUseSSL = useSSL; - } else { - _electrumAdapterChannel = null; - await ClientManager.sharedInstance.remove(cryptoCurrency: cryptoCurrency); - useHost = _failovers[currentFailoverIndex].address; - usePort = _failovers[currentFailoverIndex].port; - useUseSSL = _failovers[currentFailoverIndex].useSSL; - } + // If the current ElectrumAdapterClient is closed, create a new one. + if (getElectrumAdapter() != null && getElectrumAdapter()!.peer.isClosed) { + _electrumAdapterChannel = null; + await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, + ); + } - _electrumAdapterChannel ??= await electrum_adapter.connect( - useHost, - port: usePort, - connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, - aliveTimerDuration: connectionTimeoutForSpecialCaseJsonRPCClients, - acceptUnverified: false, - useSSL: useUseSSL, - proxyInfo: proxyInfo, - ); + final String useHost; + final int usePort; + final bool useUseSSL; - if (getElectrumAdapter() == null) { - final ElectrumClient newClient; - if (cryptoCurrency is Firo) { - newClient = FiroElectrumClient( - _electrumAdapterChannel!, - useHost, - usePort, - useUseSSL, - proxyInfo, - ); + if (currentFailoverIndex == -1) { + useHost = host; + usePort = port; + useUseSSL = useSSL; } else { - newClient = ElectrumClient( - _electrumAdapterChannel!, - useHost, - usePort, - useUseSSL, - proxyInfo, + _electrumAdapterChannel = null; + await ClientManager.sharedInstance.remove( + cryptoCurrency: cryptoCurrency, ); + useHost = _failovers[currentFailoverIndex].address; + usePort = _failovers[currentFailoverIndex].port; + useUseSSL = _failovers[currentFailoverIndex].useSSL; } - await newClient.request('server.version'); - - await ClientManager.sharedInstance.addClient( - newClient, - cryptoCurrency: cryptoCurrency, - netType: netType, + _electrumAdapterChannel ??= await electrum_adapter.connect( + useHost, + port: usePort, + connectionTimeout: connectionTimeoutForSpecialCaseJsonRPCClients, + aliveTimerDuration: connectionTimeoutForSpecialCaseJsonRPCClients, + acceptUnverified: false, + useSSL: useUseSSL, + proxyInfo: proxyInfo, ); - } - return; + if (getElectrumAdapter() == null) { + final ElectrumClient newClient; + if (cryptoCurrency is Firo) { + newClient = FiroElectrumClient( + _electrumAdapterChannel!, + useHost, + usePort, + useUseSSL, + proxyInfo, + ); + } else { + newClient = ElectrumClient( + _electrumAdapterChannel!, + useHost, + usePort, + useUseSSL, + proxyInfo, + ); + } + + await newClient.request('server.version'); + + await ClientManager.sharedInstance.addClient( + newClient, + cryptoCurrency: cryptoCurrency, + netType: netType, + ); + } + }); } /// Send raw rpc command diff --git a/lib/models/shopinbit/shopinbit_order_model.dart b/lib/models/shopinbit/shopinbit_order_model.dart index c7d4e4df2c..14b530475d 100644 --- a/lib/models/shopinbit/shopinbit_order_model.dart +++ b/lib/models/shopinbit/shopinbit_order_model.dart @@ -142,6 +142,19 @@ class ShopInBitOrderModel extends ChangeNotifier { } } + // 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; @@ -277,6 +290,7 @@ class ShopInBitOrderModel extends ChangeNotifier { displayName: Value(_displayName), category: Value(_category ?? ShopInBitCategory.concierge), status: Value(_status), + statusRaw: Value(_statusRaw), requestDescription: Value(_requestDescription), deliveryCountry: Value(_deliveryCountry), offerProductName: Value(_offerProductName), @@ -316,6 +330,7 @@ class ShopInBitOrderModel extends ChangeNotifier { .._apiTicketId = ticket.apiTicketId .._ticketId = ticket.ticketId .._status = ticket.status + .._statusRaw = ticket.statusRaw .._requestDescription = ticket.requestDescription .._deliveryCountry = ticket.deliveryCountry .._offerProductName = ticket.offerProductName @@ -335,7 +350,7 @@ class ShopInBitOrderModel extends ChangeNotifier { .._messages = messages; } - static ShopInBitOrderStatus statusFromTicketState(TicketState state) { + static ShopInBitOrderStatus? statusFromTicketState(TicketState state) { switch (state) { case TicketState.newTicket: return ShopInBitOrderStatus.pending; @@ -360,6 +375,8 @@ class ShopInBitOrderModel extends ChangeNotifier { return ShopInBitOrderStatus.cancelled; case TicketState.refunded: return ShopInBitOrderStatus.refunded; + case TicketState.unknown: + return null; } } } diff --git a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart index c44c5f2645..e650564a18 100644 --- a/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart +++ b/lib/pages/add_wallet_views/restore_wallet_view/restore_options_view/restore_options_view.dart @@ -178,7 +178,7 @@ class _RestoreOptionsViewState extends ConsumerState { } if (mounted) { - final date = await showSWDatePicker(context); + final date = (await showSWDatePicker(context))?.first; if (date != null) { _restoreFromDate = date; _dateController.text = Format.formatDate(date); @@ -187,7 +187,7 @@ class _RestoreOptionsViewState extends ConsumerState { } Future chooseDesktopDate() async { - final date = await showSWDatePicker(context); + final date = (await showSWDatePicker(context))?.first; if (date != null) { _restoreFromDate = date; _dateController.text = Format.formatDate(date); diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index 7488d0e990..aa693c7d84 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'; @@ -25,6 +25,7 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.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'; @@ -41,56 +42,62 @@ class CakePayOrderView extends ConsumerStatefulWidget { } class _CakePayOrderViewState extends ConsumerState { - 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) { @@ -213,41 +220,6 @@ class _CakePayOrderViewState extends ConsumerState { ); } - Future _loadOrder() async { - await showLoading( - context: context, - message: "Updating order...", - whileFutureAlt: _loadOrderHelper, - rootNavigator: Util.isDesktop, - ); - } - - Future _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) { @@ -329,7 +301,13 @@ class _CakePayOrderViewState extends ConsumerState { 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 = [ @@ -372,6 +350,7 @@ class _CakePayOrderViewState extends ConsumerState { }, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "Order ID", @@ -379,18 +358,23 @@ class _CakePayOrderViewState extends ConsumerState { ? 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), + ], + ), ), ], ), @@ -523,7 +507,7 @@ class _CakePayOrderViewState extends ConsumerState { // Expiration countdown. if (order.expirationTime != null) { - final isExpired = _timeRemaining == Duration.zero; + final isExpired = remaining == Duration.zero; details.add( RoundedWhiteContainer( child: Row( @@ -536,7 +520,7 @@ class _CakePayOrderViewState extends ConsumerState { : STextStyles.itemSubtitle12(context), ), Text( - _formatDuration(_timeRemaining), + _formatDuration(remaining), style: (isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) @@ -546,7 +530,7 @@ class _CakePayOrderViewState extends ConsumerState { ? Theme.of( context, ).extension()!.accentColorRed - : _timeRemaining.inMinutes < 5 + : remaining.inMinutes < 5 ? Theme.of( context, ).extension()!.accentColorOrange @@ -839,17 +823,33 @@ class _CakePayOrderViewState extends ConsumerState { 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 Function() onRefresh, + required Widget child, + }) { return ConditionalParent( condition: isDesktop, builder: (child) => SDialog( @@ -865,7 +865,17 @@ class _CakePayOrderViewState extends ConsumerState { padding: const EdgeInsets.only(left: 32), child: Text("Order", style: STextStyles.desktopH3(context)), ), - const DesktopDialogCloseButton(), + Row( + mainAxisSize: .min, + children: [ + RefreshButton( + isRefreshing: isRefreshing, + onPressed: () => onRefresh(), + ), + const SizedBox(width: 8), + const DesktopDialogCloseButton(), + ], + ), ], ), Flexible( diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index f808fcf7bc..0e476089fa 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 createState() => _CakePayOrdersViewState(); + ConsumerState createState() => _CakePayOrdersViewState(); } -class _CakePayOrdersViewState extends State { - List _orders = []; - +class _CakePayOrdersViewState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _syncFromApi()); - } - - Future _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 _syncFromApiHelper() async { - try { - final orderIds = await CakePayService.instance.getOrderIds(); - final results = []; - - 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 = []; + 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()!.textFieldDefaultBG + : null, + color: Theme.of(context).extension()!.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()!, + ) + .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()!, + ), + ), + ), + ), + ], + ), + 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()!.textSubtitle1, + ), + ), + ], + ], + ), + ), + SizedBox(width: isDesktop ? 16 : 8), + Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ], + ), + ), + ); } - } catch (_) { - // Fall back to empty list — no local cache to fall back on } - } - @override - Widget build(BuildContext context) { - final isDesktop = Util.isDesktop; + Future 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 { child: SizedBox( width: 580, child: Column( - mainAxisSize: .min, + mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -85,7 +179,17 @@ class _CakePayOrdersViewState extends State { 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 { ), ), ), - 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()!.textFieldDefaultBG - : null, - color: Theme.of(context).extension()!.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()!, - ) - .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()!, - ), - ), - ), - ), - ], - ), - 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()! - .textSubtitle1, - ), - ), - ], - ], - ), - ), - SizedBox(width: isDesktop ? 16 : 8), - Icon( - Icons.chevron_right, - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ), - ], - ), - ); - }, - ), + child: body, ), ); } diff --git a/lib/pages/cakepay/cakepay_vendors_view.dart b/lib/pages/cakepay/cakepay_vendors_view.dart index d04f05baf3..59b145825b 100644 --- a/lib/pages/cakepay/cakepay_vendors_view.dart +++ b/lib/pages/cakepay/cakepay_vendors_view.dart @@ -4,7 +4,6 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../../services/cakepay/cakepay_service.dart'; import '../../services/cakepay/src/models/card.dart'; -import '../../services/cakepay/src/models/vendor.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; @@ -15,7 +14,9 @@ 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/secondary_button.dart'; import '../../widgets/icon_widgets/credit_card_icon.dart'; +import '../../widgets/infinite_scroll_list_view.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/stack_text_field.dart'; @@ -31,20 +32,27 @@ class CakePayVendorsView extends StatefulWidget { } class _CakePayVendorsViewState extends State { - List _vendors = []; List _countryNames = []; String? _selectedCountry; + String? _searchQuery; bool _loading = true; - String? _error; final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(); final _countrySearchController = TextEditingController(); + final _listController = InfiniteScrollListController(); + @override void initState() { super.initState(); - _loadVendors(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + _countryNames = await CakePayService.instance.getCountryNames(); + } finally { + if (mounted) setState(() => _loading = false); + } + }); } @override @@ -55,46 +63,27 @@ class _CakePayVendorsViewState extends State { super.dispose(); } - List _availableCards() => - _vendors.expand((v) => v.cards.where((c) => c.available)).toList(); - - /// Derive a country list from the loaded vendors so we don't need the - /// broken /marketplace/countries/ endpoint. - void _deriveCountries() { - final seen = {}; - _countryNames = - _vendors - .map((v) => v.country) - .whereType() - .where((c) => c.isNotEmpty && seen.add(c)) - .toList() - ..sort(); - } - - Future _loadVendors() async { - setState(() { - _loading = true; - _error = null; - }); - - final resp = await CakePayService.instance.client.getVendors( + Future<({List cards, int? nextPage})> _fetchCards( + int page, + ) async { + final response = await CakePayService.instance.client.getVendors( + page: page, + pageSize: 50, country: _selectedCountry, - search: _searchController.text.trim().isNotEmpty - ? _searchController.text.trim() - : null, + search: _searchQuery, ); - if (!mounted) return; + if (response.hasError || response.value == null) { + throw response.exception ?? + Exception("Unknown exception with value is null????"); + } - setState(() { - _loading = false; - if (!resp.hasError && resp.value != null) { - _vendors = resp.value!; - _deriveCountries(); - } else { - _error = resp.exception?.message ?? "Failed to load gift cards"; - } - }); + return ( + cards: response.value!.vendors + .expand((e) => e.cards.where((e) => e.available)) + .toList(), + nextPage: response.value!.nextPage, + ); } Future _onCardTapped(CakePayCard card) async { @@ -106,7 +95,6 @@ class _CakePayVendorsViewState extends State { @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; - final cards = _availableCards(); return ConditionalParent( condition: isDesktop, @@ -167,7 +155,10 @@ class _CakePayVendorsViewState extends State { _SearchField( controller: _searchController, focusNode: _searchFocusNode, - onSubmitted: (_) => _loadVendors(), + onSubmitted: (value) { + setState(() => _searchQuery = value); + _listController.refresh(); + }, ), if (_countryNames.isNotEmpty) ...[ SizedBox(height: isDesktop ? 12 : 12), @@ -177,33 +168,79 @@ class _CakePayVendorsViewState extends State { searchController: _countrySearchController, onChanged: (value) { setState(() => _selectedCountry = value); - _loadVendors(); + _listController.refresh(); }, ), ], SizedBox(height: isDesktop ? 16 : 12), Expanded( child: _loading - ? const LoadingIndicator(width: 48, height: 48) - : cards.isEmpty - ? Center( - child: Text( - _error ?? "No gift cards found", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - ) - : ListView.separated( - shrinkWrap: isDesktop, - primary: isDesktop ? false : null, - itemCount: cards.length, + ? const LoadingIndicator(width: 64, height: 64) + : InfiniteScrollListView( + controller: _listController, + prefetchThreshold: 300, padding: .only(bottom: isDesktop ? 32 : 16), - separatorBuilder: (_, __) => + firstPageKey: 1, + separatorBuilder: (_, _) => SizedBox(height: isDesktop ? 16 : 12), - itemBuilder: (_, index) => _CardTile( - card: cards[index], - onTap: () => _onCardTapped(cards[index]), + fetchPage: (pageKey) async { + final result = await _fetchCards(pageKey); + return InfiniteScrollPage( + items: result.cards, + nextPageKey: result.nextPage, + ); + }, + itemBuilder: (context, item, index) { + return _CardTile( + card: item, + onTap: () => _onCardTapped(item), + ); + }, + firstPageProgressBuilder: (_) => + const LoadingIndicator(width: 64, height: 64), + newPageProgressBuilder: (_) => const Center( + child: Padding( + padding: .all(16), + child: LoadingIndicator(width: 48, height: 48), + ), + ), + emptyBuilder: (_) => Center( + child: Padding( + padding: const .all(24), + child: Text( + "No items", + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + ), + newPageErrorBuilder: (context, error, retry) => Center( + child: Padding( + padding: const .all(16), + child: Column( + mainAxisSize: .min, + children: [ + Text( + error.toString(), + style: STextStyles.w500_14(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + const SizedBox(height: 16), + SecondaryButton( + label: "Retry", + buttonHeight: isDesktop ? .s : .l, + width: 100, + onPressed: retry, + ), + ], + ), + ), ), ), ), diff --git a/lib/pages/home_view/home_view.dart b/lib/pages/home_view/home_view.dart index 9c5af137cf..e464edb52f 100644 --- a/lib/pages/home_view/home_view.dart +++ b/lib/pages/home_view/home_view.dart @@ -491,7 +491,7 @@ class _HomeViewState extends ConsumerState { previous, next, ) { - if (next is int && next >= 0 && next <= 2) { + if (next >= 0 && next < _children.length) { // if (next == 1) { // _exchangeDataLoadingService.loadAll(ref); // } diff --git a/lib/pages/home_view/sub_widgets/home_view_button_bar.dart b/lib/pages/home_view/sub_widgets/home_view_button_bar.dart index 9741603ac5..962b7dfb6f 100644 --- a/lib/pages/home_view/sub_widgets/home_view_button_bar.dart +++ b/lib/pages/home_view/sub_widgets/home_view_button_bar.dart @@ -44,144 +44,78 @@ class _HomeViewButtonBarState extends ConsumerState { @override Widget build(BuildContext context) { - final selectedIndex = ref.watch(homeViewPageIndexStateProvider.state).state; return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Expanded( - child: TextButton( - style: selectedIndex == 0 - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ) - : Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ), - onPressed: () { - FocusScope.of(context).unfocus(); - if (selectedIndex != 0) { - ref.read(homeViewPageIndexStateProvider.state).state = 0; - } - }, - child: Text( - "Wallets", - style: STextStyles.button(context).copyWith( - fontSize: 14, - color: selectedIndex == 0 - ? Theme.of(context) - .extension()! - .buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextSecondary, - ), - ), - ), + const Expanded( + child: _HomeViewTopMenuButton(index: 0, label: "Wallets"), ), + + if (AppConfig.hasFeature(AppFeature.swap)) const SizedBox(width: 8), if (AppConfig.hasFeature(AppFeature.swap)) - const SizedBox( - width: 8, - ), - if (AppConfig.hasFeature(AppFeature.swap)) - Expanded( - child: TextButton( - style: selectedIndex == 1 - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ) - : Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ), - onPressed: () async { - FocusScope.of(context).unfocus(); - if (selectedIndex != 1) { - ref.read(homeViewPageIndexStateProvider.state).state = 1; - } - // DateTime now = DateTime.now(); - // if (ref.read(prefsChangeNotifierProvider).externalCalls) { - // print("loading?"); - // await ExchangeDataLoadingService().loadAll(ref); - // } - // if (now.difference(_lastRefreshed) > _refreshInterval) { - // await ExchangeDataLoadingService().loadAll(ref); - // } - }, - child: Text( - "Swap", - style: STextStyles.button(context).copyWith( - fontSize: 14, - color: selectedIndex == 1 - ? Theme.of(context) - .extension()! - .buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextSecondary, - ), - ), - ), - ), - if (AppConfig.hasFeature(AppFeature.buy)) - const SizedBox( - width: 8, + const Expanded( + child: _HomeViewTopMenuButton(index: 1, label: "Swap"), ), + + if (AppConfig.hasFeature(AppFeature.buy)) const SizedBox(width: 8), if (AppConfig.hasFeature(AppFeature.buy)) - Expanded( - child: TextButton( - style: selectedIndex == 2 - ? Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ) - : Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context)! - .copyWith( - minimumSize: - MaterialStateProperty.all(const Size(46, 36)), - ), - onPressed: () async { - FocusScope.of(context).unfocus(); - if (selectedIndex != 2) { - ref.read(homeViewPageIndexStateProvider.state).state = 2; - } - // await BuyDataLoadingService().loadAll(ref); - }, - child: Text( - "Buy", - style: STextStyles.button(context).copyWith( - fontSize: 14, - color: selectedIndex == 2 - ? Theme.of(context) - .extension()! - .buttonTextPrimary - : Theme.of(context) - .extension()! - .buttonTextSecondary, + const Expanded(child: _HomeViewTopMenuButton(index: 2, label: "Buy")), + ], + ); + } +} + +class _HomeViewTopMenuButton extends ConsumerWidget { + const _HomeViewTopMenuButton({ + super.key, + required this.index, + required this.label, + }); + + final int index; + final String label; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedIndex = ref.watch(homeViewPageIndexStateProvider); + return TextButton( + style: selectedIndex == index + ? Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context)! + .copyWith( + minimumSize: MaterialStateProperty.all( + const Size(46, 36), + ), + ) + : Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context)! + .copyWith( + minimumSize: MaterialStateProperty.all( + const Size(46, 36), + ), ), - ), - ), + onPressed: () async { + FocusScope.of(context).unfocus(); + if (selectedIndex != index) { + ref.read(homeViewPageIndexStateProvider.state).state = index; + } + }, + child: Padding( + padding: const .symmetric(horizontal: 8), + child: Text( + label, + style: STextStyles.button(context).copyWith( + fontSize: 14, + color: selectedIndex == index + ? Theme.of(context).extension()!.buttonTextPrimary + : Theme.of( + context, + ).extension()!.buttonTextSecondary, ), - ], + ), + ), ); } } diff --git a/lib/pages/more_view/services_view.dart b/lib/pages/more_view/services_view.dart index 403ac0bf3e..f99bfa92aa 100644 --- a/lib/pages/more_view/services_view.dart +++ b/lib/pages/more_view/services_view.dart @@ -1,4 +1,3 @@ -import 'package:drift/drift.dart' show TableOrViewStatements; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -262,25 +261,13 @@ class _ServicesViewState extends ConsumerState { onPressed: _showShopDialog, ), const SizedBox(height: 12), - StreamBuilder( - stream: ref - .watch(pSharedDrift) - .shopInBitTickets - .count() - .watchSingleOrNull(), - builder: (context, snapshot) { - final count = snapshot.data ?? 0; - return SecondaryButton( - label: count > 0 - ? "My requests ($count)" - : "My requests", - onPressed: () async { - await Navigator.of( - context, - ).pushNamed(ShopInBitTicketsView.routeName); - if (mounted) setState(() {}); - }, - ); + SecondaryButton( + label: "My requests", + onPressed: () async { + await Navigator.of( + context, + ).pushNamed(ShopInBitTicketsView.routeName); + if (mounted) setState(() {}); }, ), ], diff --git a/lib/pages/ordinals/ordinals_filter_view.dart b/lib/pages/ordinals/ordinals_filter_view.dart index 93c7e0dd70..660a0f8178 100644 --- a/lib/pages/ordinals/ordinals_filter_view.dart +++ b/lib/pages/ordinals/ordinals_filter_view.dart @@ -125,10 +125,9 @@ class _OrdinalsFilterViewState extends ConsumerState { return Text( isDateSelected ? "From..." : _fromDateString, style: STextStyles.fieldLabel(context).copyWith( - color: - isDateSelected - ? Theme.of(context).extension()!.textSubtitle2 - : Theme.of(context).extension()!.accentColorDark, + color: isDateSelected + ? Theme.of(context).extension()!.textSubtitle2 + : Theme.of(context).extension()!.accentColorDark, ), ); } @@ -138,10 +137,9 @@ class _OrdinalsFilterViewState extends ConsumerState { return Text( isDateSelected ? "To..." : _toDateString, style: STextStyles.fieldLabel(context).copyWith( - color: - isDateSelected - ? Theme.of(context).extension()!.textSubtitle2 - : Theme.of(context).extension()!.accentColorDark, + color: isDateSelected + ? Theme.of(context).extension()!.textSubtitle2 + : Theme.of(context).extension()!.accentColorDark, ), ); } @@ -154,14 +152,13 @@ class _OrdinalsFilterViewState extends ConsumerState { const middleSeparatorWidth = 12.0; final isDesktop = Util.isDesktop; - final width = - isDesktop - ? null - : (MediaQuery.of(context).size.width - - (middleSeparatorWidth + - (2 * middleSeparatorPadding) + - (2 * Constants.size.standardPadding))) / - 2; + final width = isDesktop + ? null + : (MediaQuery.of(context).size.width - + (middleSeparatorWidth + + (2 * middleSeparatorPadding) + + (2 * Constants.size.standardPadding))) / + 2; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -177,7 +174,7 @@ class _OrdinalsFilterViewState extends ConsumerState { } if (mounted) { - final date = await showSWDatePicker(context); + final date = (await showSWDatePicker(context))?.first; if (date != null) { _selectedFromDate = date; @@ -193,15 +190,13 @@ class _OrdinalsFilterViewState extends ConsumerState { setState(() { if (flag) { - _toDateString = - _selectedToDate == null - ? "" - : Format.formatDate(_selectedToDate!); + _toDateString = _selectedToDate == null + ? "" + : Format.formatDate(_selectedToDate!); } - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); + _fromDateString = _selectedFromDate == null + ? "" + : Format.formatDate(_selectedFromDate!); }); } } @@ -209,18 +204,16 @@ class _OrdinalsFilterViewState extends ConsumerState { child: Container( width: width, decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), border: Border.all( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, width: 1, ), ), @@ -235,10 +228,9 @@ class _OrdinalsFilterViewState extends ConsumerState { Assets.svg.calendar, height: 20, width: 20, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), const SizedBox(width: 10), Align( @@ -272,7 +264,7 @@ class _OrdinalsFilterViewState extends ConsumerState { } if (mounted) { - final date = await showSWDatePicker(context); + final date = (await showSWDatePicker(context))?.first; if (date != null) { _selectedToDate = date; @@ -288,15 +280,13 @@ class _OrdinalsFilterViewState extends ConsumerState { setState(() { if (flag) { - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); + _fromDateString = _selectedFromDate == null + ? "" + : Format.formatDate(_selectedFromDate!); } - _toDateString = - _selectedToDate == null - ? "" - : Format.formatDate(_selectedToDate!); + _toDateString = _selectedToDate == null + ? "" + : Format.formatDate(_selectedToDate!); }); } } @@ -304,18 +294,16 @@ class _OrdinalsFilterViewState extends ConsumerState { child: Container( width: width, decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), border: Border.all( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, width: 1, ), ), @@ -330,10 +318,9 @@ class _OrdinalsFilterViewState extends ConsumerState { Assets.svg.calendar, height: 20, width: 20, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, + color: Theme.of( + context, + ).extension()!.textSubtitle2, ), const SizedBox(width: 10), Align( @@ -365,11 +352,13 @@ class _OrdinalsFilterViewState extends ConsumerState { } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, leading: AppBarBackButton( onPressed: () async { if (FocusScope.of(context).hasFocus) { @@ -573,10 +562,9 @@ class _OrdinalsFilterViewState extends ConsumerState { child: FittedBox( child: Text( "Date", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -588,10 +576,9 @@ class _OrdinalsFilterViewState extends ConsumerState { child: FittedBox( child: Text( "Inscription", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -609,51 +596,49 @@ class _OrdinalsFilterViewState extends ConsumerState { controller: _inscriptionTextEditingController, focusNode: inscriptionTextFieldFocusNode, onChanged: (_) => setState(() {}), - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Enter inscription number...", - keywordTextFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Enter inscription number...", + keywordTextFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ) + vertical: 10, + horizontal: 16, + ) : null, - suffixIcon: - _inscriptionTextEditingController.text.isNotEmpty + suffixIcon: + _inscriptionTextEditingController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _inscriptionTextEditingController.text = - ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _inscriptionTextEditingController.text = + ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -663,10 +648,9 @@ class _OrdinalsFilterViewState extends ConsumerState { child: FittedBox( child: Text( "Keyword", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -683,51 +667,48 @@ class _OrdinalsFilterViewState extends ConsumerState { key: const Key("OrdinalsViewKeywordFieldKey"), controller: _keywordTextEditingController, focusNode: keywordTextFieldFocusNode, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - height: 1.8, - ) - : STextStyles.field(context), + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + height: 1.8, + ) + : STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type keyword...", - keywordTextFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Type keyword...", + keywordTextFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ) + vertical: 10, + horizontal: 16, + ) : null, - suffixIcon: - _keywordTextEditingController.text.isNotEmpty + suffixIcon: _keywordTextEditingController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _keywordTextEditingController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _keywordTextEditingController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), diff --git a/lib/pages/send_view/frost_ms/frost_send_view.dart b/lib/pages/send_view/frost_ms/frost_send_view.dart index 4b10141585..59bdc843ef 100644 --- a/lib/pages/send_view/frost_ms/frost_send_view.dart +++ b/lib/pages/send_view/frost_ms/frost_send_view.dart @@ -33,6 +33,7 @@ import '../../../wallets/crypto_currency/crypto_currency.dart'; import '../../../wallets/isar/providers/wallet_info_provider.dart'; import '../../../wallets/models/tx_data.dart'; import '../../../wallets/wallet/impl/bitcoin_frost_wallet.dart'; +import '../../../wallets/wallet/impl/salvium_wallet.dart'; import '../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../widgets/background.dart'; import '../../../widgets/conditional_parent.dart'; @@ -164,10 +165,9 @@ class _FrostSendViewState extends ConsumerState { child: Text( "Ok", style: STextStyles.button(context).copyWith( - color: - Theme.of( - context, - ).extension()!.accentColorDark, + color: Theme.of( + context, + ).extension()!.accentColorDark, ), ), onPressed: () { @@ -231,6 +231,7 @@ class _FrostSendViewState extends ConsumerState { final showCoinControl = wallet is CoinControlInterface && + wallet is! SalviumWallet && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, @@ -242,59 +243,56 @@ class _FrostSendViewState extends ConsumerState { return ConditionalParent( condition: !Util.isDesktop, - builder: - (child) => Background( - child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () async { - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed( - const Duration(milliseconds: 50), - ); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ), - title: Text( - "Send ${coin.ticker}", - style: STextStyles.navBarTitle(context), - ), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (builderContext, constraints) { - return SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - // subtract top and bottom padding set in parent - minHeight: constraints.maxHeight, - ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: child, - ), - ), + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () async { + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 50)); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + title: Text( + "Send ${coin.ticker}", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + // subtract top and bottom padding set in parent + minHeight: constraints.maxHeight, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: child, ), - ); - }, - ), - ), + ), + ), + ); + }, ), ), + ), + ), child: ConditionalParent( condition: Util.isDesktop, - builder: - (child) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - child: child, - ), + builder: (child) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + child: child, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -375,11 +373,10 @@ class _FrostSendViewState extends ConsumerState { for (int i = 0; i < recipientWidgetIndexes.length; i++) ConditionalParent( condition: recipientWidgetIndexes.length > 1, - builder: - (child) => Padding( - padding: const EdgeInsets.only(top: 8), - child: child, - ), + builder: (child) => Padding( + padding: const EdgeInsets.only(top: 8), + child: child, + ), child: Recipient( key: Key("recipientKey_${recipientWidgetIndexes[i]}"), index: recipientWidgetIndexes[i], @@ -388,21 +385,21 @@ class _FrostSendViewState extends ConsumerState { onChanged: () { _validateRecipientFormStates(); }, - remove: - i == 0 && recipientWidgetIndexes.length == 1 - ? null - : () { - ref - .read( - pRecipient( - recipientWidgetIndexes[i], - ).notifier, - ) - .state = null; - recipientWidgetIndexes.removeAt(i); - setState(() {}); - _validateRecipientFormStates(); - }, + remove: i == 0 && recipientWidgetIndexes.length == 1 + ? null + : () { + ref + .read( + pRecipient( + recipientWidgetIndexes[i], + ).notifier, + ) + .state = + null; + recipientWidgetIndexes.removeAt(i); + setState(() {}); + _validateRecipientFormStates(); + }, addAnotherRecipientTapped: () { // used for tracking recipient forms _greatestWidgetIndex++; @@ -443,17 +440,15 @@ class _FrostSendViewState extends ConsumerState { Text( "Coin control", style: STextStyles.w500_14(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of( + context, + ).extension()!.textSubtitle1, ), ), CustomTextButton( - text: - selectedUTXOs.isEmpty - ? "Select coins" - : "Selected coins (${selectedUTXOs.length})", + text: selectedUTXOs.isEmpty + ? "Select coins" + : "Selected coins (${selectedUTXOs.length})", onTap: () async { if (FocusScope.of(context).hasFocus) { FocusScope.of(context).unfocus(); @@ -506,32 +501,32 @@ class _FrostSendViewState extends ConsumerState { focusNode: _noteFocusNode, style: STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type something...", - _noteFocusNode, - context, - ).copyWith( - suffixIcon: - noteController.text.isNotEmpty + decoration: + standardInputDecoration( + "Type something...", + _noteFocusNode, + context, + ).copyWith( + suffixIcon: noteController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - noteController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + noteController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 12), diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 94b5663c82..a2dd4f4834 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -57,6 +57,7 @@ import '../../wallets/models/tx_data.dart'; import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../wallets/wallet/impl/salvium_wallet.dart'; import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; @@ -813,7 +814,9 @@ class _SendViewState extends ConsumerState { .enableCoinControl; if (coin is! Ethereum && - !(wallet is CoinControlInterface && coinControlEnabled) || + !(wallet is CoinControlInterface && + wallet is! SalviumWallet && + coinControlEnabled) || (wallet is CoinControlInterface && coinControlEnabled && selectedUTXOs.isEmpty)) { @@ -915,6 +918,7 @@ class _SendViewState extends ConsumerState { feeRateType: feeRate, utxos: (wallet is CoinControlInterface && + wallet is! SalviumWallet && coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs @@ -1037,6 +1041,7 @@ class _SendViewState extends ConsumerState { ethEIP1559Fee: ethFee, utxos: (wallet is CoinControlInterface && + wallet is! SalviumWallet && coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs @@ -1405,6 +1410,7 @@ class _SendViewState extends ConsumerState { ), ) && ref.watch(pWallets).getWallet(walletId) is CoinControlInterface && + ref.watch(pWallets).getWallet(walletId) is! SalviumWallet && (showPrivateBalance ? balType == BalanceType.public : true); final isExchangeAddress = ref.watch(pIsExchangeAddress); diff --git a/lib/pages/settings_views/global_settings_view/global_settings_view.dart b/lib/pages/settings_views/global_settings_view/global_settings_view.dart index 40b53198c8..729709f819 100644 --- a/lib/pages/settings_views/global_settings_view/global_settings_view.dart +++ b/lib/pages/settings_views/global_settings_view/global_settings_view.dart @@ -247,33 +247,37 @@ class GlobalSettingsView extends StatelessWidget { ); }, ), - Consumer( - builder: (_, ref, __) { - final familiarity = ref.watch( - prefsChangeNotifierProvider.select( - (v) => v.familiarity, - ), - ); - if (familiarity < 6) { - return const SizedBox.shrink(); - } - return Column( - children: [ - const SizedBox(height: 8), - SettingsListButton( - iconAssetName: Assets.svg.key, - iconSize: 16, - title: "ShopinBit", - onPressed: () { - Navigator.of(context).pushNamed( - ShopInBitSettingsView.routeName, - ); - }, + if (AppConfig.hasFeature( + AppFeature.shopinBit, + )) + Consumer( + builder: (_, ref, __) { + final familiarity = ref.watch( + prefsChangeNotifierProvider.select( + (v) => v.familiarity, ), - ], - ); - }, - ), + ); + if (familiarity < 6) { + return const SizedBox.shrink(); + } + return Column( + children: [ + const SizedBox(height: 8), + SettingsListButton( + iconAssetName: Assets.svg.key, + iconSize: 16, + title: "ShopinBit", + onPressed: () { + Navigator.of(context).pushNamed( + ShopInBitSettingsView + .routeName, + ); + }, + ), + ], + ); + }, + ), const SizedBox(height: 8), SettingsListButton( iconAssetName: Assets.svg.questionMessage, diff --git a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart index ebb6b94cf2..0d88f525fb 100644 --- a/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart +++ b/lib/pages/settings_views/global_settings_view/stack_backup_views/create_backup_view.dart @@ -114,6 +114,7 @@ class _RestoreFromFileViewState extends ConsumerState { subMessage: "This shouldn't take long", delay: const Duration(seconds: 1), onException: (e) => ex = e, + rootNavigator: Util.isDesktop, ); if (mounted) { @@ -154,7 +155,10 @@ class _RestoreFromFileViewState extends ConsumerState { child: PrimaryButton( label: "Ok", buttonHeight: ButtonHeight.l, - onPressed: Navigator.of(context).pop, + onPressed: Navigator.of( + context, + rootNavigator: true, + ).pop, ), ), ], 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 5f93a9dea7..675765340f 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 @@ -11,8 +11,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; import 'package:isar_community/isar.dart'; import 'package:stack_wallet_backup/stack_wallet_backup.dart'; @@ -21,6 +21,7 @@ import 'package:uuid/uuid.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../../../../app_config.dart'; +import '../../../../../db/drift/shared_db/shared_database.dart'; import '../../../../../db/hive/db.dart'; import '../../../../../db/isar/main_db.dart'; import '../../../../../models/exchange/change_now/exchange_transaction.dart'; @@ -34,7 +35,9 @@ import '../../../../../models/trade_wallet_lookup.dart'; import '../../../../../models/wallet_restore_state.dart'; import '../../../../../notifications/show_flush_bar.dart'; import '../../../../../services/address_book_service.dart'; +import '../../../../../services/cakepay/cakepay_service.dart'; import '../../../../../services/node_service.dart'; +import '../../../../../services/shopinbit/shopinbit_service.dart'; import '../../../../../services/trade_notes_service.dart'; import '../../../../../services/trade_sent_from_stack_service.dart'; import '../../../../../services/trade_service.dart'; @@ -236,6 +239,29 @@ abstract class SWB { Logging.instance.e("", error: e, stackTrace: s); } + Logging.instance.i("SWB backing up cakepay orders"); + final cakepayOrderIds = await CakePayService.instance.getOrderIds(); + backupJson["cakepayOrderIds"] = cakepayOrderIds; + + 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(); + + 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), + }; + Logging.instance.d("SWB backing up prefs"); final Map prefs = {}; @@ -609,6 +635,9 @@ abstract class SWB { uiState?.preferences = StackRestoringStatus.restoring; + Logging.instance.d("SWB restoring cakepay order ids and shop in bit info"); + await _restoreCakepayAndShopinBitInfo(validJSON, secureStorageInterface); + Logging.instance.d("SWB restoring prefs"); await _restorePrefs(prefs); @@ -886,6 +915,12 @@ abstract class SWB { final Map? tradeNotes = revertToState.validJSON["tradeNotes"] as Map?; + // cakepay and shopinbit + await _restoreCakepayAndShopinBitInfo( + revertToState.validJSON, + secureStorageInterface, + ); + // prefs await _restorePrefs(prefs); @@ -1085,6 +1120,65 @@ abstract class SWB { Logging.instance.d("Revert SWB complete"); } + static Future _restoreCakepayAndShopinBitInfo( + Map backupJson, + SecureStorageInterface _secureStore, + ) async { + final cakepayOrderIds = (backupJson["cakepayOrderIds"] as List? ?? []) + .cast(); + for (final orderId in cakepayOrderIds) { + 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 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 { final _prefs = Prefs.instance; await _prefs.init(); diff --git a/lib/pages/shopinbit/shopinbit_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index e822e87714..8186a66585 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -20,11 +20,12 @@ 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/nested_navigator_dialog/nested_navigator_dialog.dart'; +import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/rounded_white_container.dart'; -import '../../widgets/stack_text_field.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; import '../more_view/services_view.dart'; import 'shopinbit_car_research_payment_view.dart'; import 'shopinbit_step_2.dart'; @@ -286,25 +287,12 @@ class _ShopInBitCarFeeViewState extends ConsumerState { if (!mounted) return; - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitCarResearchPaymentView( - model: widget.model, - invoice: invoice, - ), - ), - ); - } else { - unawaited( - Navigator.of(context).pushNamed( - ShopInBitCarResearchPaymentView.routeName, - arguments: (widget.model, invoice), - ), - ); - } + unawaited( + Navigator.of(context).pushNamed( + ShopInBitCarResearchPaymentView.routeName, + arguments: (widget.model, invoice), + ), + ); } catch (e) { if (mounted) { setState(() => _submitting = false); @@ -380,45 +368,6 @@ class _ShopInBitCarFeeViewState extends ConsumerState { // placeholder in place rather than showing "--". } - Widget _buildField({ - required TextEditingController controller, - required FocusNode focusNode, - required String label, - required bool isDesktop, - }) { - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: TextField( - controller: controller, - focusNode: focusNode, - autocorrect: false, - enableSuggestions: false, - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - label, - focusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), - ); - } - Widget _buildCountryDropdown({ required String? value, required ValueChanged onChanged, @@ -543,6 +492,7 @@ class _ShopInBitCarFeeViewState extends ConsumerState { final spacing = SizedBox(height: isDesktop ? 16 : 12); final content = Column( + mainAxisSize: .min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( @@ -553,6 +503,9 @@ class _ShopInBitCarFeeViewState extends ConsumerState { ), SizedBox(height: isDesktop ? 16 : 8), RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -579,37 +532,45 @@ class _ShopInBitCarFeeViewState extends ConsumerState { : STextStyles.titleBold12(context), ), SizedBox(height: isDesktop ? 16 : 12), - _buildField( + AdaptiveTextField( controller: _nameController, focusNode: _nameFocusNode, - label: "Full name", - isDesktop: isDesktop, + labelText: "Full name", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), spacing, - _buildField( + AdaptiveTextField( controller: _streetController, focusNode: _streetFocusNode, - label: "Street address", - isDesktop: isDesktop, + labelText: "Street address", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), spacing, Row( children: [ Expanded( - child: _buildField( + child: AdaptiveTextField( controller: _cityController, focusNode: _cityFocusNode, - label: "City", - isDesktop: isDesktop, + labelText: "City", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), ), SizedBox(width: isDesktop ? 16 : 12), Expanded( - child: _buildField( + child: AdaptiveTextField( controller: _postalCodeController, focusNode: _postalCodeFocusNode, - label: "Postal code", - isDesktop: isDesktop, + labelText: "Postal code", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), ), ], @@ -671,37 +632,45 @@ class _ShopInBitCarFeeViewState extends ConsumerState { : STextStyles.titleBold12(context), ), SizedBox(height: isDesktop ? 16 : 12), - _buildField( + AdaptiveTextField( controller: _billingNameController, focusNode: _billingNameFocusNode, - label: "Full name", - isDesktop: isDesktop, + labelText: "Full name", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), spacing, - _buildField( + AdaptiveTextField( controller: _billingStreetController, focusNode: _billingStreetFocusNode, - label: "Street address", - isDesktop: isDesktop, + labelText: "Street address", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), spacing, Row( children: [ Expanded( - child: _buildField( + child: AdaptiveTextField( controller: _billingCityController, focusNode: _billingCityFocusNode, - label: "City", - isDesktop: isDesktop, + labelText: "City", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), ), SizedBox(width: isDesktop ? 16 : 12), Expanded( - child: _buildField( + child: AdaptiveTextField( controller: _billingPostalCodeController, focusNode: _billingPostalCodeFocusNode, - label: "Postal code", - isDesktop: isDesktop, + labelText: "Postal code", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), ), ], @@ -728,34 +697,41 @@ class _ShopInBitCarFeeViewState extends ConsumerState { ); if (isDesktop) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 750, - 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), + ), ), + DesktopDialogCloseButton( + onPressedOverride: () => + NestedNavigatorDialog.of(context).close(), + ), + ], + ), + Flexible( + child: Padding( + padding: const .only( + left: 32, + right: 32, + bottom: 32, + top: 16, + ), + 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_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 8ef075b706..0d3d3a14a8 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -23,10 +23,10 @@ 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/dialogs/s_dialog.dart'; import '../../widgets/qr.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; @@ -407,22 +407,13 @@ class _ShopInBitCarResearchPaymentViewState // Both steps already done: navigate to success directly. if (!mounted) return; setState(() => _flowState = _PaymentFlowState.complete); - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitOrderCreated(model: widget.model), - ), - ); - } else { - unawaited( - Navigator.of(context).pushNamed( - ShopInBitOrderCreated.routeName, - arguments: widget.model, - ), - ); - } + + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), + ); + return; } // Fee logged; skip to createRequest. @@ -495,22 +486,12 @@ class _ShopInBitCarResearchPaymentViewState } if (!mounted) return; setState(() => _flowState = _PaymentFlowState.complete); - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitOrderCreated(model: widget.model), - ), - ); - } else { - unawaited( - Navigator.of(context).pushNamed( - ShopInBitOrderCreated.routeName, - arguments: widget.model, - ), - ); - } + + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), + ); } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); @@ -630,21 +611,11 @@ class _ShopInBitCarResearchPaymentViewState if (!mounted) return; setState(() => _flowState = _PaymentFlowState.complete); - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitOrderCreated(model: widget.model), - ), - ); - } else { - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); - } + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), + ); } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); @@ -719,21 +690,11 @@ class _ShopInBitCarResearchPaymentViewState if (!mounted) return; setState(() => _flowState = _PaymentFlowState.complete); - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitOrderCreated(model: widget.model), - ), - ); - } else { - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), - ); - } + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: widget.model), + ); } catch (e) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); @@ -848,6 +809,7 @@ class _ShopInBitCarResearchPaymentViewState final content = Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, children: [ Text( "Car research payment", @@ -987,34 +949,38 @@ class _ShopInBitCarResearchPaymentViewState ); if (isDesktop) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 750, - 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: 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_order_created.dart b/lib/pages/shopinbit/shopinbit_order_created.dart index e522e88d78..9680519e0c 100644 --- a/lib/pages/shopinbit/shopinbit_order_created.dart +++ b/lib/pages/shopinbit/shopinbit_order_created.dart @@ -7,11 +7,13 @@ import '../../utilities/assets.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/nested_navigator_dialog/nested_navigator_dialog.dart'; +import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/rounded_white_container.dart'; import '../more_view/services_view.dart'; import 'shopinbit_ticket_detail.dart'; @@ -39,171 +41,202 @@ class ShopInBitOrderCreated extends StatelessWidget { Widget build(BuildContext context) { final isDesktop = Util.isDesktop; - final content = Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Spacer(), - SvgPicture.asset( - Assets.svg.checkCircle, - width: isDesktop ? 64 : 48, - height: isDesktop ? 64 : 48, - color: Theme.of(context).extension()!.accentColorGreen, - ), - SizedBox(height: isDesktop ? 24 : 16), - Text( - "Request created!", - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - SizedBox(height: isDesktop ? 16 : 8), - Text( - "Your request has been submitted.", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - textAlign: TextAlign.center, - ), - SizedBox(height: isDesktop ? 32 : 24), - RoundedWhiteContainer( + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, child: Column( + mainAxisSize: .min, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - "Request ID", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), ), - Text( - model.ticketId ?? "N/A", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.titleBold12(context), + DesktopDialogCloseButton( + onPressedOverride: () => NestedNavigatorDialog.of( + context, + ).close(args: const .noWarning()), ), ], ), - SizedBox(height: isDesktop ? 12 : 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Status", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), + Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + top: 16, ), - Text( - "Pending review", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.titleBold12(context), - ), - ], + child: child, + ), ), ], ), ), - const Spacer(), - PrimaryButton( - label: "View request", - onPressed: () { - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, + ), - builder: (_) => ShopInBitTicketDetail(model: model), - ); - } else { - Navigator.of( + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (!didPop) { + _popToServices(context); + } + }, + child: Scaffold( + backgroundColor: Theme.of( context, - ).pushNamed(ShopInBitTicketDetail.routeName, arguments: model); - } - }, - ), - SizedBox(height: isDesktop ? 16 : 12), - SecondaryButton( - label: "Back to services", - onPressed: () { - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - } else { - _popToServices(context); - } - }, - ), - ], - ); - - if (isDesktop) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 550, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "ShopinBit", - style: STextStyles.desktopH3(context), - ), + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => _popToServices(context), ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, + 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: content, ), ), - ], - ), - ); - } - - return Background( - child: PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, dynamic result) { - if (!didPop) { - _popToServices(context); - } - }, - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton(onPressed: () => _popToServices(context)), - 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: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (!isDesktop) const Spacer(), + SvgPicture.asset( + Assets.svg.checkCircle, + width: isDesktop ? 64 : 48, + height: isDesktop ? 64 : 48, + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Request created!", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Your request has been submitted.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + SizedBox(height: isDesktop ? 32 : 24), + RoundedWhiteContainer( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Request ID", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), ), - child: IntrinsicHeight(child: content), - ), + Text( + model.ticketId ?? "N/A", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + SizedBox(height: isDesktop ? 12 : 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Status", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + "Pending review", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], ), - ); - }, + ], + ), ), - ), + isDesktop ? const SizedBox(height: 40) : const Spacer(), + BranchedParent( + condition: isDesktop, + conditionBranchBuilder: (children) => Row( + children: [ + Expanded(child: children[2]), + children[1], + Expanded(child: children[0]), + ], + ), + otherBranchBuilder: (children) => Column( + crossAxisAlignment: .stretch, + mainAxisSize: .min, + children: children, + ), + children: [ + PrimaryButton( + label: "View request", + buttonHeight: isDesktop ? .l : null, + onPressed: () { + Navigator.of(context).pushNamed( + ShopInBitTicketDetail.routeName, + arguments: model, + ); + }, + ), + const SizedBox(height: 16, width: 24), + SecondaryButton( + label: "Back to services", + buttonHeight: isDesktop ? .l : null, + onPressed: () { + if (Util.isDesktop) { + NestedNavigatorDialog.of( + context, + ).close(args: const .noWarning()); + } else { + _popToServices(context); + } + }, + ), + ], + ), + ], ), ), ); diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 03ae923542..ccebcb30b1 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -18,7 +18,7 @@ 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/stack_text_field.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; import 'shopinbit_payment_view.dart'; class ShopInBitShippingView extends ConsumerStatefulWidget { @@ -252,45 +252,6 @@ class _ShopInBitShippingViewState extends ConsumerState { } } - Widget _buildField({ - required TextEditingController controller, - required FocusNode focusNode, - required String label, - required bool isDesktop, - }) { - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: TextField( - controller: controller, - focusNode: focusNode, - autocorrect: false, - enableSuggestions: false, - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - label, - focusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; @@ -313,37 +274,45 @@ class _ShopInBitShippingViewState extends ConsumerState { : STextStyles.itemSubtitle(context), ), SizedBox(height: isDesktop ? 32 : 24), - _buildField( + AdaptiveTextField( controller: _nameController, focusNode: _nameFocusNode, - label: "Full name", - isDesktop: isDesktop, + labelText: "Full name", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), spacing, - _buildField( + AdaptiveTextField( controller: _streetController, focusNode: _streetFocusNode, - label: "Street address", - isDesktop: isDesktop, + labelText: "Street address", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), spacing, Row( children: [ Expanded( - child: _buildField( + child: AdaptiveTextField( controller: _cityController, focusNode: _cityFocusNode, - label: "City", - isDesktop: isDesktop, + labelText: "City", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), ), SizedBox(width: isDesktop ? 16 : 12), Expanded( - child: _buildField( + child: AdaptiveTextField( controller: _postalCodeController, focusNode: _postalCodeFocusNode, - label: "Postal code", - isDesktop: isDesktop, + labelText: "Postal code", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), ), ], @@ -523,37 +492,45 @@ class _ShopInBitShippingViewState extends ConsumerState { : STextStyles.titleBold12(context), ), spacing, - _buildField( + AdaptiveTextField( controller: _billingNameController, focusNode: _billingNameFocusNode, - label: "Full name", - isDesktop: isDesktop, + labelText: "Full name", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), spacing, - _buildField( + AdaptiveTextField( controller: _billingStreetController, focusNode: _billingStreetFocusNode, - label: "Street address", - isDesktop: isDesktop, + labelText: "Street address", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), spacing, Row( children: [ Expanded( - child: _buildField( + child: AdaptiveTextField( controller: _billingCityController, focusNode: _billingCityFocusNode, - label: "City", - isDesktop: isDesktop, + labelText: "City", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), ), SizedBox(width: isDesktop ? 16 : 12), Expanded( - child: _buildField( + child: AdaptiveTextField( controller: _billingPostalCodeController, focusNode: _billingPostalCodeFocusNode, - label: "Postal code", - isDesktop: isDesktop, + labelText: "Postal code", + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), ), ), ], diff --git a/lib/pages/shopinbit/shopinbit_step_2.dart b/lib/pages/shopinbit/shopinbit_step_2.dart index 23403ce600..2a4d9f09ff 100644 --- a/lib/pages/shopinbit/shopinbit_step_2.dart +++ b/lib/pages/shopinbit/shopinbit_step_2.dart @@ -13,6 +13,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/desktop/primary_button.dart'; +import '../../widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart'; import '../../widgets/dialogs/s_dialog.dart'; import '../../widgets/rounded_container.dart'; import '../exchange_view/sub_widgets/step_row.dart'; @@ -20,11 +21,16 @@ import 'shopinbit_step_3.dart'; import 'shopinbit_step_4.dart'; class ShopInBitStep2 extends ConsumerStatefulWidget { - const ShopInBitStep2({super.key, required this.model}); + const ShopInBitStep2({ + super.key, + required this.model, + this.isActuallyFirstStep = false, + }); static const String routeName = "/shopInBitStep2"; final ShopInBitOrderModel model; + final bool isActuallyFirstStep; @override ConsumerState createState() => _ShopInBitStep2State(); @@ -77,11 +83,23 @@ class _ShopInBitStep2State extends ConsumerState { children: [ Row( children: [ - const AppBarBackButton(isCompact: true, iconSize: 23), + widget.isActuallyFirstStep + ? const SizedBox(width: 32) + : const AppBarBackButton( + isCompact: true, + iconSize: 23, + ), Text("ShopinBit", style: STextStyles.desktopH3(context)), ], ), - const DesktopDialogCloseButton(), + DesktopDialogCloseButton( + onPressedOverride: () => + NestedNavigatorDialog.of(context).close( + args: widget.isActuallyFirstStep + ? const .noWarning() + : const .genericWarning(), + ), + ), ], ), Flexible( diff --git a/lib/pages/shopinbit/shopinbit_step_3.dart b/lib/pages/shopinbit/shopinbit_step_3.dart index f84d487c2f..d5cae9d600 100644 --- a/lib/pages/shopinbit/shopinbit_step_3.dart +++ b/lib/pages/shopinbit/shopinbit_step_3.dart @@ -11,6 +11,7 @@ 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/nested_navigator_dialog/nested_navigator_dialog.dart'; import '../../widgets/rounded_white_container.dart'; import '../exchange_view/sub_widgets/step_row.dart'; import 'shopinbit_step_4.dart'; @@ -167,7 +168,10 @@ class _ShopInBitStep3State extends ConsumerState { Text("ShopinBit", style: STextStyles.desktopH3(context)), ], ), - const DesktopDialogCloseButton(), + DesktopDialogCloseButton( + onPressedOverride: () => + NestedNavigatorDialog.of(context).close(), + ), ], ), Expanded( diff --git a/lib/pages/shopinbit/shopinbit_step_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart index c5cfd4fff8..605d6e22c7 100644 --- a/lib/pages/shopinbit/shopinbit_step_4.dart +++ b/lib/pages/shopinbit/shopinbit_step_4.dart @@ -7,8 +7,9 @@ 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/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart"; +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"; @@ -47,30 +48,35 @@ class _ShopInBitStep4DesktopShell extends StatelessWidget { @override Widget build(BuildContext context) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 750, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const AppBarBackButton(isCompact: true, iconSize: 23), - Text("ShopinBit", style: STextStyles.desktopH3(context)), - ], + return SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const AppBarBackButton(isCompact: true, iconSize: 23), + Text("ShopinBit", style: STextStyles.desktopH3(context)), + ], + ), + DesktopDialogCloseButton( + onPressedOverride: () => + NestedNavigatorDialog.of(context).close(), + ), + ], + ), + Flexible( + child: Padding( + padding: const .only(left: 32, right: 32, bottom: 32, top: 16), + 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 9875a7bdd9..dc5127c573 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -3,11 +3,14 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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'; +import '../../services/shopinbit/shopinbit_orders_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; @@ -17,7 +20,7 @@ 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/loading_indicator.dart'; +import '../../widgets/refresh_control.dart'; import '../../widgets/rounded_container.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_offer_view.dart'; @@ -36,90 +39,43 @@ class ShopInBitTicketDetail extends ConsumerStatefulWidget { class _ShopInBitTicketDetailState extends ConsumerState { 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; + bool get _isCarResearch => _model.category == ShopInBitCategory.car; - Future _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, - ); - } - - 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 _refresh() => _ordersService.refreshOne(_model.apiTicketId); Future _sendMessage() async { final text = _messageController.text.trim(); @@ -129,25 +85,25 @@ class _ShopInBitTicketDetailState extends ConsumerState { _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 @@ -161,7 +117,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { setState(() => _retrying = true); try { - final model = widget.model; + final model = _model; final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); final comment = "${model.requestDescription}\n\n" @@ -237,14 +193,16 @@ class _ShopInBitTicketDetailState extends ConsumerState { } String _formatTime(DateTime dt) { - // TODO: local time is a start but this is still far from ideal... - if (dt.isUtc) { - dt = dt.toLocal(); - } - - final hour = dt.hour.toString().padLeft(2, '0'); - final minute = dt.minute.toString().padLeft(2, '0'); - return "$hour:$minute"; + final local = dt.toLocal(); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + final hm = "$hour:$minute"; + final now = DateTime.now(); + final isToday = + local.year == now.year && + local.month == now.month && + local.day == now.day; + return isToday ? hm : "${DateFormat('MMM d').format(local)} $hm"; } static final _imgTagRegex = RegExp( @@ -384,7 +342,9 @@ class _ShopInBitTicketDetailState extends ConsumerState { @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), @@ -478,6 +438,17 @@ class _ShopInBitTicketDetailState extends ConsumerState { ) : 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, @@ -486,22 +457,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { color: Theme.of(context).extension()!.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), ), ); @@ -584,8 +540,7 @@ class _ShopInBitTicketDetailState extends ConsumerState { : 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( @@ -630,7 +585,17 @@ class _ShopInBitTicketDetailState extends ConsumerState { style: STextStyles.desktopH3(context), ), ), - const DesktopDialogCloseButton(), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + RefreshButton( + isRefreshing: isRefreshing, + onPressed: _refresh, + ), + const SizedBox(width: 8), + const DesktopDialogCloseButton(), + ], + ), ], ), Expanded( diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index c71782a79c..71445aedd7 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 { List _tickets = []; ShopInBitTicket? _pendingTicket; StreamSubscription>? _ticketsSub; + bool _refreshing = false; @override void initState() { @@ -54,7 +55,7 @@ class _ShopInBitTicketsViewState extends ConsumerState { .toList(); }); }); - WidgetsBinding.instance.addPostFrameCallback((_) => _syncFromApi()); + WidgetsBinding.instance.addPostFrameCallback((_) => _refresh()); } @override @@ -63,6 +64,16 @@ class _ShopInBitTicketsViewState extends ConsumerState { super.dispose(); } + Future _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; @@ -81,101 +92,16 @@ class _ShopInBitTicketsViewState extends ConsumerState { expiresAt: expiresAt, paymentLinks: links, ); - if (isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - builder: (_) => - ShopInBitCarResearchPaymentView(model: model, invoice: invoice), - ); - } else { - Navigator.of(context).pushNamed( - ShopInBitCarResearchPaymentView.routeName, - arguments: (model, invoice), - ); - } + + Navigator.of(context).pushNamed( + ShopInBitCarResearchPaymentView.routeName, + arguments: (model, invoice), + ); } else { // Invoice expired: navigate to fee view. - if (isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - builder: (_) => ShopInBitCarFeeView(model: model), - ); - } else { - Navigator.of( - context, - ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model); - } - } - } - - Future _syncFromApi() async { - await showLoading( - context: context, - message: "Loading requests...", - whileFutureAlt: _syncFromApiHelper, - rootNavigator: Util.isDesktop, - ); - } - - Future _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. + Navigator.of( + context, + ).pushNamed(ShopInBitCarFeeView.routeName, arguments: model); } } @@ -187,6 +113,73 @@ class _ShopInBitTicketsViewState extends ConsumerState { null => "", }; + List _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 = []; + if (pending != null) { + children.add( + RoundedContainer( + color: Theme.of(context).extension()!.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()!.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()!.textFieldDefaultBG + : null, + color: Theme.of(context).extension()!.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()!, + ), + ), + ), + ); + } + return children; + } + @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; @@ -211,7 +204,17 @@ class _ShopInBitTicketsViewState extends ConsumerState { 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 +253,21 @@ class _ShopInBitTicketsViewState extends ConsumerState { ), ), ), - 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()!.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()!.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()!.textFieldDefaultBG - : null, - color: Theme.of( - context, - ).extension()!.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()!, - ), - ), - ); - }, - ), - - // // 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/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart index 9a8ff9ded1..0cb6511825 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 @@ -3,13 +3,19 @@ import "dart:async"; import "package:flutter/material.dart"; 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 "../../../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"; @@ -18,7 +24,6 @@ import "shopinbit_privacy_checkbox.dart"; import "shopinbit_step4_dropdown.dart"; import "shopinbit_step4_header.dart"; import "shopinbit_step4_submit_button.dart"; -import "shopinbit_step4_text_field.dart"; const List _carConditions = ["NEW", "PREOWNED"]; @@ -134,22 +139,22 @@ class _ShopInBitCarResearchFormState final bool? resumePrevious = await showDialog( 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?", + 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), ), - 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"), - ), - ], ), ); @@ -166,21 +171,11 @@ class _ShopInBitCarResearchFormState if (!mounted) return; - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitCarFeeView(model: widget.model), - ), - ); - } else { - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitCarFeeView.routeName, arguments: widget.model), - ); - } + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitCarFeeView.routeName, arguments: widget.model), + ); } finally { if (mounted) setState(() => _submitting = false); } @@ -231,18 +226,22 @@ class _ShopInBitCarResearchFormState onChanged: (iso) => setState(() => _selectedCountryIso = iso), ), SizedBox(height: isDesktop ? 24 : 16), - ShopInBitStep4TextField( + AdaptiveTextField( controller: _brandController, focusNode: _brandFocusNode, - hintText: "Car brand (e.g., BMW, Mercedes, Toyota...)", + labelText: "Car brand (e.g., BMW, Mercedes, Toyota...)", + autocorrect: false, + enableSuggestions: false, errorText: brandError, onChanged: (_) => setState(() {}), ), SizedBox(height: isDesktop ? 24 : 16), - ShopInBitStep4TextField( + AdaptiveTextField( controller: _modelController, focusNode: _modelFocusNode, - hintText: "Car model (e.g., 3 Series, E-Class, Camry...)", + labelText: "Car model (e.g., 3 Series, E-Class, Camry...)", + autocorrect: false, + enableSuggestions: false, errorText: modelError, onChanged: (_) => setState(() {}), ), @@ -254,25 +253,29 @@ class _ShopInBitCarResearchFormState onChanged: (value) => setState(() => _selectedCarCondition = value), ), SizedBox(height: isDesktop ? 24 : 16), - ShopInBitStep4TextField( + AdaptiveTextField( controller: _carDescriptionController, focusNode: _carDescriptionFocusNode, - hintText: + labelText: "Describe your requirements " "(year, mileage, features...)", minLines: 3, maxLines: 6, + autocorrect: false, + enableSuggestions: false, errorText: carDescriptionError, onChanged: (_) => setState(() {}), ), SizedBox(height: isDesktop ? 24 : 16), - ShopInBitStep4TextField( + AdaptiveTextField( controller: _carBudgetController, focusNode: _carBudgetFocusNode, - hintText: "Budget (\u20AC, minimum 20,000)", + labelText: "Budget (\u20AC, minimum 20,000)", keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], suffixText: "\u20AC", + autocorrect: false, + enableSuggestions: false, errorText: carBudgetError, onChanged: (_) => setState(() {}), ), @@ -284,12 +287,12 @@ class _ShopInBitCarResearchFormState onChanged: (v) => setState(() => _feeAcknowledged = v), label: "I acknowledge the \u20AC223 research fee", ), - SizedBox(height: isDesktop ? 16 : 12), + const SizedBox(height: 24), ShopInBitPrivacyCheckbox( value: _privacyAccepted, onChanged: (v) => setState(() => _privacyAccepted = v), ), - SizedBox(height: isDesktop ? 16 : 12), + const SizedBox(height: 32), ShopInBitStep4SubmitButton( submitting: _submitting, enabled: _canContinue, @@ -313,15 +316,22 @@ class _CarResearchFeeInfo extends StatelessWidget { : STextStyles.w500_14(context); return RoundedWhiteContainer( + borderColor: isDesktop + ? Theme.of(context).extension()!.textFieldDefaultBG + : null, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon( - Icons.info_outline, - size: 20, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconLeft, + SvgPicture.asset( + Assets.svg.circleInfo, + width: 20, + height: 20, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconLeft, + .srcIn, + ), ), const SizedBox(width: 12), Expanded( 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 480f427272..1c191cd722 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart @@ -6,6 +6,7 @@ import "../../../models/shopinbit/shopinbit_order_model.dart"; import "../../../providers/db/drift_provider.dart"; import "../../../providers/global/shopin_bit_service_provider.dart"; import "../../../utilities/util.dart"; +import "../../../widgets/textfields/adaptive_text_field.dart"; import "shopinbit_country_picker.dart"; import "shopinbit_labeled_checkbox.dart"; import "shopinbit_privacy_checkbox.dart"; @@ -13,7 +14,6 @@ import "shopinbit_step4_dropdown.dart"; import "shopinbit_step4_header.dart"; import "shopinbit_step4_submit.dart"; import "shopinbit_step4_submit_button.dart"; -import "shopinbit_step4_text_field.dart"; const List _conciergeConditions = ["NEW", "USED"]; @@ -143,14 +143,16 @@ class _ShopInBitConciergeFormState "for you.", ), SizedBox(height: isDesktop ? 16 : 12), - ShopInBitStep4TextField( + AdaptiveTextField( controller: _whatToPurchaseController, focusNode: _whatToPurchaseFocusNode, - hintText: + labelText: "Describe what you'd like to purchase " "(e.g., electronics, luxury goods, services...)", minLines: 3, maxLines: 6, + autocorrect: false, + enableSuggestions: false, errorText: whatToPurchaseError, onChanged: (_) => setState(() {}), ), @@ -162,34 +164,36 @@ class _ShopInBitConciergeFormState onChanged: (value) => setState(() => _selectedCondition = value), ), SizedBox(height: isDesktop ? 24 : 16), - ShopInBitStep4TextField( + AdaptiveTextField( controller: _budgetController, focusNode: _budgetFocusNode, - hintText: "Budget (\u20AC)", + labelText: "Budget (\u20AC)", enabled: !_noLimit, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], suffixText: "\u20AC", + autocorrect: false, + enableSuggestions: false, errorText: budgetError, onChanged: (_) => setState(() {}), ), - SizedBox(height: isDesktop ? 12 : 8), + const SizedBox(height: 12), ShopInBitLabeledCheckbox( value: _noLimit, onChanged: (v) => setState(() => _noLimit = v), label: "No budget limit", ), - SizedBox(height: isDesktop ? 12 : 12), + SizedBox(height: isDesktop ? 24 : 20), ShopInBitCountryPicker( selectedIso: _selectedCountryIso, onChanged: (iso) => setState(() => _selectedCountryIso = iso), ), - SizedBox(height: isDesktop ? 12 : 12), + SizedBox(height: isDesktop ? 16 : 24), ShopInBitPrivacyCheckbox( value: _privacyAccepted, onChanged: (v) => setState(() => _privacyAccepted = v), ), - SizedBox(height: isDesktop ? 16 : 12), + const SizedBox(height: 32), ShopInBitStep4SubmitButton( submitting: _submitting, enabled: _canContinue, diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart index f0feb9db67..4f59c68697 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart @@ -78,88 +78,85 @@ class _ShopInBitCountryPickerState ).copyWith(color: stackColors.textFieldDefaultSearchIconLeft) : STextStyles.fieldLabel(context); - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: widget.selectedIso, - items: _countries - .map( - (c) => DropdownMenuItem( - value: c["iso"] as String, - child: Text(c["label"] as String, style: itemStyle), - ), - ) - .toList(), - onMenuStateChange: (isOpen) { - if (!isOpen) { - _searchController.clear(); - } - }, - onChanged: _loading ? null : widget.onChanged, - hint: Text( - _loading ? "Loading countries..." : widget.hintText, - style: hintStyle, - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: stackColors.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, + return DropdownButtonHideUnderline( + child: DropdownButton2( + value: widget.selectedIso, + items: _countries + .map( + (c) => DropdownMenuItem( + value: c["iso"] as String, + child: Text(c["label"] as String, style: itemStyle), ), + ) + .toList(), + onMenuStateChange: (isOpen) { + if (!isOpen) { + _searchController.clear(); + } + }, + onChanged: _loading ? null : widget.onChanged, + hint: Text( + _loading ? "Loading countries..." : widget.hintText, + style: hintStyle, + ), + isExpanded: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: stackColors.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: stackColors.textFieldActiveSearchIconRight, - ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: stackColors.textFieldActiveSearchIconRight, ), ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - maxHeight: 300, - decoration: BoxDecoration( - color: stackColors.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + maxHeight: 300, + decoration: BoxDecoration( + color: stackColors.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), ), - dropdownSearchData: DropdownSearchData( - searchController: _searchController, - searchInnerWidgetHeight: 48, - searchInnerWidget: TextFormField( - controller: _searchController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - hintText: "Search...", - hintStyle: STextStyles.fieldLabel(context), - border: InputBorder.none, + ), + dropdownSearchData: DropdownSearchData( + searchController: _searchController, + searchInnerWidgetHeight: 48, + searchInnerWidget: TextFormField( + controller: _searchController, + 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 String? 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), ), + searchMatchFn: (item, searchValue) { + final String? 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), ), ), ); diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart index 9fcb5b958e..82b862ea87 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart @@ -5,12 +5,12 @@ 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"; -import "shopinbit_step4_text_field.dart"; /// Fallback Step 4 form used when no category was selected. Collects a free /// text description and a delivery country. @@ -90,13 +90,15 @@ class _ShopInBitGenericFormState extends ConsumerState { subtitle: "Provide details about your trip.", ), SizedBox(height: isDesktop ? 32 : 24), - ShopInBitStep4TextField( + AdaptiveTextField( controller: _descriptionController, focusNode: _descriptionFocusNode, - hintText: + labelText: "Describe your travel request (destinations, dates, passengers)", minLines: 3, maxLines: 6, + autocorrect: false, + enableSuggestions: false, onChanged: (_) => setState(() {}), ), SizedBox(height: isDesktop ? 24 : 16), diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart b/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart index d5d33475ec..5135a89a50 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart @@ -3,9 +3,6 @@ import "package:flutter/material.dart"; import "../../../utilities/text_styles.dart"; import "../../../utilities/util.dart"; -import "../../../widgets/desktop/desktop_dialog.dart"; -import "../../../widgets/desktop/primary_button.dart"; -import "../../../widgets/desktop/secondary_button.dart"; import "../../../widgets/dialogs/request_external_link_navigation_dialog.dart"; const String _shopInBitPrivacyUrl = @@ -81,46 +78,3 @@ class ShopInBitPrivacyCheckbox extends StatelessWidget { ); } } - -class _DesktopBrowserWarning extends StatelessWidget { - const _DesktopBrowserWarning({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - return DesktopDialog( - maxWidth: 550, - maxHeight: 250, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), - child: Column( - children: [ - Text("Attention", style: STextStyles.desktopH2(context)), - const SizedBox(height: 16), - Text(message, style: STextStyles.desktopTextSmall(context)), - const SizedBox(height: 35), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SecondaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Cancel", - onPressed: () => Navigator.of(context).pop(false), - ), - const SizedBox(width: 20), - PrimaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Continue", - onPressed: () => Navigator.of(context).pop(true), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart index baae092879..d3b2e53c3a 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart @@ -38,54 +38,51 @@ class ShopInBitStep4Dropdown extends StatelessWidget { ).copyWith(color: stackColors.textFieldDefaultSearchIconLeft) : STextStyles.fieldLabel(context); - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: value, - items: items - .map( - (item) => DropdownMenuItem( - value: item, - child: Text(item, style: itemStyle), - ), - ) - .toList(), - onChanged: onChanged, - hint: Text(hintText, style: hintStyle), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: stackColors.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, + return DropdownButtonHideUnderline( + child: DropdownButton2( + value: value, + items: items + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(item, style: itemStyle), ), + ) + .toList(), + onChanged: onChanged, + hint: Text(hintText, style: hintStyle), + isExpanded: true, + buttonStyleData: ButtonStyleData( + decoration: BoxDecoration( + color: stackColors.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: stackColors.textFieldActiveSearchIconRight, - ), + ), + iconStyleData: IconStyleData( + icon: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + Assets.svg.chevronDown, + width: 12, + height: 6, + color: stackColors.textFieldActiveSearchIconRight, ), ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - decoration: BoxDecoration( - color: stackColors.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + elevation: 0, + decoration: BoxDecoration( + color: stackColors.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, ), ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), ), ); diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart index 4c81d7df15..c7b20e101d 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart @@ -17,6 +17,7 @@ class ShopInBitStep4Header extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + mainAxisSize: .min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!Util.isDesktop) ...[ 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 567a5c52b0..9e0eedae68 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart @@ -6,7 +6,6 @@ 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 "../shopinbit_order_created.dart"; /// Submits a ShopinBit request to the API and navigates to the order-created @@ -70,21 +69,12 @@ Future submitShopInBitRequest( .insertOnConflictUpdate(model.toCompanion()); if (!context.mounted) return; - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitOrderCreated(model: model), - ), - ); - } else { - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitOrderCreated.routeName, arguments: model), - ); - } + + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.routeName, arguments: model), + ); } catch (e) { if (context.mounted) { unawaited( diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_text_field.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_text_field.dart deleted file mode 100644 index 7cdc97a30c..0000000000 --- a/lib/pages/shopinbit/step_4_components/shopinbit_step4_text_field.dart +++ /dev/null @@ -1,89 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter/services.dart"; - -import "../../../themes/stack_colors.dart"; -import "../../../utilities/text_styles.dart"; -import "../../../utilities/util.dart"; -import "../../../widgets/stack_text_field.dart"; - -class ShopInBitStep4TextField extends StatelessWidget { - const ShopInBitStep4TextField({ - super.key, - required this.controller, - required this.focusNode, - required this.hintText, - this.errorText, - this.minLines, - this.maxLines = 1, - this.keyboardType, - this.inputFormatters, - this.enabled = true, - this.suffixText, - this.suffixIcon, - this.labelText, - this.readOnly = false, - this.onTap, - this.onChanged, - }); - - final TextEditingController controller; - final FocusNode focusNode; - final String hintText; - final String? errorText; - final int? minLines; - final int? maxLines; - final TextInputType? keyboardType; - final List? inputFormatters; - final bool enabled; - final String? suffixText; - final Widget? suffixIcon; - final String? labelText; - final bool readOnly; - final VoidCallback? onTap; - final ValueChanged? onChanged; - - @override - Widget build(BuildContext context) { - final TextStyle style = Util.isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context); - - return TextField( - controller: controller, - focusNode: focusNode, - autocorrect: false, - enableSuggestions: false, - enabled: enabled, - readOnly: readOnly, - onTap: onTap, - minLines: minLines, - maxLines: maxLines, - keyboardType: keyboardType, - inputFormatters: inputFormatters, - onChanged: onChanged, - style: style, - decoration: - standardInputDecoration( - hintText, - focusNode, - context, - desktopMed: Util.isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: errorText, - suffixText: suffixText, - suffixIcon: suffixIcon, - labelText: labelText, - ), - ); - } -} 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 a1e505f33e..e110eab537 100644 --- a/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart +++ b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart @@ -7,6 +7,8 @@ import "../../../providers/db/drift_provider.dart"; import "../../../providers/global/shopin_bit_service_provider.dart"; import "../../../utilities/text_styles.dart"; import "../../../utilities/util.dart"; +import "../../../widgets/date_picker/date_picker.dart"; +import "../../../widgets/textfields/adaptive_text_field.dart"; import "shopinbit_country_picker.dart"; import "shopinbit_labeled_checkbox.dart"; import "shopinbit_privacy_checkbox.dart"; @@ -14,7 +16,6 @@ import "shopinbit_step4_dropdown.dart"; import "shopinbit_step4_header.dart"; import "shopinbit_step4_submit.dart"; import "shopinbit_step4_submit_button.dart"; -import "shopinbit_step4_text_field.dart"; import "shopinbit_traveler_counter.dart"; const String _exactDates = "Exact dates"; @@ -82,14 +83,8 @@ class _ShopInBitTravelFormState extends ConsumerState { final FocusNode _destinationsFocusNode = FocusNode(); bool _destinationsTouched = false; - final TextEditingController _departureDateController = - TextEditingController(); - final FocusNode _departureDateFocusNode = FocusNode(); - bool _departureDateTouched = false; - - final TextEditingController _returnDateController = TextEditingController(); - final FocusNode _returnDateFocusNode = FocusNode(); - bool _returnDateTouched = false; + DateTime? _departureDate; + DateTime? _returnDate; final TextEditingController _tripLengthController = TextEditingController(); final FocusNode _tripLengthFocusNode = FocusNode(); @@ -129,11 +124,6 @@ class _ShopInBitTravelFormState extends ConsumerState { () => _departureCityTouched = true, ); _wireTouchOnBlur(_destinationsFocusNode, () => _destinationsTouched = true); - _wireTouchOnBlur( - _departureDateFocusNode, - () => _departureDateTouched = true, - ); - _wireTouchOnBlur(_returnDateFocusNode, () => _returnDateTouched = true); _wireTouchOnBlur(_tripLengthFocusNode, () => _tripLengthTouched = true); _wireTouchOnBlur(_travelBudgetFocusNode, () => _travelBudgetTouched = true); } @@ -153,10 +143,6 @@ class _ShopInBitTravelFormState extends ConsumerState { _departureCityFocusNode.dispose(); _destinationsController.dispose(); _destinationsFocusNode.dispose(); - _departureDateController.dispose(); - _departureDateFocusNode.dispose(); - _returnDateController.dispose(); - _returnDateFocusNode.dispose(); _tripLengthController.dispose(); _tripLengthFocusNode.dispose(); _travelBudgetController.dispose(); @@ -169,9 +155,7 @@ class _ShopInBitTravelFormState extends ConsumerState { _selectedYear != null && _selectedMonthSeason != null && _tripLengthController.text.trim().isNotEmpty, - _exactDates => - _departureDateController.text.trim().isNotEmpty && - _returnDateController.text.trim().isNotEmpty, + _exactDates => _departureDate != null && _returnDate != null, _ => false, }; @@ -195,24 +179,6 @@ class _ShopInBitTravelFormState extends ConsumerState { travelBudgetValue >= _minTravelBudget; } - Future _pickDate( - TextEditingController target, - VoidCallback onPicked, - ) async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 3650)), - ); - if (picked != null) { - setState(() { - target.text = _formatDate(picked); - onPicked(); - }); - } - } - String _formatDate(DateTime date) { final String day = date.day.toString().padLeft(2, "0"); final String month = date.month.toString().padLeft(2, "0"); @@ -239,8 +205,8 @@ class _ShopInBitTravelFormState extends ConsumerState { ? " ($_selectedFlexibility)" : ""; parts.add( - "Dates: ${_departureDateController.text.trim()} - " - "${_returnDateController.text.trim()}$flex", + "Dates: ${_formatDate(_departureDate!)} - " + "${_formatDate(_returnDate!)}$flex", ); } else if (_selectedDateMode == _flexibleDates) { parts.add( @@ -309,16 +275,6 @@ class _ShopInBitTravelFormState extends ConsumerState { ? "Required (or check 'I need recommendations')" : null; - final String? departureDateError = - _departureDateTouched && _departureDateController.text.trim().isEmpty - ? "Required" - : null; - - final String? returnDateError = - _returnDateTouched && _returnDateController.text.trim().isEmpty - ? "Required" - : null; - final String? tripLengthError = _tripLengthTouched && _tripLengthController.text.trim().isEmpty ? "Required" @@ -353,15 +309,17 @@ class _ShopInBitTravelFormState extends ConsumerState { hintText: "Arrangement type", onChanged: (value) => setState(() => _selectedArrangement = value), ), - SizedBox(height: isDesktop ? 16 : 12), - ShopInBitStep4TextField( + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( controller: _arrangementDetailsController, focusNode: _arrangementDetailsFocusNode, - hintText: + labelText: "Describe your specific requirements " "(luggage, cabin class, hotel stars, etc.)", minLines: 3, maxLines: 6, + autocorrect: false, + enableSuggestions: false, errorText: arrangementDetailsError, onChanged: (_) => setState(() {}), ), @@ -375,24 +333,28 @@ class _ShopInBitTravelFormState extends ConsumerState { setState(() => _selectedDepartureCountryIso = iso), hintText: "Departure country", ), - SizedBox(height: isDesktop ? 16 : 12), - ShopInBitStep4TextField( + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( controller: _departureCityController, focusNode: _departureCityFocusNode, - hintText: "Departure city", + labelText: "Departure city", + autocorrect: false, + enableSuggestions: false, errorText: departureCityError, onChanged: (_) => setState(() {}), ), - SizedBox(height: isDesktop ? 16 : 12), - ShopInBitStep4TextField( + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( controller: _destinationsController, focusNode: _destinationsFocusNode, - hintText: "e.g. Paris, France; Rome, Italy", + labelText: "Destination city", enabled: !_needsRecommendations, + autocorrect: false, + enableSuggestions: false, errorText: destinationsError, onChanged: (_) => setState(() {}), ), - SizedBox(height: isDesktop ? 12 : 8), + SizedBox(height: isDesktop ? 16 : 12), ShopInBitLabeledCheckbox( value: _needsRecommendations, onChanged: (v) => setState(() => _needsRecommendations = v), @@ -408,37 +370,22 @@ class _ShopInBitTravelFormState extends ConsumerState { hintText: "Date mode", onChanged: (value) => setState(() => _selectedDateMode = value), ), - SizedBox(height: isDesktop ? 16 : 12), + SizedBox(height: isDesktop ? 24 : 16), if (_selectedDateMode == _exactDates) ...[ - ShopInBitStep4TextField( - controller: _departureDateController, - focusNode: _departureDateFocusNode, - hintText: "DD/MM/YYYY", - labelText: "Departure date", - readOnly: true, - onTap: () => _pickDate( - _departureDateController, - () => _departureDateTouched = true, - ), - suffixIcon: const Icon(Icons.calendar_today, size: 18), - errorText: departureDateError, + StackDateRangePicker( + fromDate: _departureDate, + toDate: _returnDate, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 3650)), + onChanged: (from, to) { + setState(() { + _departureDate = from; + _returnDate = to; + }); + }, ), - SizedBox(height: isDesktop ? 16 : 12), - ShopInBitStep4TextField( - controller: _returnDateController, - focusNode: _returnDateFocusNode, - hintText: "DD/MM/YYYY", - labelText: "Return date", - readOnly: true, - onTap: () => _pickDate( - _returnDateController, - () => _returnDateTouched = true, - ), - suffixIcon: const Icon(Icons.calendar_today, size: 18), - errorText: returnDateError, - ), - SizedBox(height: isDesktop ? 16 : 12), + SizedBox(height: isDesktop ? 24 : 16), ShopInBitStep4Dropdown( value: _selectedFlexibility, items: _flexibilities, @@ -454,20 +401,22 @@ class _ShopInBitTravelFormState extends ConsumerState { hintText: "Year", onChanged: (value) => setState(() => _selectedYear = value), ), - SizedBox(height: isDesktop ? 16 : 12), + SizedBox(height: isDesktop ? 24 : 16), ShopInBitStep4Dropdown( value: _selectedMonthSeason, items: _months, - hintText: "Month or season", + hintText: "Month", onChanged: (value) => setState(() => _selectedMonthSeason = value), ), - SizedBox(height: isDesktop ? 16 : 12), - ShopInBitStep4TextField( + SizedBox(height: isDesktop ? 24 : 16), + AdaptiveTextField( controller: _tripLengthController, focusNode: _tripLengthFocusNode, - hintText: "Number of nights", + labelText: "Number of nights", keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], + autocorrect: false, + enableSuggestions: false, errorText: tripLengthError, onChanged: (_) => setState(() {}), ), @@ -504,25 +453,27 @@ class _ShopInBitTravelFormState extends ConsumerState { SizedBox(height: isDesktop ? 24 : 16), _TravelSectionLabel(text: "Budget", isDesktop: isDesktop), SizedBox(height: isDesktop ? 12 : 8), - ShopInBitStep4TextField( + AdaptiveTextField( controller: _travelBudgetController, focusNode: _travelBudgetFocusNode, - hintText: "Minimum 1000 EUR", + labelText: "Minimum 1000 EUR", keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], suffixText: "EUR", + autocorrect: false, + enableSuggestions: false, errorText: travelBudgetError, onChanged: (_) => setState(() {}), ), // Travel doesn't collect delivery country: destinations are in the // form and the API field is set to "DE" on submit. - SizedBox(height: isDesktop ? 16 : 12), + const SizedBox(height: 24), ShopInBitPrivacyCheckbox( value: _privacyAccepted, onChanged: (v) => setState(() => _privacyAccepted = v), ), - SizedBox(height: isDesktop ? 16 : 12), + const SizedBox(height: 32), ShopInBitStep4SubmitButton( submitting: _submitting, enabled: _canContinue, diff --git a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart index a337cd898a..b799d10eb9 100644 --- a/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart +++ b/lib/pages/wallet_view/transaction_views/transaction_search_filter_view.dart @@ -11,7 +11,6 @@ import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; import '../../../models/transaction_filter.dart'; import '../../../providers/global/locale_provider.dart'; @@ -21,9 +20,7 @@ import '../../../themes/theme_providers.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/amount/amount_formatter.dart'; import '../../../utilities/amount/amount_input_formatter.dart'; -import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; -import '../../../utilities/format.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../../wallets/crypto_currency/crypto_currency.dart'; @@ -60,9 +57,6 @@ class _TransactionSearchViewState bool _isActiveSentCheckbox = false; bool _isActiveTradeCheckbox = false; - String _fromDateString = ""; - String _toDateString = ""; - final keywordTextFieldFocusNode = FocusNode(); final amountTextFieldFocusNode = FocusNode(); @@ -79,19 +73,11 @@ class _TransactionSearchViewState _selectedFromDate = filterState.from; _keywordTextEditingController.text = filterState.keyword; - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); - _toDateString = - _selectedToDate == null ? "" : Format.formatDate(_selectedToDate!); - - final String amount = - filterState.amount == null - ? "" - : ref - .read(pAmountFormatter(widget.coin)) - .format(filterState.amount!, withUnitName: false); + final String amount = filterState.amount == null + ? "" + : ref + .read(pAmountFormatter(widget.coin)) + .format(filterState.amount!, withUnitName: false); _amountTextEditingController.text = amount; } @@ -109,239 +95,9 @@ class _TransactionSearchViewState super.dispose(); } - // The following two getters are not required if the - // date fields are to remain unclearable. - Widget get _dateFromText { - final isDateSelected = _fromDateString.isEmpty; - return Text( - isDateSelected ? "From..." : _fromDateString, - style: STextStyles.fieldLabel(context).copyWith( - color: - isDateSelected - ? Theme.of(context).extension()!.textSubtitle2 - : Theme.of(context).extension()!.accentColorDark, - ), - ); - } - - Widget get _dateToText { - final isDateSelected = _toDateString.isEmpty; - return Text( - isDateSelected ? "To..." : _toDateString, - style: STextStyles.fieldLabel(context).copyWith( - color: - isDateSelected - ? Theme.of(context).extension()!.textSubtitle2 - : Theme.of(context).extension()!.accentColorDark, - ), - ); - } - DateTime? _selectedFromDate = DateTime(2007); DateTime? _selectedToDate = DateTime.now(); - Widget _buildDateRangePicker() { - const middleSeparatorPadding = 2.0; - const middleSeparatorWidth = 12.0; - final isDesktop = Util.isDesktop; - - final width = - isDesktop - ? null - : (MediaQuery.of(context).size.width - - (middleSeparatorWidth + - (2 * middleSeparatorPadding) + - (2 * Constants.size.standardPadding))) / - 2; - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: GestureDetector( - key: const Key("transactionSearchViewFromDatePickerKey"), - onTap: () async { - // check and hide keyboard - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 125)); - } - - if (mounted) { - final date = await showSWDatePicker(context); - if (date != null) { - _selectedFromDate = date; - - // flag to adjust date so from date is always before to date - final flag = - _selectedToDate != null && - !_selectedFromDate!.isBefore(_selectedToDate!); - if (flag) { - _selectedToDate = DateTime.fromMillisecondsSinceEpoch( - _selectedFromDate!.millisecondsSinceEpoch, - ); - } - - setState(() { - if (flag) { - _toDateString = - _selectedToDate == null - ? "" - : Format.formatDate(_selectedToDate!); - } - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); - }); - } - } - }, - child: Container( - width: width, - decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - border: Border.all( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 12, - vertical: isDesktop ? 17 : 12, - ), - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.calendar, - height: 20, - width: 20, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, - ), - const SizedBox(width: 10), - Align( - alignment: Alignment.centerLeft, - child: FittedBox(child: _dateFromText), - ), - ], - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: middleSeparatorPadding, - ), - child: Container( - width: middleSeparatorWidth, - // height: 1, - // color: CFColors.smoke, - ), - ), - Expanded( - child: GestureDetector( - key: const Key("transactionSearchViewToDatePickerKey"), - onTap: () async { - // check and hide keyboard - if (FocusScope.of(context).hasFocus) { - FocusScope.of(context).unfocus(); - await Future.delayed(const Duration(milliseconds: 125)); - } - - if (mounted) { - final date = await showSWDatePicker(context); - if (date != null) { - _selectedToDate = date; - - // flag to adjust date so from date is always before to date - final flag = - _selectedFromDate != null && - !_selectedToDate!.isAfter(_selectedFromDate!); - if (flag) { - _selectedFromDate = DateTime.fromMillisecondsSinceEpoch( - _selectedToDate!.millisecondsSinceEpoch, - ); - } - - setState(() { - if (flag) { - _fromDateString = - _selectedFromDate == null - ? "" - : Format.formatDate(_selectedFromDate!); - } - _toDateString = - _selectedToDate == null - ? "" - : Format.formatDate(_selectedToDate!); - }); - } - } - }, - child: Container( - width: width, - decoration: BoxDecoration( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - border: Border.all( - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 12, - vertical: isDesktop ? 17 : 12, - ), - child: Row( - children: [ - SvgPicture.asset( - Assets.svg.calendar, - height: 20, - width: 20, - color: - Theme.of( - context, - ).extension()!.textSubtitle2, - ), - const SizedBox(width: 10), - Align( - alignment: Alignment.centerLeft, - child: FittedBox(child: _dateToText), - ), - ], - ), - ), - ), - ), - ), - if (isDesktop) const SizedBox(width: 24), - ], - ); - } - @override Widget build(BuildContext context) { if (Util.isDesktop) { @@ -356,11 +112,13 @@ class _TransactionSearchViewState } else { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, leading: AppBarBackButton( onPressed: () async { if (FocusScope.of(context).hasFocus) { @@ -472,14 +230,9 @@ class _TransactionSearchViewState children: [ Text( "Sent", - style: - isDesktop - ? STextStyles.desktopTextSmall( - context, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle12(context), ), if (isDesktop) const SizedBox(height: 4), ], @@ -530,14 +283,9 @@ class _TransactionSearchViewState children: [ Text( "Received", - style: - isDesktop - ? STextStyles.desktopTextSmall( - context, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle12(context), ), if (isDesktop) const SizedBox(height: 4), ], @@ -588,14 +336,9 @@ class _TransactionSearchViewState children: [ Text( "Trades", - style: - isDesktop - ? STextStyles.desktopTextSmall( - context, - ) - : STextStyles.itemSubtitle12( - context, - ), + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle12(context), ), if (isDesktop) const SizedBox(height: 4), ], @@ -617,25 +360,35 @@ class _TransactionSearchViewState child: FittedBox( child: Text( "Date", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), SizedBox(height: isDesktop ? 10 : 8), - _buildDateRangePicker(), + Padding( + padding: isDesktop ? const .only(right: 32) : .zero, + child: StackDateRangePicker( + fromDate: _selectedFromDate, + toDate: _selectedToDate, + onChanged: (from, to) { + setState(() { + _selectedFromDate = from; + _selectedToDate = to; + }); + }, + ), + ), SizedBox(height: isDesktop ? 32 : 24), Align( alignment: Alignment.centerLeft, child: FittedBox( child: Text( "Amount", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -653,13 +406,12 @@ class _TransactionSearchViewState controller: _amountTextEditingController, focusNode: amountTextFieldFocusNode, onChanged: (_) => setState(() {}), - keyboardType: - Util.isDesktop - ? null - : const TextInputType.numberWithOptions( - signed: false, - decimal: true, - ), + keyboardType: Util.isDesktop + ? null + : const TextInputType.numberWithOptions( + signed: false, + decimal: true, + ), inputFormatters: [ AmountInputFormatter( decimals: widget.coin.fractionDigits, @@ -677,50 +429,47 @@ class _TransactionSearchViewState // ? newValue // : oldValue), ], - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - height: 1.8, - ) - : STextStyles.field(context), - decoration: standardInputDecoration( - "Enter ${widget.coin.ticker} amount...", - keywordTextFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Enter ${widget.coin.ticker} amount...", + keywordTextFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ) + vertical: 10, + horizontal: 16, + ) : null, - suffixIcon: - _amountTextEditingController.text.isNotEmpty + suffixIcon: _amountTextEditingController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _amountTextEditingController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _amountTextEditingController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -730,10 +479,9 @@ class _TransactionSearchViewState child: FittedBox( child: Text( "Keyword", - style: - isDesktop - ? STextStyles.labelExtraExtraSmall(context) - : STextStyles.smallMed12(context), + style: isDesktop + ? STextStyles.labelExtraExtraSmall(context) + : STextStyles.smallMed12(context), ), ), ), @@ -750,51 +498,48 @@ class _TransactionSearchViewState key: const Key("transactionSearchViewKeywordFieldKey"), controller: _keywordTextEditingController, focusNode: keywordTextFieldFocusNode, - style: - isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: - Theme.of( - context, - ).extension()!.textDark, - height: 1.8, - ) - : STextStyles.field(context), + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + height: 1.8, + ) + : STextStyles.field(context), onChanged: (_) => setState(() {}), - decoration: standardInputDecoration( - "Type keyword...", - keywordTextFieldFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - contentPadding: - isDesktop + decoration: + standardInputDecoration( + "Type keyword...", + keywordTextFieldFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + contentPadding: isDesktop ? const EdgeInsets.symmetric( - vertical: 10, - horizontal: 16, - ) + vertical: 10, + horizontal: 16, + ) : null, - suffixIcon: - _keywordTextEditingController.text.isNotEmpty + suffixIcon: _keywordTextEditingController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _keywordTextEditingController.text = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _keywordTextEditingController.text = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), ), @@ -887,14 +632,13 @@ class _TransactionSearchViewState final amountText = _amountTextEditingController.text; Amount? amount; if (amountText.isNotEmpty && !(amountText == "," || amountText == ".")) { - amount = - amountText.contains(",") - ? Decimal.parse( - amountText.replaceFirst(",", "."), - ).toAmount(fractionDigits: widget.coin.fractionDigits) - : Decimal.parse( - amountText, - ).toAmount(fractionDigits: widget.coin.fractionDigits); + amount = amountText.contains(",") + ? Decimal.parse( + amountText.replaceFirst(",", "."), + ).toAmount(fractionDigits: widget.coin.fractionDigits) + : Decimal.parse( + amountText, + ).toAmount(fractionDigits: widget.coin.fractionDigits); } final TransactionFilter filter = TransactionFilter( diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 83a4d6e8fa..04c0888d59 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -54,6 +54,7 @@ import '../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../wallets/wallet/impl/firo_wallet.dart'; import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; import '../../wallets/wallet/impl/namecoin_wallet.dart'; +import '../../wallets/wallet/impl/salvium_wallet.dart'; import '../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; import '../../wallets/wallet/wallet_mixin_interfaces/cash_fusion_interface.dart'; @@ -1172,6 +1173,7 @@ class _WalletViewState extends ConsumerState { }, ), if (wallet is CoinControlInterface && + wallet is! SalviumWallet && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, @@ -1346,7 +1348,7 @@ class _WalletViewState extends ConsumerState { ); }, ), - if (!viewOnly) + if (!viewOnly && AppConfig.hasFeature(.shopinBit)) WalletNavigationBarItemData( label: "Services", icon: SvgPicture.asset( @@ -1363,21 +1365,22 @@ class _WalletViewState extends ConsumerState { ).pushNamed(ServicesView.routeName); }, ), - WalletNavigationBarItemData( - label: "Gift cards", - icon: CreditCardIcon( - height: 20, - width: 20, - color: Theme.of( - context, - ).extension()!.bottomNavIconIcon, + if (AppConfig.hasFeature(.shopinBit)) + WalletNavigationBarItemData( + label: "Gift cards", + icon: CreditCardIcon( + height: 20, + width: 20, + color: Theme.of( + context, + ).extension()!.bottomNavIconIcon, + ), + onTap: () { + Navigator.of( + context, + ).pushNamed(GiftCardsView.routeName); + }, ), - onTap: () { - Navigator.of( - context, - ).pushNamed(GiftCardsView.routeName); - }, - ), ], ), ), diff --git a/lib/pages_desktop_specific/desktop_menu.dart b/lib/pages_desktop_specific/desktop_menu.dart index 5ffe149a11..7602ca532b 100644 --- a/lib/pages_desktop_specific/desktop_menu.dart +++ b/lib/pages_desktop_specific/desktop_menu.dart @@ -223,17 +223,20 @@ class _DesktopMenuState extends ConsumerState { isExpandedInitially: !_isMinimized, ), ], - const SizedBox(height: 2), - DesktopMenuItem( - key: const ValueKey('services'), - duration: duration, - icon: const DesktopServicesIcon(), - label: "Services", - value: DesktopMenuItemId.services, - onChanged: updateSelectedMenuItem, - controller: controllers[3], - isExpandedInitially: !_isMinimized, - ), + if (AppConfig.hasFeature(.shopinBit) || + AppConfig.hasFeature(.cakePay)) ...[ + const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('services'), + duration: duration, + icon: const DesktopServicesIcon(), + label: "Services", + value: DesktopMenuItemId.services, + onChanged: updateSelectedMenuItem, + controller: controllers[3], + isExpandedInitially: !_isMinimized, + ), + ], const SizedBox(height: 2), DesktopMenuItem( key: const ValueKey('notifications'), diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart index b8dc85f4d7..efc02f6283 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart @@ -56,6 +56,7 @@ import '../../../../wallets/models/tx_data.dart'; import '../../../../wallets/wallet/impl/epiccash_wallet.dart'; import '../../../../wallets/wallet/impl/firo_wallet.dart'; import '../../../../wallets/wallet/impl/mimblewimblecoin_wallet.dart'; +import '../../../../wallets/wallet/impl/salvium_wallet.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/coin_control_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart'; import '../../../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart'; @@ -458,7 +459,9 @@ class _DesktopSendState extends ConsumerState { .read(prefsChangeNotifierProvider) .enableCoinControl; - if (!(wallet is CoinControlInterface && coinControlEnabled) || + if (!(wallet is CoinControlInterface && + wallet is! SalviumWallet && + coinControlEnabled) || (coinControlEnabled && ref.read(desktopUseUTXOs).isEmpty)) { // confirm send all if (amount == availableBalance) { @@ -597,6 +600,7 @@ class _DesktopSendState extends ConsumerState { feeRateType: feeRate, utxos: (wallet is CoinControlInterface && + wallet is! SalviumWallet && coinControlEnabled && ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) @@ -724,6 +728,7 @@ class _DesktopSendState extends ConsumerState { : null, utxos: (wallet is CoinControlInterface && + wallet is! SalviumWallet && coinControlEnabled && ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) @@ -1351,6 +1356,7 @@ class _DesktopSendState extends ConsumerState { ), ) && ref.watch(pWallets).getWallet(walletId) is CoinControlInterface && + ref.watch(pWallets).getWallet(walletId) is! SalviumWallet && (showPrivateBalance ? balType == BalanceType.public : true); return Column( diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart index a9458bfd93..ca0a2ae0cf 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_wallet_features.dart @@ -44,6 +44,7 @@ import '../../../../wallets/crypto_currency/coins/firo.dart'; import '../../../../wallets/wallet/impl/bitcoin_wallet.dart'; import '../../../../wallets/wallet/impl/firo_wallet.dart'; import '../../../../wallets/wallet/impl/namecoin_wallet.dart'; +import '../../../../wallets/wallet/impl/salvium_wallet.dart'; import '../../../../wallets/wallet/intermediate/cryptonote_wallet.dart'; import '../../../../wallets/wallet/intermediate/lib_salvium_wallet.dart'; import '../../../../wallets/wallet/wallet.dart' show Wallet; @@ -561,6 +562,7 @@ class _DesktopWalletFeaturesState extends ConsumerState { prefsChangeNotifierProvider.select((value) => value.enableExchange), ), (wallet is CoinControlInterface && + wallet is! SalviumWallet && ref.watch( prefsChangeNotifierProvider.select( (value) => value.enableCoinControl, diff --git a/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart b/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart index 22b077a9cd..150e6f63b7 100644 --- a/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart +++ b/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart @@ -98,35 +98,34 @@ class _DesktopGiftCardsViewState extends ConsumerState { ), child: Row( children: [ - Expanded( - child: PrimaryButton( - buttonHeight: ButtonHeight.m, - label: "Browse Gift Cards", - enabled: !_torEnabled, - onPressed: () { - showDialog( - context: context, - builder: (_) => const NestedNavigatorDialog( - initialRoute: CakePayVendorsView.routeName, - ), - ); - }, - ), + PrimaryButton( + width: 220, + buttonHeight: ButtonHeight.m, + label: "Browse Gift Cards", + enabled: !_torEnabled, + onPressed: () { + showDialog( + context: context, + builder: (_) => const NestedNavigatorDialog( + initialRoute: CakePayVendorsView.routeName, + ), + ); + }, ), const SizedBox(width: 16), - Expanded( - child: SecondaryButton( - buttonHeight: ButtonHeight.m, - label: "My Orders", - onPressed: () { - showDialog( - context: context, - builder: (_) => const NestedNavigatorDialog( - initialRoute: CakePayOrdersView.routeName, - ), - ); - }, - ), + SecondaryButton( + width: 200, + buttonHeight: ButtonHeight.m, + label: "My Orders", + enabled: !_torEnabled, + onPressed: () { + showDialog( + context: context, + builder: (_) => const NestedNavigatorDialog( + initialRoute: CakePayOrdersView.routeName, + ), + ); + }, ), ], ), diff --git a/lib/pages_desktop_specific/services/desktop_services_view.dart b/lib/pages_desktop_specific/services/desktop_services_view.dart index f94f708831..7a24d94aeb 100644 --- a/lib/pages_desktop_specific/services/desktop_services_view.dart +++ b/lib/pages_desktop_specific/services/desktop_services_view.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../app_config.dart'; import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -12,7 +13,22 @@ import '../settings/settings_menu_item.dart'; import 'cakepay/desktop_gift_cards_view.dart'; import 'shopin_bit/desktop_shopinbit_view.dart'; -final selectedServicesMenuItemStateProvider = StateProvider((_) => 0); +final _selectedServicesMenuItemStateProvider = StateProvider<_MenuItem?>( + (_) => _labels.firstOrNull, +); + +enum _MenuItem { + shopinBit("Services"), + cakePay("Gift Cards"); + + final String value; + const _MenuItem(this.value); +} + +final _labels = [ + if (AppConfig.hasFeature(.shopinBit)) _MenuItem.shopinBit, + if (AppConfig.hasFeature(.cakePay)) _MenuItem.cakePay, +]; class DesktopServicesView extends ConsumerStatefulWidget { const DesktopServicesView({super.key}); @@ -25,22 +41,22 @@ class DesktopServicesView extends ConsumerStatefulWidget { } class _DesktopServicesViewState extends ConsumerState { - final List _labels = const ["Services", "Gift Cards"]; - @override Widget build(BuildContext context) { - final List contentViews = [ - const Navigator( - key: Key("servicesShopInBitDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: DesktopShopInBitView.routeName, - ), - const Navigator( - key: Key("servicesGiftCardsDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: DesktopGiftCardsView.routeName, - ), - ]; + final Map<_MenuItem, Widget> contentViews = { + if (AppConfig.hasFeature(.shopinBit)) + .shopinBit: const Navigator( + key: Key("servicesShopInBitDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: DesktopShopInBitView.routeName, + ), + if (AppConfig.hasFeature(.cakePay)) + .cakePay: const Navigator( + key: Key("servicesGiftCardsDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: DesktopGiftCardsView.routeName, + ), + }; return DesktopScaffold( background: Theme.of(context).extension()!.background, @@ -68,12 +84,11 @@ class _DesktopServicesViewState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (int i = 0; i < _labels.length; i++) - Column( + ..._labels.map( + (label) => Column( mainAxisSize: MainAxisSize.min, children: [ - if (i > 0) const SizedBox(height: 2), - SettingsMenuItem( + SettingsMenuItem<_MenuItem?>( icon: SvgPicture.asset( Assets.svg.polygon, width: 11, @@ -81,28 +96,28 @@ class _DesktopServicesViewState extends ConsumerState { color: ref .watch( - selectedServicesMenuItemStateProvider + _selectedServicesMenuItemStateProvider .state, ) .state == - i + label ? Theme.of(context) .extension()! .accentColorBlue : Colors.transparent, ), - label: _labels[i], - value: i, + label: label.value, + value: label, group: ref .watch( - selectedServicesMenuItemStateProvider + _selectedServicesMenuItemStateProvider .state, ) .state, onChanged: (newValue) => ref .read( - selectedServicesMenuItemStateProvider + _selectedServicesMenuItemStateProvider .state, ) .state = @@ -110,6 +125,7 @@ class _DesktopServicesViewState extends ConsumerState { ), ], ), + ), ], ), ), @@ -121,8 +137,8 @@ class _DesktopServicesViewState extends ConsumerState { Expanded( child: contentViews[ref - .watch(selectedServicesMenuItemStateProvider.state) - .state], + .watch(_selectedServicesMenuItemStateProvider.state) + .state]!, ), ], ), 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 302794ca34..d5a81094d9 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 @@ -1,4 +1,3 @@ -import 'package:drift/drift.dart' show TableOrViewStatements; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,7 +7,7 @@ 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_1.dart'; +import '../../../pages/shopinbit/shopinbit_step_2.dart'; import '../../../pages/shopinbit/shopinbit_tickets_view.dart'; import '../../../providers/db/drift_provider.dart'; import '../../../providers/desktop/current_desktop_menu_item.dart'; @@ -80,13 +79,15 @@ class _DesktopServicesViewState extends ConsumerState { ), ); } else { - // Returning user: go directly to Step1 (skip service overview dialog). + // Returning user: go directly to Step2 (skip service overview dialog + // and the redundant display-name prompt; name is already loaded from + // settings into model). await showDialog( context: context, barrierDismissible: false, builder: (_) => NestedNavigatorDialog( - initialRoute: ShopInBitStep1.routeName, - initialRouteArgs: model, + initialRoute: ShopInBitStep2.routeName, + initialRouteArgs: (model: model, isActuallyFirstStep: true), ), ); @@ -185,31 +186,18 @@ class _DesktopServicesViewState extends ConsumerState { onPressed: _showShopDialog, ), const SizedBox(width: 16), - StreamBuilder( - stream: ref - .watch(pSharedDrift) - .shopInBitTickets - .count() - .watchSingleOrNull(), - builder: (context, snapshot) { - final count = snapshot.data ?? 0; - - return SecondaryButton( - width: 196, - buttonHeight: ButtonHeight.m, - label: count > 0 - ? "My requests ($count)" - : "My requests", - onPressed: () async { - await showDialog( - context: context, - builder: (_) => const NestedNavigatorDialog( - initialRoute: ShopInBitTicketsView.routeName, - ), - ); - if (mounted) setState(() {}); - }, + SecondaryButton( + width: 196, + buttonHeight: ButtonHeight.m, + label: "My requests", + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const NestedNavigatorDialog( + initialRoute: ShopInBitTicketsView.routeName, + ), ); + if (mounted) setState(() {}); }, ), const SizedBox(width: 16), 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 b693f5d3fd..16e791d923 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,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../models/shopinbit/shopinbit_order_model.dart'; -import '../../../../pages/shopinbit/shopinbit_step_1.dart'; +import '../../../../pages/shopinbit/shopinbit_step_2.dart'; import '../../../../utilities/text_styles.dart'; import '../../../../widgets/desktop/primary_button.dart'; import '../../../../widgets/desktop/secondary_button.dart'; @@ -54,7 +54,7 @@ class DesktopShopinBitFirstRun extends StatelessWidget { buttonHeight: ButtonHeight.l, label: "Continue", onPressed: () => Navigator.of(context).pushReplacementNamed( - ShopInBitStep1.routeName, + ShopInBitStep2.routeName, arguments: model, ), ), diff --git a/lib/pages_desktop_specific/settings/desktop_settings_view.dart b/lib/pages_desktop_specific/settings/desktop_settings_view.dart index ee2c423b3d..65569890d1 100644 --- a/lib/pages_desktop_specific/settings/desktop_settings_view.dart +++ b/lib/pages_desktop_specific/settings/desktop_settings_view.dart @@ -94,7 +94,7 @@ class _DesktopSettingsViewState extends ConsumerState { onGenerateRoute: RouteGenerator.generateRoute, initialRoute: AdvancedSettings.routeName, ), //advanced - if (familiarity >= 6) + if (AppConfig.hasFeature(.shopinBit) && familiarity >= 6) const Navigator( key: Key("settingsShopInBitDesktopKey"), onGenerateRoute: RouteGenerator.generateRoute, diff --git a/lib/pages_desktop_specific/settings/settings_menu.dart b/lib/pages_desktop_specific/settings/settings_menu.dart index a7f5129c1a..be49fc2a2a 100644 --- a/lib/pages_desktop_specific/settings/settings_menu.dart +++ b/lib/pages_desktop_specific/settings/settings_menu.dart @@ -46,7 +46,7 @@ class _SettingsMenuState extends ConsumerState { "Syncing preferences", if (AppConfig.hasFeature(AppFeature.themeSelection)) "Appearance", "Advanced", - if (familiarity >= 6) "ShopinBit", + if (AppConfig.hasFeature(.shopinBit) && familiarity >= 6) "ShopinBit", ]; return Column( diff --git a/lib/providers/global/cakepay_orders_provider.dart b/lib/providers/global/cakepay_orders_provider.dart new file mode 100644 index 0000000000..6f68348ccf --- /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( + (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 0000000000..2e46a7e76e --- /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( + (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 0000000000..625850a0aa --- /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 _orders = {}; + final Set _inflight = {}; + final Map _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 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 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 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/cakepay/cakepay_service.dart b/lib/services/cakepay/cakepay_service.dart index fced753afc..ed1bfd0f64 100644 --- a/lib/services/cakepay/cakepay_service.dart +++ b/lib/services/cakepay/cakepay_service.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import 'package:mutex/mutex.dart'; import '../../db/drift/shared_db/shared_database.dart'; import '../../external_api_keys.dart'; @@ -14,6 +15,43 @@ class CakePayService { return _client ??= CakePayClient(apiToken: kCakePayApiToken); } + // TODO clean this up some day + // simple in memory cache + DateTime? _countryNamesUpdated; + List _countryNames = []; + final _countryNamesMutex = Mutex(); + Future> getCountryNames({bool refreshCache = false}) async { + return _countryNamesMutex.protect(() async { + final isFresh = + _countryNamesUpdated != null && + _countryNamesUpdated! + .add(const Duration(hours: 12)) + .isAfter(DateTime.now()); + + if (!refreshCache && isFresh && _countryNames.isNotEmpty) { + return _countryNames; + } + + final response = await client.getAllCountries(); + + if (response.hasError || response.value == null) { + throw response.exception ?? Exception("Failed to fetch countries"); + } + + _countryNames = + response.value! + .where((e) => e.available) + .map((e) => e.name) + .toSet() + .toList() + ..sort(); + + _countryNamesUpdated = DateTime.now(); + + return _countryNames; + }); + } + Future addOrderId(String orderId) async { final db = SharedDrift.get(); diff --git a/lib/services/cakepay/src/client.dart b/lib/services/cakepay/src/client.dart index daaf7f0440..7d401bcf62 100644 --- a/lib/services/cakepay/src/client.dart +++ b/lib/services/cakepay/src/client.dart @@ -27,7 +27,7 @@ class CakePayClient { HTTP? httpClient, }) : _httpClient = httpClient ?? const HTTP(); - Map _headers() => { + late final _authHeaders = { 'Authorization': 'Bearer $apiToken', 'Content-Type': 'application/json', }; @@ -41,7 +41,8 @@ class CakePayClient { // -- Marketplace -- - Future>> getVendors({ + Future vendors, int? nextPage})>> + getVendors({ String? country, String? countryCode, String? search, @@ -70,23 +71,25 @@ class CakePayClient { '/marketplace/vendors/', query: query, parse: (body) { - final decoded = jsonDecode(body); - if (decoded is List) { - return decoded - .whereType>() - .map(CakePayVendor.fromJson) - .toList(); - } - if (decoded is Map) { - final results = decoded['results']; - if (results is List) { - return results - .whereType>() - .map(CakePayVendor.fromJson) - .toList(); - } - } - return []; + final dynamic decoded = jsonDecode(body); + + final List rawList = switch (decoded) { + final List list => list, + {"results": final List results} => results, + _ => const [], + }; + + final List vendors = rawList + .whereType>() + .map(CakePayVendor.fromJson) + .toList(); + + final int? nextPage = + (page != null && pageSize != null && vendors.length >= pageSize) + ? page + 1 + : null; + + return (vendors: vendors, nextPage: nextPage); }, ); } @@ -175,44 +178,8 @@ class CakePayClient { ); } - Future>> getCountries({ - int? page, - int? pageSize, - }) async { - final query = {}; - if (page != null) query['page'] = page.toString(); - if (pageSize != null) query['page_size'] = pageSize.toString(); - - return _requestRaw( - 'GET', - '/marketplace/countries/', - query: query, - parse: (body) { - final decoded = jsonDecode(body); - if (decoded is List) { - return decoded - .whereType>() - .map(CakePayCountry.fromJson) - .toList(); - } - if (decoded is Map) { - final results = decoded['results']; - if (results is List) { - return results - .whereType>() - .map(CakePayCountry.fromJson) - .toList(); - } - } - return []; - }, - ); - } - /// Fetches all countries by following pagination til last page. - Future>> getAllCountries({ - int pageSize = 250, - }) async { + Future>> getAllCountries() async { try { final allCountries = []; int page = 1; @@ -221,7 +188,8 @@ class CakePayClient { final response = await _send( 'GET', '/marketplace/countries/', - query: {'page': page.toString(), 'page_size': pageSize.toString()}, + query: {'page': page.toString()}, + overrideHeaders: {}, // Auth here leads to 403. Why? Who knows? ); if (response.code < 200 || response.code >= 300) { @@ -236,15 +204,16 @@ class CakePayClient { final decoded = jsonDecode(response.body); + // This never gets hit according to docs // Handle non-paginated response (plain list). - if (decoded is List) { - return ApiResponse( - value: decoded - .whereType>() - .map(CakePayCountry.fromJson) - .toList(), - ); - } + // if (decoded is List) { + // return ApiResponse( + // value: decoded + // .whereType>() + // .map(CakePayCountry.fromJson) + // .toList(), + // ); + // } if (decoded is Map) { final results = decoded['results']; @@ -466,12 +435,13 @@ class CakePayClient { String path, { Map? body, Map? query, + Map? overrideHeaders, }) async { var uri = Uri.parse('$baseUrl$path'); if (query != null && query.isNotEmpty) { uri = uri.replace(queryParameters: query); } - final headers = _headers(); + final headers = overrideHeaders ?? _authHeaders; final proxy = _proxyInfo; Logging.instance.t("$_kTag $method $uri"); diff --git a/lib/services/shopinbit/shopinbit_orders_service.dart b/lib/services/shopinbit/shopinbit_orders_service.dart new file mode 100644 index 0000000000..204bb25c3c --- /dev/null +++ b/lib/services/shopinbit/shopinbit_orders_service.dart @@ -0,0 +1,197 @@ +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 d8d2cee319..af9825e2f3 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -1,7 +1,14 @@ +import 'package:drift/drift.dart'; + +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'; const _kShopinBitCustomerKeyKeySecureStore = "shopinBitSecStoreCustomerKeyKey"; @@ -10,7 +17,9 @@ class ShopInBitService { SecureStorageInterface get _secure { if (_secureStorageInterface == null) { - throw Exception("Did you forget to call ShopInBitService.init()?"); + throw Exception( + "Did you forget to call ShopInBitService.ensureInitialized()?", + ); } return _secureStorageInterface!; } @@ -60,4 +69,155 @@ class ShopInBitService { await _secure.delete(key: _kShopinBitCustomerKeyKeySecureStore); Logging.instance.i("ShopInBitService: customer key cleared"); } + + /// 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 []; + } + + final db = SharedDrift.get(); + final localRows = await db.select(db.shopInBitTickets).get(); + final knownApiIds = localRows.map((r) => r.apiTicketId).toSet(); + + final newRefs = resp.value! + .where((r) => !knownApiIds.contains(r.id)) + .toList(); + if (newRefs.isEmpty) return const []; + + // 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(); + } + + Future _hydrateNewTicket(TicketRef ref) async { + 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}", + ); + return null; + } + + final apiMessages = messagesResp.value ?? const []; + + final mappedStatus = + ShopInBitOrderModel.statusFromTicketState(statusResp.value!.state) ?? + ShopInBitOrderStatus.pending; + + 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 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(""), + 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; + } + } +} + +// 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 \(#([^)]+)\)'); +final RegExp _kTravelArrangementRegex = RegExp( + r'^Arrangement:\s', + multiLine: true, +); + +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; + } + if (_kTravelArrangementRegex.hasMatch(content)) { + return ShopInBitCategory.travel; + } + return ShopInBitCategory.concierge; +} + +String? _extractFeeTicketNumber(List messages) { + final firstUser = messages.where((m) => !m.fromAgent).firstOrNull; + if (firstUser == null) return null; + return _kCarResearchFeeRegex.firstMatch(firstUser.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, '') + .trim(); } diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 05a20d15b5..1313d6032d 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -1,3 +1,5 @@ +import '../../../../utilities/logger.dart'; + enum TicketState { newTicket('NEW'), checking('CHECKING'), @@ -11,16 +13,25 @@ enum TicketState { replyNeeded('REPLY NEEDED'), closed('CLOSED'), closedCancelled('CLOSED/CANCELLED'), - merged('MERGED'); + merged('MERGED'), + // Sentinel for any state string the API returns that this client does not + // recognise (e.g. the API added a new state, or renamed an existing one). + // Callers must handle this explicitly: treat as "do not trust", do not + // overwrite previously known good state with it. + unknown('UNKNOWN'); final String value; const TicketState(this.value); - static TicketState fromString(String value) { - return TicketState.values.firstWhere( - (e) => e.value == value, - orElse: () => throw Exception("Unknown TicketState string found: $value"), + static TicketState fromString(String s) { + for (final e in TicketState.values) { + if (e.value == s) return e; + } + Logging.instance.w( + "ShopInBit: unrecognised TicketState '$s' from API: " + "mapping to TicketState.unknown", ); + return TicketState.unknown; } } @@ -45,6 +56,10 @@ class TicketRef { class TicketStatus { final int ticketId; final TicketState state; + // The raw 'state' string returned by the API. Preserved verbatim so that + // unknown / renamed states can be re-derived later via a client update, + // rather than being lost to TicketState.unknown. + final String stateRaw; final DateTime updatedAt; final DateTime? lastAgentMessageAt; final String? paymentInvoiceStatus; @@ -53,6 +68,7 @@ class TicketStatus { TicketStatus({ required this.ticketId, required this.state, + required this.stateRaw, required this.updatedAt, this.lastAgentMessageAt, this.paymentInvoiceStatus, @@ -60,9 +76,11 @@ class TicketStatus { }); factory TicketStatus.fromJson(Map json) { + final rawState = json['state'] as String; return TicketStatus( ticketId: _toInt(json['ticket_id']), - state: TicketState.fromString(json['state'] as String), + state: TicketState.fromString(rawState), + stateRaw: rawState, updatedAt: DateTime.parse(json['updated_at'] as String), lastAgentMessageAt: json['last_agent_message_at'] != null ? DateTime.parse(json['last_agent_message_at'] as String) diff --git a/lib/services/shopinbit/src/models/webhook_event.dart b/lib/services/shopinbit/src/models/webhook_event.dart index 67a160b2cf..e1ff040f39 100644 --- a/lib/services/shopinbit/src/models/webhook_event.dart +++ b/lib/services/shopinbit/src/models/webhook_event.dart @@ -1,16 +1,25 @@ +import '../../../../utilities/logger.dart'; + enum WebhookEventType { ticketStateChanged('ticket.state_changed'), - ticketMessageCreated('ticket.message_created'); + ticketMessageCreated('ticket.message_created'), + // Sentinel for any webhook event_type the API sends that this client does + // not recognise. Callers MUST drop these events rather than dispatch them: + // coercing an unknown event onto a known handler is worse than ignoring it. + unknown('UNKNOWN'); final String value; const WebhookEventType(this.value); - static WebhookEventType fromString(String value) { - return WebhookEventType.values.firstWhere( - (e) => e.value == value, - orElse: () => - throw Exception("Unknown WebhookEventType string found: $value"), + static WebhookEventType fromString(String s) { + for (final e in WebhookEventType.values) { + if (e.value == s) return e; + } + Logging.instance.w( + "ShopInBit: unrecognised WebhookEventType '$s' from API: " + "mapping to WebhookEventType.unknown (event will be dropped)", ); + return WebhookEventType.unknown; } } diff --git a/lib/widgets/animated_widgets/rotating_arrows.dart b/lib/widgets/animated_widgets/rotating_arrows.dart index 3da54f63aa..b0d81e49b0 100644 --- a/lib/widgets/animated_widgets/rotating_arrows.dart +++ b/lib/widgets/animated_widgets/rotating_arrows.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; + import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -56,6 +57,18 @@ class _RotatingArrowsState extends State super.initState(); } + @override + void didUpdateWidget(RotatingArrows oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.spinByDefault != widget.spinByDefault) { + if (widget.spinByDefault) { + animationController.repeat(); + } else { + animationController.stop(); + } + } + } + @override void dispose() { animationController.dispose(); @@ -76,12 +89,14 @@ class _RotatingArrowsState extends State values: [ ValueDelegate.color( const ["**"], - value: widget.color ?? + value: + widget.color ?? Theme.of(context).extension()!.accentColorDark, ), ValueDelegate.strokeColor( const ["**"], - value: widget.color ?? + value: + widget.color ?? Theme.of(context).extension()!.accentColorDark, ), ], diff --git a/lib/widgets/date_picker/date_picker.dart b/lib/widgets/date_picker/date_picker.dart index 328e2c0960..9655fe262f 100644 --- a/lib/widgets/date_picker/date_picker.dart +++ b/lib/widgets/date_picker/date_picker.dart @@ -2,9 +2,13 @@ import 'dart:math'; import 'package:calendar_date_picker2/calendar_date_picker2.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; import '../../utilities/constants.dart'; +import '../../utilities/format.dart'; +import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../conditional_parent.dart'; import '../desktop/primary_button.dart'; @@ -12,7 +16,15 @@ import '../desktop/secondary_button.dart'; part 'sw_date_picker.dart'; -Future showSWDatePicker(BuildContext context) async { +/// [value] holds selected dates. One if [range] is false. Start and end dates +/// otherwise. +Future?> showSWDatePicker( + BuildContext context, { + DateTime? firstDate, + DateTime? lastDate, + List value = const [], + bool range = false, +}) async { final Size size; if (Util.isDesktop) { size = const Size(450, 450); @@ -26,27 +38,28 @@ Future showSWDatePicker(BuildContext context) async { final now = DateTime.now(); - final date = await _showDatePickerDialog( + final dates = await _showDatePickerDialog( context: context, - value: [now], + value: value, dialogSize: size, config: CalendarDatePicker2WithActionButtonsConfig( - firstDate: DateTime(2007), - lastDate: now, + firstDate: firstDate ?? DateTime(2007), + lastDate: lastDate ?? now, currentDate: now, - buttonPadding: const EdgeInsets.only( - right: 16, - ), + rangeBidirectional: range ? false : null, + calendarType: range ? .range : null, + buttonPadding: const EdgeInsets.only(right: 16), centerAlignModePicker: true, - selectedDayHighlightColor: - Theme.of(context).extension()!.accentColorDark, - daySplashColor: Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.6), + selectedDayHighlightColor: Theme.of( + context, + ).extension()!.accentColorDark, + daySplashColor: Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.6), ), ); - return date?.first; + + return dates; } Future?> _showDatePickerDialog({ @@ -63,10 +76,7 @@ Future?> _showDatePickerDialog({ TransitionBuilder? builder, }) { final dialog = Dialog( - insetPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), backgroundColor: Theme.of(context).extension()!.popupBG, surfaceTintColor: Colors.transparent, shadowColor: Colors.transparent, @@ -105,3 +115,220 @@ Future?> _showDatePickerDialog({ useSafeArea: useSafeArea, ); } + +class StackDateRangePicker extends StatelessWidget { + const StackDateRangePicker({ + super.key, + required this.fromDate, + required this.toDate, + this.firstDate, + this.lastDate, + required this.onChanged, + }); + + final DateTime? fromDate; + final DateTime? toDate; + final DateTime? firstDate, lastDate; + final void Function(DateTime? from, DateTime? to) onChanged; + + @override + Widget build(BuildContext context) { + const middleSeparatorPadding = 2.0; + const middleSeparatorWidth = 12.0; + final isDesktop = Util.isDesktop; + + final String fromDateString = switch (fromDate) { + null => "", + final d => Format.formatDate(d), + }; + final String toDateString = switch (toDate) { + null => "", + final d => Format.formatDate(d), + }; + + return Row( + children: [ + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: const Key("transactionSearchViewFromDatePickerKey"), + onTap: () async { + // check and hide keyboard + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 125)); + } + + if (context.mounted) { + final date = (await showSWDatePicker( + context, + firstDate: firstDate, + lastDate: lastDate, + ))?.first; + if (date != null) { + final newFrom = date; + DateTime? newTo = toDate; + + // flag to adjust date so from date is always before to date + if (newTo != null && !newFrom.isBefore(newTo)) { + newTo = DateTime.fromMillisecondsSinceEpoch( + newFrom.millisecondsSinceEpoch, + ); + } + + onChanged(newFrom, newTo); + } + } + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + border: Border.all( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + width: 1, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: isDesktop ? 17 : 12, + ), + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.calendar, + height: 20, + width: 20, + color: Theme.of( + context, + ).extension()!.textSubtitle2, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + fromDateString.isEmpty ? "From..." : fromDateString, + style: STextStyles.fieldLabel(context).copyWith( + color: fromDateString.isEmpty + ? Theme.of( + context, + ).extension()!.textSubtitle2 + : Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: middleSeparatorPadding, + ), + child: Container( + width: middleSeparatorWidth, + // height: 1, + // color: CFColors.smoke, + ), + ), + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + key: const Key("transactionSearchViewToDatePickerKey"), + onTap: () async { + // check and hide keyboard + if (FocusScope.of(context).hasFocus) { + FocusScope.of(context).unfocus(); + await Future.delayed(const Duration(milliseconds: 125)); + } + + if (context.mounted) { + final date = (await showSWDatePicker( + context, + firstDate: firstDate, + lastDate: lastDate, + ))?.first; + if (date != null) { + final newTo = date; + DateTime? newFrom = fromDate; + + // flag to adjust date so from date is always before to date + if (newFrom != null && !newTo.isAfter(newFrom)) { + newFrom = DateTime.fromMillisecondsSinceEpoch( + newTo.millisecondsSinceEpoch, + ); + } + + onChanged(newFrom, newTo); + } + } + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + border: Border.all( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + width: 1, + ), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: isDesktop ? 17 : 12, + ), + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.calendar, + height: 20, + width: 20, + color: Theme.of( + context, + ).extension()!.textSubtitle2, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + toDateString.isEmpty ? "To..." : toDateString, + style: STextStyles.fieldLabel(context).copyWith( + color: toDateString.isEmpty + ? Theme.of( + context, + ).extension()!.textSubtitle2 + : Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart index ae54f23f28..14f788bd0d 100644 --- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart @@ -1,5 +1,10 @@ import 'package:flutter/material.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../desktop/primary_button.dart'; +import '../../desktop/secondary_button.dart'; +import '../s_dialog.dart'; import 'nested_navigator_dialog_route_generator.dart'; class NestedNavigatorDialog extends StatefulWidget { @@ -14,24 +19,106 @@ class NestedNavigatorDialog extends StatefulWidget { final Object? initialRouteArgs; final GlobalKey? navigatorKey; + /// Grabs the nearest [NestedNavigatorDialogState]. Use [maybeOf] if you're + /// not sure one exists. + static NestedNavigatorDialogState of(BuildContext context) { + final NestedNavigatorDialogState? state = maybeOf(context); + assert(state != null, "No NestedNavigatorDialog found above this context."); + return state!; + } + + static NestedNavigatorDialogState? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_NestedNavigatorDialogScope>() + ?.state; + } + @override - State createState() => _NestedNavigatorDialogState(); + State createState() => NestedNavigatorDialogState(); } -class _NestedNavigatorDialogState extends State { +class NestedNavigatorDialogState extends State { late final _CloseOnEmptyObserver _observer; late final GlobalKey _navigatorKey; NavigatorState? _parentNavigator; - void _close() { - if (mounted) _parentNavigator?.pop(); + Future close({ + NestedNavigatorDialogCloseArgs args = const .genericWarning(), + }) async { + if (!mounted) return; + + final bool proceed = switch (args) { + _NoWarning() => true, + _GenericWarning() => await _showGenericWarning(), + _CustomWarning(:final shouldClose) => await shouldClose(), + }; + + if (proceed && mounted) _parentNavigator?.pop(); + } + + Future _showGenericWarning() async { + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + useRootNavigator: true, + builder: (context) { + assert(Util.isDesktop, ""); + + return SDialog( + padding: const .all(32), + child: SizedBox( + width: 500, + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text("Discard changes?", style: STextStyles.desktopH3(context)), + const SizedBox(height: 16), + Text( + "Are you sure you want to close?", + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(height: 40), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(false), + ), + ), + const SizedBox(width: 24), + Expanded( + child: PrimaryButton( + label: "Discard", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(true), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + + return confirmed ?? false; } @override void initState() { super.initState(); - _observer = _CloseOnEmptyObserver(_close); + _observer = _CloseOnEmptyObserver(() => close(args: const .noWarning())); _navigatorKey = widget.navigatorKey ?? GlobalKey(); } @@ -47,23 +134,40 @@ class _NestedNavigatorDialogState extends State { backgroundColor: Colors.transparent, elevation: 0, insetPadding: EdgeInsets.zero, - child: Navigator( - key: _navigatorKey, - observers: [_observer], - onGenerateRoute: NestedNavigatorDialogRouteGenerator.generateRoute, - onGenerateInitialRoutes: (_, _) => [ - NestedNavigatorDialogRouteGenerator.generateRoute( - RouteSettings( - name: widget.initialRoute, - arguments: widget.initialRouteArgs, + child: _NestedNavigatorDialogScope( + state: this, + child: Navigator( + key: _navigatorKey, + observers: [_observer], + onGenerateRoute: NestedNavigatorDialogRouteGenerator.generateRoute, + onGenerateInitialRoutes: (_, _) => [ + NestedNavigatorDialogRouteGenerator.generateRoute( + RouteSettings( + name: widget.initialRoute, + arguments: widget.initialRouteArgs, + ), ), - ), - ], + ], + ), ), ); } } +class _NestedNavigatorDialogScope extends InheritedWidget { + const _NestedNavigatorDialogScope({ + required this.state, + required super.child, + }); + + final NestedNavigatorDialogState state; + + @override + bool updateShouldNotify(_NestedNavigatorDialogScope oldWidget) { + return state != oldWidget.state; + } +} + class _CloseOnEmptyObserver extends NavigatorObserver { _CloseOnEmptyObserver(this.onEmpty); @@ -74,3 +178,27 @@ class _CloseOnEmptyObserver extends NavigatorObserver { if (previousRoute == null) onEmpty(); } } + +sealed class NestedNavigatorDialogCloseArgs { + const NestedNavigatorDialogCloseArgs(); + + const factory NestedNavigatorDialogCloseArgs.noWarning() = _NoWarning; + const factory NestedNavigatorDialogCloseArgs.genericWarning() = + _GenericWarning; + const factory NestedNavigatorDialogCloseArgs.customWarning( + Future Function() shouldClose, + ) = _CustomWarning; +} + +class _NoWarning extends NestedNavigatorDialogCloseArgs { + const _NoWarning(); +} + +class _GenericWarning extends NestedNavigatorDialogCloseArgs { + const _GenericWarning(); +} + +class _CustomWarning extends NestedNavigatorDialogCloseArgs { + const _CustomWarning(this.shouldClose); + final Future Function() shouldClose; +} 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 3aa7d816bb..235aca453a 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 @@ -7,6 +7,9 @@ import '../../../pages/cakepay/cakepay_card_detail_view.dart'; import '../../../pages/cakepay/cakepay_order_view.dart'; 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_order_created.dart'; import '../../../pages/shopinbit/shopinbit_step_1.dart'; import '../../../pages/shopinbit/shopinbit_step_2.dart'; import '../../../pages/shopinbit/shopinbit_step_3.dart'; @@ -16,6 +19,7 @@ import '../../../pages/shopinbit/shopinbit_tickets_view.dart'; import '../../../pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart'; import '../../../services/cakepay/src/models/card.dart'; import '../../../services/cakepay/src/models/order.dart'; +import '../../../services/shopinbit/src/models/models.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; import '../../conditional_parent.dart'; @@ -60,6 +64,15 @@ abstract final class NestedNavigatorDialogRouteGenerator { settings: RouteSettings(name: settings.name), ); } + if (args is ({ShopInBitOrderModel model, bool isActuallyFirstStep})) { + return getRoute( + builder: (_) => ShopInBitStep2( + model: args.model, + isActuallyFirstStep: args.isActuallyFirstStep, + ), + settings: RouteSettings(name: settings.name), + ); + } return _routeError( "${settings.name} invalid args\n" "Got ${args.runtimeType}\n" @@ -98,6 +111,48 @@ abstract final class NestedNavigatorDialogRouteGenerator { settings: RouteSettings(name: settings.name), ); + case ShopInBitOrderCreated.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitOrderCreated(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + + case ShopInBitCarFeeView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitCarFeeView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + + case ShopInBitCarResearchPaymentView.routeName: + if (args is (ShopInBitOrderModel, CarResearchInvoice)) { + return getRoute( + builder: (_) => ShopInBitCarResearchPaymentView( + model: args.$1, + invoice: args.$2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ({ShopInBitOrderModel model, CarResearchInvoice invoice})", + ); + case ShopInBitTicketDetail.routeName: if (args is ShopInBitOrderModel) { return getRoute( @@ -214,7 +269,7 @@ abstract final class NestedNavigatorDialogRouteGenerator { ], ), ), - child: Text( + child: SelectableText( "Error handling route, this is not supposed to happen. " "Contact developers.\n$message", ), diff --git a/lib/widgets/infinite_scroll_list_view.dart b/lib/widgets/infinite_scroll_list_view.dart new file mode 100644 index 0000000000..d027d6c054 --- /dev/null +++ b/lib/widgets/infinite_scroll_list_view.dart @@ -0,0 +1,405 @@ +import "package:flutter/widgets.dart"; + +/// A generic infinite-scroll [ListView]. +/// +/// Works correctly with [shrinkWrap] as long as the parent provides bounded +/// height (e.g. inside a [Flexible] or sized container). +/// +/// Search/filter changes should be applied by updating any state your +/// [fetchPage] closure reads, then calling +/// [InfiniteScrollListController.refresh]. +class InfiniteScrollListView extends StatefulWidget { + const InfiniteScrollListView({ + super.key, + required this.firstPageKey, + required this.fetchPage, + required this.itemBuilder, + this.controller, + this.separatorBuilder, + this.firstPageProgressBuilder, + this.newPageProgressBuilder, + this.firstPageErrorBuilder, + this.newPageErrorBuilder, + this.emptyBuilder, + this.noMoreItemsBuilder, + this.padding, + this.shrinkWrap = false, + this.physics, + this.prefetchThreshold = 200, + }); + + /// Key passed to [fetchPage] for the very first page. + final K firstPageKey; + + /// Fetches a page. Return an [InfiniteScrollPage] with + /// [InfiniteScrollPage.nextPageKey] set to null on the last page. + final Future> Function(K pageKey) fetchPage; + + /// Builds a single data item. + final Widget Function(BuildContext context, T item, int index) itemBuilder; + + final InfiniteScrollListController? controller; + + /// Optional separator builder. Called between data items only (not around + /// the footer). + final Widget Function(BuildContext context, int index)? separatorBuilder; + + final WidgetBuilder? firstPageProgressBuilder; + final WidgetBuilder? newPageProgressBuilder; + final Widget Function(BuildContext context, Object error, VoidCallback retry)? + firstPageErrorBuilder; + final Widget Function(BuildContext context, Object error, VoidCallback retry)? + newPageErrorBuilder; + final WidgetBuilder? emptyBuilder; + final WidgetBuilder? noMoreItemsBuilder; + + final EdgeInsetsGeometry? padding; + final bool shrinkWrap; + final ScrollPhysics? physics; + + /// Pixels from the bottom at which the next page begins fetching. + final double prefetchThreshold; + + @override + State> createState() => + _InfiniteScrollListViewState(); +} + +class _InfiniteScrollListViewState + extends State> { + final ScrollController _scrollController = ScrollController(); + final List _items = []; + + _Status _status = _LoadingFirstPageStatus(); + + /// Incremented on every refresh. Each fetch captures the value at its start; + /// if the captured value differs from the current value when the fetch + /// completes, the result is discarded. + int _generation = 0; + + /// Transition status to a loading variant and start a fetch. + void _fetch(K pageKey) { + setState(() { + _status = _items.isEmpty + ? _LoadingFirstPageStatus() + : _LoadingMoreStatus(); + }); + _runFetch(pageKey); + } + + /// Run a fetch without changing status. Used for the initial fetch and + /// when auto-continuing past an empty page (status is already loading). + Future _runFetch(K pageKey) async { + final generation = _generation; + final wasFirstPage = _items.isEmpty; + + try { + final result = await widget.fetchPage(pageKey); + if (!mounted || generation != _generation) return; + + // Empty page but more pages remain: continue immediately, staying in + // the loading state. (A buggy backend returning unbounded empty pages + // will hammer the API here.) + if (result.items.isEmpty && result.nextPageKey != null) { + return _runFetch(result.nextPageKey as K); + } + + setState(() { + _items.addAll(result.items); + _status = _IdleStatus(nextPageKey: result.nextPageKey); + }); + + // First page may not fill the viewport. After layout, if the list + // still isn't scrollable and more pages exist, fetch the next. + _maybeFetchIfUnderfilled(); + } catch (error) { + if (!mounted || generation != _generation) return; + setState(() { + _status = wasFirstPage + ? _FailedFirstPageStatus(error: error, pageKey: pageKey) + : _FailedMoreStatus(error: error, pageKey: pageKey); + }); + + if (!wasFirstPage) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + }); + } + } + } + + void _maybeFetchIfUnderfilled() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_status case _IdleStatus(nextPageKey: final next?) + when _scrollController.hasClients && + _scrollController.position.maxScrollExtent <= 0) { + _fetch(next); + } + }); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + if (_status case _IdleStatus(nextPageKey: final next?)) { + final position = _scrollController.position; + if (position.pixels >= + position.maxScrollExtent - widget.prefetchThreshold) { + _fetch(next); + } + } + } + + void _refresh() { + _generation++; + setState(() { + _items.clear(); + _status = _LoadingFirstPageStatus(); + }); + _runFetch(widget.firstPageKey); + } + + void _retry() { + if (_status + case _FailedFirstPageStatus(:final pageKey) || + _FailedMoreStatus(:final pageKey)) { + _fetch(pageKey); + } + } + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + widget.controller?._attach(onRefresh: _refresh, onRetry: _retry); + // Status defaults to _LoadingFirstPage so _runFetch can be called directly. + _runFetch(widget.firstPageKey); + } + + @override + void didUpdateWidget(covariant InfiniteScrollListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller?._detach(); + widget.controller?._attach(onRefresh: _refresh, onRetry: _retry); + } + } + + @override + void dispose() { + widget.controller?._detach(); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_items.isEmpty) { + return switch (_status) { + _LoadingFirstPageStatus() => + widget.firstPageProgressBuilder?.call(context) ?? + const _DefaultFirstPageProgress(), + _FailedFirstPageStatus(:final error) => + widget.firstPageErrorBuilder?.call(context, error, _retry) ?? + _DefaultErrorView(error: error, onRetry: _retry), + _IdleStatus() => + widget.emptyBuilder?.call(context) ?? const _DefaultEmpty(), + // Defensive: these variants cannot occur with no items. + _LoadingMoreStatus() || + _FailedMoreStatus() => const SizedBox.shrink(), + }; + } + + final Widget? footer = switch (_status) { + _LoadingMoreStatus() => + widget.newPageProgressBuilder?.call(context) ?? + const _DefaultNewPageProgress(), + _FailedMoreStatus(:final error) => + widget.newPageErrorBuilder?.call(context, error, _retry) ?? + _DefaultErrorView(error: error, onRetry: _retry), + _IdleStatus(nextPageKey: null) => widget.noMoreItemsBuilder?.call( + context, + ), + _IdleStatus() => + widget.newPageProgressBuilder?.call(context) ?? + const _DefaultNewPageProgress(), + // Defensive: these variants cannot occur with items present. + _LoadingFirstPageStatus() || _FailedFirstPageStatus() => null, + }; + + final itemCount = _items.length + (footer != null ? 1 : 0); + + return NotificationListener( + onNotification: (_) { + _maybeFetchIfUnderfilled(); + return false; + }, + child: ListView.separated( + controller: _scrollController, + primary: false, + shrinkWrap: widget.shrinkWrap, + physics: widget.physics, + padding: widget.padding, + itemCount: itemCount, + separatorBuilder: (context, index) { + if (index == _items.length - 1 && footer != null) { + return const SizedBox.shrink(); + } + return widget.separatorBuilder?.call(context, index) ?? + const SizedBox.shrink(); + }, + itemBuilder: (context, index) { + if (index < _items.length) { + return widget.itemBuilder(context, _items[index], index); + } + return footer!; + }, + ), + ); + } +} + +// ============================================================================= +// ========= Supporting ======================================================== + +/// A page of results returned from [InfiniteScrollListView.fetchPage]. +/// +/// Set [nextPageKey] to null to signal that this is the last page. +class InfiniteScrollPage { + InfiniteScrollPage({required this.items, required this.nextPageKey}); + + final List items; + final K? nextPageKey; +} + +/// Triggers refresh and retry on an [InfiniteScrollListView] from outside. +/// +/// Create one in the parent's state, pass it to +/// [InfiniteScrollListView.controller], and call [refresh] when search/filter +/// state changes. In-flight fetches from before the refresh are discarded +/// when they complete. +class InfiniteScrollListController { + VoidCallback? _onRefresh; + VoidCallback? _onRetry; + + void _attach({ + required VoidCallback onRefresh, + required VoidCallback onRetry, + }) { + _onRefresh = onRefresh; + _onRetry = onRetry; + } + + void _detach() { + _onRefresh = null; + _onRetry = null; + } + + /// Discard current items and reload from the first page. + void refresh() => _onRefresh?.call(); + + /// Retry the last failed fetch. + void retry() => _onRetry?.call(); +} + +/// The load lifecycle of an [InfiniteScrollListView]. A sealed type so all +/// transitions are explicit and the compiler enforces exhaustive handling. +sealed class _Status { + const _Status(); +} + +class _LoadingFirstPageStatus extends _Status { + const _LoadingFirstPageStatus(); +} + +class _LoadingMoreStatus extends _Status { + const _LoadingMoreStatus(); +} + +class _IdleStatus extends _Status { + const _IdleStatus({required this.nextPageKey}); + + /// Null means there are no more pages. + final K? nextPageKey; +} + +class _FailedFirstPageStatus extends _Status { + const _FailedFirstPageStatus({required this.error, required this.pageKey}); + final Object error; + final K pageKey; +} + +class _FailedMoreStatus extends _Status { + const _FailedMoreStatus({required this.error, required this.pageKey}); + final Object error; + final K pageKey; +} + +// ============================================================================= +// ========= Default widgets =================================================== + +class _DefaultFirstPageProgress extends StatelessWidget { + const _DefaultFirstPageProgress(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding(padding: EdgeInsets.all(24), child: Text("Loading...")), + ); + } +} + +class _DefaultNewPageProgress extends StatelessWidget { + const _DefaultNewPageProgress(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text("Loading more..."), + ), + ); + } +} + +class _DefaultEmpty extends StatelessWidget { + const _DefaultEmpty(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding(padding: EdgeInsets.all(24), child: Text("No items")), + ); + } +} + +class _DefaultErrorView extends StatelessWidget { + const _DefaultErrorView({required this.error, required this.onRetry}); + + final Object error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("$error"), + const SizedBox(height: 8), + GestureDetector(onTap: onRetry, child: const Text("Retry")), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/refresh_control.dart b/lib/widgets/refresh_control.dart new file mode 100644 index 0000000000..faaef19a7b --- /dev/null +++ b/lib/widgets/refresh_control.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../themes/stack_colors.dart'; +import '../utilities/util.dart'; +import 'animated_widgets/rotating_arrows.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 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) { + return AppBarIconButton( + // Don't use tooltip to be consistent with rest of UI + // tooltip: tooltip,TODO revisit this if adding tooltips to other controls + // semanticsLabel: tooltip, + color: Theme.of(context).extension()!.textFieldDefaultBG, + size: 40, + onPressed: isRefreshing ? null : onPressed, + icon: RotatingArrows( + spinByDefault: isRefreshing, + width: Util.isDesktop ? 21 : 24, + height: Util.isDesktop ? 21 : 24, + ), + ); + } +} diff --git a/lib/widgets/stack_dialog.dart b/lib/widgets/stack_dialog.dart index 2c56aa7c03..d4ee9a7708 100644 --- a/lib/widgets/stack_dialog.dart +++ b/lib/widgets/stack_dialog.dart @@ -20,12 +20,15 @@ class StackDialogBase extends StatelessWidget { this.child, this.padding = const EdgeInsets.all(24), this.keyboardPaddingAmount = 0, + this.width, }); final EdgeInsets padding; final Widget? child; final double keyboardPaddingAmount; + final double? width; + @override Widget build(BuildContext context) { return SafeArea( @@ -37,22 +40,25 @@ class StackDialogBase extends StatelessWidget { bottom: 16 + keyboardPaddingAmount, ), child: Column( - mainAxisAlignment: - !Util.isDesktop - ? MainAxisAlignment.end - : MainAxisAlignment.center, + mainAxisAlignment: !Util.isDesktop + ? MainAxisAlignment.end + : MainAxisAlignment.center, children: [ Flexible( - child: SingleChildScrollView( - child: Material( - borderRadius: BorderRadius.circular(20), - child: Container( - decoration: BoxDecoration( - color: - Theme.of(context).extension()!.popupBG, - borderRadius: BorderRadius.circular(20), + child: SizedBox( + width: width, + child: SingleChildScrollView( + child: Material( + borderRadius: BorderRadius.circular(20), + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.popupBG, + borderRadius: BorderRadius.circular(20), + ), + child: Padding(padding: padding, child: child), ), - child: Padding(padding: padding, child: child), ), ), ), @@ -72,6 +78,7 @@ class StackDialog extends StatelessWidget { this.icon, required this.title, this.message, + this.width, }); final Widget? leftButton; @@ -82,9 +89,12 @@ class StackDialog extends StatelessWidget { final String title; final String? message; + final double? width; + @override Widget build(BuildContext context) { return StackDialogBase( + width: width, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -119,7 +129,9 @@ class StackDialog extends StatelessWidget { leftButton == null ? const Spacer() : Expanded(child: leftButton!), - const SizedBox(width: 8), + Util.isDesktop + ? const SizedBox(width: 16) + : const SizedBox(width: 8), rightButton == null ? const Spacer() : Expanded(child: rightButton!), @@ -199,26 +211,22 @@ class StackOkDialog extends StatelessWidget { const SizedBox(width: 8), Expanded( child: TextButton( - onPressed: - !Util.isDesktop - ? () { - Navigator.of(context).pop(); - onOkPressed?.call("OK"); + onPressed: !Util.isDesktop + ? () { + Navigator.of(context).pop(); + onOkPressed?.call("OK"); + } + : () { + if (desktopPopRootNavigator) { + Navigator.of(context, rootNavigator: true).pop(); + } else { + int count = 0; + Navigator.of( + context, + ).popUntil((_) => count++ >= 2); + // onOkPressed?.call("OK"); } - : () { - if (desktopPopRootNavigator) { - Navigator.of( - context, - rootNavigator: true, - ).pop(); - } else { - int count = 0; - Navigator.of( - context, - ).popUntil((_) => count++ >= 2); - // onOkPressed?.call("OK"); - } - }, + }, style: Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context), diff --git a/lib/widgets/textfields/adaptive_text_field.dart b/lib/widgets/textfields/adaptive_text_field.dart index da30057e8b..9d1b605d67 100644 --- a/lib/widgets/textfields/adaptive_text_field.dart +++ b/lib/widgets/textfields/adaptive_text_field.dart @@ -13,23 +13,30 @@ class AdaptiveTextField extends StatefulWidget { const AdaptiveTextField({ super.key, this.labelText, + this.hintText, this.controller, this.focusNode, this.autocorrect, this.readOnly = false, + this.enabled = true, this.enableSuggestions = true, this.onChanged, this.onChangedComprehensive, this.onSubmitted, + this.onTap, this.suffixIcons, + this.suffixText, + this.errorText, this.contentPadding, this.minLines, this.maxLines, + this.inputFormatters, this.showPasteClearButton = false, this.keyboardType, }); final String? labelText; + final String? hintText; final TextEditingController? controller; final FocusNode? focusNode; @@ -39,11 +46,13 @@ class AdaptiveTextField extends StatefulWidget { final int? maxLines; final bool readOnly; + final bool enabled; final bool enableSuggestions; final void Function(String)? onChanged; final void Function(String)? onChangedComprehensive; final void Function(String)? onSubmitted; + final VoidCallback? onTap; /// This will be ignored if [suffixIcons] is not null! final bool showPasteClearButton; @@ -51,6 +60,15 @@ class AdaptiveTextField extends StatefulWidget { /// If this is not null, [showPasteClearButton] will be ignored. final List? suffixIcons; + /// Optional trailing text rendered in the decoration's suffixText slot. + /// Ignored when [suffixIcons] is non-empty or [showPasteClearButton] is + /// true, since those occupy the same visual space. + final String? suffixText; + + final String? errorText; + + final List? inputFormatters; + final TextInputType? keyboardType; @override @@ -100,79 +118,104 @@ class _AdaptiveTextFieldState extends State { @override Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: TextField( - minLines: widget.minLines, - maxLines: widget.maxLines, - style: Util.isDesktop - ? STextStyles.field(context).copyWith(fontSize: 16) - : STextStyles.field(context), - controller: controller, - focusNode: _focusNode, - onChanged: widget.onChanged, - readOnly: widget.readOnly, - autocorrect: widget.autocorrect, - enableSuggestions: widget.enableSuggestions, - onSubmitted: widget.onSubmitted, - keyboardType: widget.keyboardType, - decoration: - standardInputDecoration( - widget.labelText, - _focusNode, - context, - ).copyWith( - contentPadding: - widget.contentPadding ?? - (Util.isDesktop - ? const EdgeInsets.only( - left: 12, - top: 11, - bottom: 12, - right: 5, + return Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: TextField( + minLines: widget.minLines, + maxLines: widget.maxLines, + style: Util.isDesktop + ? STextStyles.field(context).copyWith(fontSize: 16) + : STextStyles.field(context), + controller: controller, + focusNode: _focusNode, + onChanged: widget.onChanged, + onTap: widget.onTap, + readOnly: widget.readOnly, + enabled: widget.enabled, + autocorrect: widget.autocorrect, + enableSuggestions: widget.enableSuggestions, + onSubmitted: widget.onSubmitted, + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, + decoration: + standardInputDecoration( + widget.labelText, + _focusNode, + context, + ).copyWith( + hintText: widget.hintText, + suffixText: + (widget.suffixIcons?.isNotEmpty != true && + !widget.showPasteClearButton) + ? widget.suffixText + : null, + contentPadding: + widget.contentPadding ?? + (Util.isDesktop + ? const EdgeInsets.only( + left: 12, + top: 11, + bottom: 12, + right: 5, + ) + : const EdgeInsets.only( + left: 10, + top: 12, + bottom: 8, + right: 5, + )), + suffixIcon: widget.suffixIcons?.isNotEmpty == true + ? Padding( + padding: controller.text.isEmpty + ? const EdgeInsets.only(right: 8) + : const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: widget.suffixIcons!, + ), + ), + ) + : widget.showPasteClearButton + ? TextFieldIconButton( + onTap: () async { + if (controller.text.isEmpty) { + final ClipboardData? data = + await Clipboard.getData(Clipboard.kTextPlain); + if (data?.text != null && + data!.text!.isNotEmpty) { + final content = data.text!.trim(); + controller.text = content; + } + } else { + controller.text = ""; + } + + if (mounted) setState(() {}); + }, + child: controller.text.isNotEmpty + ? const XIcon() + : const ClipboardIcon(), ) - : const EdgeInsets.only( - left: 10, - top: 12, - bottom: 8, - right: 5, - )), - suffixIcon: widget.suffixIcons?.isNotEmpty == true - ? Padding( - padding: controller.text.isEmpty - ? const EdgeInsets.only(right: 8) - : const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: widget.suffixIcons!, - ), - ), - ) - : widget.showPasteClearButton - ? TextFieldIconButton( - onTap: () async { - if (controller.text.isEmpty) { - final ClipboardData? data = await Clipboard.getData( - Clipboard.kTextPlain, - ); - if (data?.text != null && data!.text!.isNotEmpty) { - final content = data.text!.trim(); - controller.text = content; - } - } else { - controller.text = ""; - } - - if (mounted) setState(() {}); - }, - child: controller.text.isNotEmpty - ? const XIcon() - : const ClipboardIcon(), - ) - : null, + : null, + ), + ), + ), + if (widget.errorText != null) + Padding( + padding: const EdgeInsets.only(top: 6, left: 12), + child: Text( + widget.errorText!, + style: STextStyles.errorSmall(context), ), - ), + ), + ], ); } } diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index c5678724b2..098ff12517 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -76,7 +76,7 @@ dependencies: # %%ENABLE_SAL%% # cs_salvium: ^2.0.0 -# cs_salvium_flutter_libs: ^2.0.1 +# cs_salvium_flutter_libs: ^3.0.1 # %%END_ENABLE_SAL%% # %%ENABLE_MWEBD%%