diff --git a/lib/db/drift/shared_database.dart b/lib/db/drift/shared_database.dart new file mode 100644 index 0000000000..2e00d3c90d --- /dev/null +++ b/lib/db/drift/shared_database.dart @@ -0,0 +1,51 @@ +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:path/path.dart' as path; + +import '../../utilities/stack_file_system.dart'; + +part 'shared_database.g.dart'; + +abstract final class SharedDrift { + static bool _didInit = false; + + static SharedDatabase? _db; + + static SharedDatabase get() { + if (!_didInit) { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + _didInit = true; + } + + return _db ??= SharedDatabase._(); + } +} + +class CakepayOrders extends Table { + TextColumn get orderId => text()(); + + @override + Set get primaryKey => {orderId}; +} + +@DriftDatabase(tables: [CakepayOrders]) +final class SharedDatabase extends _$SharedDatabase { + SharedDatabase._([QueryExecutor? executor]) + : super(executor ?? _openConnection()); + + @override + int get schemaVersion => 1; + + static QueryExecutor _openConnection() { + return driftDatabase( + name: "shared", + native: DriftNativeOptions( + shareAcrossIsolates: true, + databasePath: () async { + final dir = await StackFileSystem.applicationDriftDirectory(); + return path.join(dir.path, "shared", "shared.db"); + }, + ), + ); + } +} diff --git a/lib/db/drift/shared_database.g.dart b/lib/db/drift/shared_database.g.dart new file mode 100644 index 0000000000..2e0c5de8b9 --- /dev/null +++ b/lib/db/drift/shared_database.g.dart @@ -0,0 +1,303 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shared_database.dart'; + +// ignore_for_file: type=lint +class $CakepayOrdersTable extends CakepayOrders + with TableInfo<$CakepayOrdersTable, CakepayOrder> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $CakepayOrdersTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _orderIdMeta = const VerificationMeta( + 'orderId', + ); + @override + late final GeneratedColumn orderId = GeneratedColumn( + 'order_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [orderId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'cakepay_orders'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('order_id')) { + context.handle( + _orderIdMeta, + orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta), + ); + } else if (isInserting) { + context.missing(_orderIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {orderId}; + @override + CakepayOrder map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return CakepayOrder( + orderId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_id'], + )!, + ); + } + + @override + $CakepayOrdersTable createAlias(String alias) { + return $CakepayOrdersTable(attachedDatabase, alias); + } +} + +class CakepayOrder extends DataClass implements Insertable { + final String orderId; + const CakepayOrder({required this.orderId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['order_id'] = Variable(orderId); + return map; + } + + CakepayOrdersCompanion toCompanion(bool nullToAbsent) { + return CakepayOrdersCompanion(orderId: Value(orderId)); + } + + factory CakepayOrder.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return CakepayOrder(orderId: serializer.fromJson(json['orderId'])); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return {'orderId': serializer.toJson(orderId)}; + } + + CakepayOrder copyWith({String? orderId}) => + CakepayOrder(orderId: orderId ?? this.orderId); + CakepayOrder copyWithCompanion(CakepayOrdersCompanion data) { + return CakepayOrder( + orderId: data.orderId.present ? data.orderId.value : this.orderId, + ); + } + + @override + String toString() { + return (StringBuffer('CakepayOrder(') + ..write('orderId: $orderId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => orderId.hashCode; + @override + bool operator ==(Object other) => + identical(this, other) || + (other is CakepayOrder && other.orderId == this.orderId); +} + +class CakepayOrdersCompanion extends UpdateCompanion { + final Value orderId; + final Value rowid; + const CakepayOrdersCompanion({ + this.orderId = const Value.absent(), + this.rowid = const Value.absent(), + }); + CakepayOrdersCompanion.insert({ + required String orderId, + this.rowid = const Value.absent(), + }) : orderId = Value(orderId); + static Insertable custom({ + Expression? orderId, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (orderId != null) 'order_id': orderId, + if (rowid != null) 'rowid': rowid, + }); + } + + CakepayOrdersCompanion copyWith({Value? orderId, Value? rowid}) { + return CakepayOrdersCompanion( + orderId: orderId ?? this.orderId, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (orderId.present) { + map['order_id'] = Variable(orderId.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('CakepayOrdersCompanion(') + ..write('orderId: $orderId, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$SharedDatabase extends GeneratedDatabase { + _$SharedDatabase(QueryExecutor e) : super(e); + $SharedDatabaseManager get managers => $SharedDatabaseManager(this); + late final $CakepayOrdersTable cakepayOrders = $CakepayOrdersTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [cakepayOrders]; +} + +typedef $$CakepayOrdersTableCreateCompanionBuilder = + CakepayOrdersCompanion Function({ + required String orderId, + Value rowid, + }); +typedef $$CakepayOrdersTableUpdateCompanionBuilder = + CakepayOrdersCompanion Function({Value orderId, Value rowid}); + +class $$CakepayOrdersTableFilterComposer + extends Composer<_$SharedDatabase, $CakepayOrdersTable> { + $$CakepayOrdersTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnFilters(column), + ); +} + +class $$CakepayOrdersTableOrderingComposer + extends Composer<_$SharedDatabase, $CakepayOrdersTable> { + $$CakepayOrdersTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$CakepayOrdersTableAnnotationComposer + extends Composer<_$SharedDatabase, $CakepayOrdersTable> { + $$CakepayOrdersTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get orderId => + $composableBuilder(column: $table.orderId, builder: (column) => column); +} + +class $$CakepayOrdersTableTableManager + extends + RootTableManager< + _$SharedDatabase, + $CakepayOrdersTable, + CakepayOrder, + $$CakepayOrdersTableFilterComposer, + $$CakepayOrdersTableOrderingComposer, + $$CakepayOrdersTableAnnotationComposer, + $$CakepayOrdersTableCreateCompanionBuilder, + $$CakepayOrdersTableUpdateCompanionBuilder, + ( + CakepayOrder, + BaseReferences<_$SharedDatabase, $CakepayOrdersTable, CakepayOrder>, + ), + CakepayOrder, + PrefetchHooks Function() + > { + $$CakepayOrdersTableTableManager( + _$SharedDatabase db, + $CakepayOrdersTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CakepayOrdersTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CakepayOrdersTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CakepayOrdersTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value orderId = const Value.absent(), + Value rowid = const Value.absent(), + }) => CakepayOrdersCompanion(orderId: orderId, rowid: rowid), + createCompanionCallback: + ({ + required String orderId, + Value rowid = const Value.absent(), + }) => + CakepayOrdersCompanion.insert(orderId: orderId, rowid: rowid), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$CakepayOrdersTableProcessedTableManager = + ProcessedTableManager< + _$SharedDatabase, + $CakepayOrdersTable, + CakepayOrder, + $$CakepayOrdersTableFilterComposer, + $$CakepayOrdersTableOrderingComposer, + $$CakepayOrdersTableAnnotationComposer, + $$CakepayOrdersTableCreateCompanionBuilder, + $$CakepayOrdersTableUpdateCompanionBuilder, + ( + CakepayOrder, + BaseReferences<_$SharedDatabase, $CakepayOrdersTable, CakepayOrder>, + ), + CakepayOrder, + PrefetchHooks Function() + >; + +class $SharedDatabaseManager { + final _$SharedDatabase _db; + $SharedDatabaseManager(this._db); + $$CakepayOrdersTableTableManager get cakepayOrders => + $$CakepayOrdersTableTableManager(_db, _db.cakepayOrders); +} diff --git a/lib/models/shopinbit/shopinbit_order_model.dart b/lib/models/shopinbit/shopinbit_order_model.dart index f41aa49e39..88d247c796 100644 --- a/lib/models/shopinbit/shopinbit_order_model.dart +++ b/lib/models/shopinbit/shopinbit_order_model.dart @@ -1,6 +1,9 @@ +import 'dart:ui'; + import 'package:flutter/foundation.dart'; import '../../services/shopinbit/src/models/ticket.dart'; +import '../../themes/stack_colors.dart'; import '../isar/models/shopinbit_ticket.dart'; enum ShopInBitCategory { concierge, travel, car } @@ -16,7 +19,29 @@ enum ShopInBitOrderStatus { delivered, closed, cancelled, - refunded, + refunded; + + String get label => switch (this) { + .pending => "Pending", + .reviewing => "Under review", + .offerAvailable => "Offer available", + .accepted => "Accepted", + .paymentPending => "Awaiting payment", + .paid => "Paid", + .shipping => "Shipping", + .delivered => "Delivered", + .closed => "Closed", + .cancelled => "Cancelled", + .refunded => "Refunded", + }; + + Color getColor(StackColors colors) => switch (this) { + .delivered => colors.accentColorGreen, + .offerAvailable => colors.accentColorBlue, + .pending || .reviewing => colors.accentColorYellow, + .closed || .cancelled || .refunded => colors.textSubtitle1, + _ => colors.accentColorDark, + }; } class ShopInBitMessage { diff --git a/lib/pages/buy_view/sub_widgets/crypto_selection_view.dart b/lib/pages/buy_view/sub_widgets/crypto_selection_view.dart index 089b492af1..7f7705b436 100644 --- a/lib/pages/buy_view/sub_widgets/crypto_selection_view.dart +++ b/lib/pages/buy_view/sub_widgets/crypto_selection_view.dart @@ -98,8 +98,9 @@ class _CryptoSelectionViewState extends ConsumerState { builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of( + context, + ).extension()!.background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -109,7 +110,7 @@ class _CryptoSelectionViewState extends ConsumerState { const Duration(milliseconds: 50), ); } - if (mounted) { + if (context.mounted) { Navigator.of(context).pop(); } }, @@ -145,45 +146,45 @@ class _CryptoSelectionViewState extends ConsumerState { focusNode: _searchFocusNode, onChanged: filter, style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - }); - filter(""); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + }); + filter(""); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 10), @@ -226,14 +227,12 @@ class _CryptoSelectionViewState extends ConsumerState { const SizedBox(height: 2), Text( _coins[index].ticker.toUpperCase(), - style: STextStyles.smallMed12( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.smallMed12(context) + .copyWith( + color: Theme.of(context) .extension()! .textSubtitle1, - ), + ), ), ], ), diff --git a/lib/pages/cakepay/cakepay_card_detail_view.dart b/lib/pages/cakepay/cakepay_card_detail_view.dart index 7fbd0ebff9..07ec3d6039 100644 --- a/lib/pages/cakepay/cakepay_card_detail_view.dart +++ b/lib/pages/cakepay/cakepay_card_detail_view.dart @@ -1,3 +1,4 @@ +import 'package:decimal/decimal.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -5,7 +6,6 @@ import 'package:url_launcher/url_launcher.dart'; import '../../services/cakepay/cakepay_service.dart'; import '../../services/cakepay/src/models/card.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/constants.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../widgets/background.dart'; @@ -15,9 +15,11 @@ 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/icon_widgets/credit_card_icon.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; -import '../../widgets/stack_text_field.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; import 'cakepay_order_view.dart'; class CakePayCardDetailView extends StatefulWidget { @@ -34,33 +36,21 @@ class CakePayCardDetailView extends StatefulWidget { class _CakePayCardDetailViewState extends State { late CakePayCard _card; bool _purchasing = false; - double? _selectedDenomination; + Decimal? _selectedDenomination; int _quantity = 1; bool _termsAccepted = false; final _customAmountController = TextEditingController(); - final _customAmountFocusNode = FocusNode(); final _emailController = TextEditingController(); - final _emailFocusNode = FocusNode(); - @override - void initState() { - super.initState(); - _card = widget.card; - if (_card.isFixedDenomination && _card.denominations.isNotEmpty) { - _selectedDenomination = _card.denominations.first; - } - _emailFocusNode.addListener(() { - setState(() {}); - }); - } + bool _canPurchase = false; - @override - void dispose() { - _customAmountController.dispose(); - _customAmountFocusNode.dispose(); - _emailController.dispose(); - _emailFocusNode.dispose(); - super.dispose(); + void _updateCanPurchase() { + if (mounted) { + final check = _checkCanPurchase(); + if (check != _canPurchase) { + setState(() => _canPurchase = check); + } + } } String get _priceString { @@ -70,13 +60,13 @@ class _CakePayCardDetailViewState extends State { return _customAmountController.text.trim(); } - bool get _canPurchase { + bool _checkCanPurchase() { if (!_termsAccepted || _purchasing) return false; if (_emailController.text.trim().isEmpty) return false; final price = _priceString; if (price.isEmpty) return false; - final parsed = double.tryParse(price); - if (parsed == null || parsed <= 0) return false; + final parsed = Decimal.tryParse(price); + if (parsed == null || parsed <= Decimal.zero) return false; if (_card.isRangeDenomination) { if (_card.minValue != null && parsed < _card.minValue!) return false; if (_card.maxValue != null && parsed > _card.maxValue!) return false; @@ -182,7 +172,7 @@ class _CakePayCardDetailViewState extends State { } Future _purchase() async { - if (!_canPurchase) return; + if (!_checkCanPurchase()) return; setState(() => _purchasing = true); final resp = await CakePayService.instance.client.createOrder( @@ -200,45 +190,41 @@ class _CakePayCardDetailViewState extends State { if (!resp.hasError && resp.value != null) { final order = resp.value!; - // Track order ID locally so the orders list view can fetch it - // via getOrder() without requiring Knox user auth. - CakePayService.instance.addOrderId(order.orderId); + await CakePayService.instance.addOrderId(order.orderId); - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - await showDialog( - context: context, - builder: (_) => CakePayOrderView(orderId: order.orderId), - ); - } else { - await Navigator.of(context).pushReplacementNamed( - CakePayOrderView.routeName, - arguments: order.orderId, - ); + if (mounted) { + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + await showDialog( + context: context, + builder: (_) => CakePayOrderView(orderId: order.orderId), + ); + } else { + await Navigator.of(context).pushReplacementNamed( + CakePayOrderView.routeName, + arguments: order.orderId, + ); + } } } else { + final String errorMessage; + if (resp.exception != null) { + final ex = resp.exception!; + final body = ex.responseBody; + errorMessage = "${ex.message}${body != null ? "\n$body" : ""}"; + } else { + errorMessage = "Failed to create order"; + } await showDialog( context: context, useSafeArea: false, barrierDismissible: true, builder: (context) { - return StackDialog( + return StackOkDialog( title: "Purchase failed", - message: resp.exception?.message ?? "Failed to create order", - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - child: Text( - "Ok", - style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.buttonTextSecondary, - ), - ), - onPressed: () => Navigator.of(context).pop(), - ), + message: errorMessage, + maxWidth: Util.isDesktop ? 580 : null, + desktopPopRootNavigator: Util.isDesktop, ); }, ); @@ -246,95 +232,356 @@ class _CakePayCardDetailViewState extends State { } } + @override + void initState() { + super.initState(); + _card = widget.card; + if (_card.isFixedDenomination && _card.denominations.isNotEmpty) { + _selectedDenomination = _card.denominations.first; + } + } + + @override + void dispose() { + _customAmountController.dispose(); + _emailController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; final card = _card; - final denominationSelector = card.isFixedDenomination - ? Wrap( - spacing: 8, - runSpacing: 8, - children: card.denominations.map((d) { - final selected = d == _selectedDenomination; - return ChoiceChip( - label: Text( - "${d.toStringAsFixed(0)} ${card.currencyCode ?? ''}", - style: - (isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context)) - .copyWith( - color: selected - ? Theme.of( - context, - ).extension()!.textDark - : null, - ), - ), - selected: selected, - onSelected: (val) { - if (val) setState(() => _selectedDenomination = d); - }, - ); - }).toList(), - ) - : card.isRangeDenomination - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, children: [ - Text( - "Enter amount (${card.minValue?.toStringAsFixed(0) ?? '?'} - " - "${card.maxValue?.toStringAsFixed(0) ?? '?'} " - "${card.currencyCode ?? ''})", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - controller: _customAmountController, - focusNode: _customAmountFocusNode, - keyboardType: const TextInputType.numberWithOptions( - decimal: true, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Gift Card", + style: STextStyles.desktopH3(context), + ), ), - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ), - decoration: - standardInputDecoration( - "Amount", - _customAmountFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: child, ), ), ], - ) - : const SizedBox.shrink(); + ), + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("Gift Card", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: SingleChildScrollView(child: child), + ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: .min, + children: [ + if (card.cardImageUrl != null) + _CardImage(imageUrl: card.cardImageUrl!, isDesktop: isDesktop), + SizedBox(height: isDesktop ? 24 : 16), + Text( + card.name, + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + if (card.description != null && card.description!.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 16 : 12), + _PlainInfoBlock(text: card.description!, isDesktop: isDesktop), + ], + if (card.howToUse != null && card.howToUse!.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 16 : 12), + _TitledInfoBlock( + title: "How to use", + body: card.howToUse!, + isDesktop: isDesktop, + ), + ], + if (card.termsAndConditions != null && + card.termsAndConditions!.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 16 : 12), + _TitledInfoBlock( + title: "Terms & conditions", + body: card.termsAndConditions!, + isDesktop: isDesktop, + ), + ], + if (card.expiryAndValidity != null && + card.expiryAndValidity!.isNotEmpty) ...[ + SizedBox(height: isDesktop ? 16 : 12), + _TitledInfoBlock( + title: "Expiry & validity", + body: card.expiryAndValidity!, + isDesktop: isDesktop, + ), + ], + SizedBox(height: isDesktop ? 24 : 16), + _DenominationSelector( + card: card, + isDesktop: isDesktop, + selectedDenomination: _selectedDenomination, + customAmountController: _customAmountController, + onDenominationSelected: (Decimal d) { + setState(() => _selectedDenomination = d); + _updateCanPurchase(); + }, + onCustomAmountChanged: _updateCanPurchase, + ), + SizedBox(height: isDesktop ? 16 : 12), + _QuantityRow( + isDesktop: isDesktop, + quantity: _quantity, + onDecrement: _quantity > 1 + ? () => setState(() => _quantity--) + : null, + onIncrement: () => setState(() => _quantity++), + ), + SizedBox(height: isDesktop ? 16 : 12), + _TermsCheckbox( + isDesktop: isDesktop, + accepted: _termsAccepted, + onToggle: () { + setState(() => _termsAccepted = !_termsAccepted); + _updateCanPurchase(); + }, + onOpenTerms: _openTerms, + ), + SizedBox(height: isDesktop ? 16 : 12), + Text( + "Email for receipt and delivery", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + AdaptiveTextField( + labelText: "Email", + controller: _emailController, + showPasteClearButton: true, + keyboardType: .emailAddress, + onChangedComprehensive: (_) => _updateCanPurchase(), + ), + SizedBox(height: isDesktop ? 24 : 16), + PrimaryButton( + label: _purchasing ? "Processing..." : "Purchase", + enabled: _canPurchase, + onPressed: _canPurchase ? _purchase : null, + ), + SizedBox(height: isDesktop ? 32 : 16), + ], + ), + ), + ); + } +} + +class _CardImage extends StatelessWidget { + const _CardImage({required this.imageUrl, required this.isDesktop}); + + final String imageUrl; + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + imageUrl, + width: isDesktop ? 200 : 150, + fit: BoxFit.contain, + errorBuilder: (BuildContext _, Object __, StackTrace? ___) => + CreditCardIcon( + width: isDesktop ? 80 : 60, + height: isDesktop ? 80 : 60, + ), + ), + ), + ); + } +} + +class _PlainInfoBlock extends StatelessWidget { + const _PlainInfoBlock({required this.text, required this.isDesktop}); + + final String text; + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Text( + text, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ); + } +} + +class _TitledInfoBlock extends StatelessWidget { + const _TitledInfoBlock({ + required this.title, + required this.body, + required this.isDesktop, + }); + + final String title; + final String body; + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(height: 8), + Text( + body, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ); + } +} + +class _DenominationSelector extends StatelessWidget { + const _DenominationSelector({ + required this.card, + required this.isDesktop, + required this.selectedDenomination, + required this.customAmountController, + required this.onDenominationSelected, + required this.onCustomAmountChanged, + }); + + final CakePayCard card; + final bool isDesktop; + final Decimal? selectedDenomination; + final TextEditingController customAmountController; + final ValueChanged onDenominationSelected; + final VoidCallback onCustomAmountChanged; + + @override + Widget build(BuildContext context) { + if (card.isFixedDenomination) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: card.denominations.map((d) { + final bool selected = d == selectedDenomination; + return ChoiceChip( + label: Text( + "${d.toStringAsFixed(2)} ${card.currencyCode ?? ''}", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: selected + ? Theme.of( + context, + ).extension()!.textDark + : null, + ), + ), + selected: selected, + onSelected: (bool val) { + if (val) onDenominationSelected(d); + }, + ); + }).toList(), + ); + } + + if (card.isRangeDenomination) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + children: [ + Text( + "Enter amount (${card.minValue?.toStringAsFixed(2) ?? '?'} - " + "${card.maxValue?.toStringAsFixed(2) ?? '?'} " + "${card.currencyCode ?? ''})", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + AdaptiveTextField( + labelText: "Amount", + controller: customAmountController, + keyboardType: const .numberWithOptions(decimal: true), + onChangedComprehensive: (_) => onCustomAmountChanged(), + ), + ], + ); + } + + return const SizedBox.shrink(); + } +} + +class _QuantityRow extends StatelessWidget { + const _QuantityRow({ + required this.isDesktop, + required this.quantity, + required this.onDecrement, + required this.onIncrement, + }); + + final bool isDesktop; + final int quantity; + final VoidCallback? onDecrement; + final VoidCallback onIncrement; - final quantityRow = Row( + @override + Widget build(BuildContext context) { + return Row( children: [ Text( "Quantity", @@ -345,23 +592,40 @@ class _CakePayCardDetailViewState extends State { const Spacer(), IconButton( icon: const Icon(Icons.remove_circle_outline, size: 20), - onPressed: _quantity > 1 ? () => setState(() => _quantity--) : null, + onPressed: onDecrement, ), Text( - "$_quantity", + "$quantity", style: isDesktop ? STextStyles.desktopTextSmall(context) : STextStyles.titleBold12(context), ), IconButton( icon: const Icon(Icons.add_circle_outline, size: 20), - onPressed: () => setState(() => _quantity++), + onPressed: onIncrement, ), ], ); + } +} + +class _TermsCheckbox extends StatelessWidget { + const _TermsCheckbox({ + required this.isDesktop, + required this.accepted, + required this.onToggle, + required this.onOpenTerms, + }); + + final bool isDesktop; + final bool accepted; + final VoidCallback onToggle; + final VoidCallback onOpenTerms; - final termsCheckbox = GestureDetector( - onTap: () => setState(() => _termsAccepted = !_termsAccepted), + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onToggle, child: Container( color: Colors.transparent, child: Row( @@ -373,7 +637,7 @@ class _CakePayCardDetailViewState extends State { child: IgnorePointer( child: Checkbox( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: _termsAccepted, + value: accepted, onChanged: (_) {}, ), ), @@ -392,7 +656,7 @@ class _CakePayCardDetailViewState extends State { style: STextStyles.richLink( context, ).copyWith(fontSize: isDesktop ? null : 14), - recognizer: TapGestureRecognizer()..onTap = _openTerms, + recognizer: TapGestureRecognizer()..onTap = onOpenTerms, ), const TextSpan( text: @@ -410,231 +674,5 @@ class _CakePayCardDetailViewState extends State { ), ), ); - - final content = SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (card.cardImageUrl != null) - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - card.cardImageUrl!, - width: isDesktop ? 200 : 150, - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => - Icon(Icons.card_giftcard, size: isDesktop ? 80 : 60), - ), - ), - ), - SizedBox(height: isDesktop ? 16 : 12), - Text( - card.name, - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - if (card.description != null && card.description!.isNotEmpty) ...[ - SizedBox(height: isDesktop ? 16 : 12), - RoundedWhiteContainer( - child: Text( - card.description!, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), - ), - ), - ], - if (card.howToUse != null && card.howToUse!.isNotEmpty) ...[ - SizedBox(height: isDesktop ? 16 : 12), - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "How to use", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.titleBold12(context), - ), - const SizedBox(height: 8), - Text( - card.howToUse!, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), - ), - ], - ), - ), - ], - if (card.termsAndConditions != null && - card.termsAndConditions!.isNotEmpty) ...[ - SizedBox(height: isDesktop ? 16 : 12), - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Terms & conditions", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.titleBold12(context), - ), - const SizedBox(height: 8), - Text( - card.termsAndConditions!, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), - ), - ], - ), - ), - ], - if (card.expiryAndValidity != null && - card.expiryAndValidity!.isNotEmpty) ...[ - SizedBox(height: isDesktop ? 16 : 12), - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Expiry & validity", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.titleBold12(context), - ), - const SizedBox(height: 8), - Text( - card.expiryAndValidity!, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), - ), - ], - ), - ), - ], - SizedBox(height: isDesktop ? 24 : 16), - denominationSelector, - SizedBox(height: isDesktop ? 16 : 12), - quantityRow, - SizedBox(height: isDesktop ? 16 : 12), - termsCheckbox, - SizedBox(height: isDesktop ? 16 : 12), - Text( - "Email for receipt and delivery", - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context), - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - controller: _emailController, - focusNode: _emailFocusNode, - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.emailAddress, - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ), - decoration: - standardInputDecoration( - "Email", - _emailFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), - ), - SizedBox(height: isDesktop ? 24 : 16), - PrimaryButton( - label: _purchasing ? "Processing..." : "Purchase", - enabled: _canPurchase, - onPressed: _canPurchase ? _purchase : null, - ), - ], - ), - ); - - return _scaffold(isDesktop: isDesktop, child: content); - } - - Widget _scaffold({required bool isDesktop, required Widget child}) { - return ConditionalParent( - condition: isDesktop, - builder: (child) => DesktopDialog( - maxWidth: 580, - maxHeight: 700, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "Gift Card", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 8, - ), - child: child, - ), - ), - ], - ), - ), - child: ConditionalParent( - condition: !isDesktop, - builder: (child) => Background( - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () => Navigator.of(context).pop(), - ), - title: Text("Gift Card", style: STextStyles.navBarTitle(context)), - ), - body: SafeArea( - child: Padding(padding: const EdgeInsets.all(16), child: child), - ), - ), - ), - child: child, - ), - ); } } diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart index 71c9fe89a9..62fc0bd41d 100644 --- a/lib/pages/cakepay/cakepay_order_view.dart +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -20,9 +20,10 @@ import '../../wallets/crypto_currency/crypto_currency.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/dialogs/s_dialog.dart'; +import '../../widgets/loading_indicator.dart'; import '../../widgets/qr.dart'; import '../../widgets/rounded_white_container.dart'; import 'cakepay_send_from_view.dart'; @@ -215,12 +216,7 @@ class _CakePayOrderViewState extends ConsumerState { setState(() { _loading = false; if (!resp.hasError && resp.value != null) { - var order = resp.value!; - final override = CakePayService.devStatusOverrides[order.orderId]; - if (override != null) { - order = order.copyWith(status: override); - } - _order = order; + _order = resp.value!; if (_isTerminal(_order!.status)) { _pollTimer?.cancel(); _countdownTimer?.cancel(); @@ -324,60 +320,6 @@ class _CakePayOrderViewState extends ConsumerState { ]; } - String _statusLabel(CakePayOrderStatus status) { - switch (status) { - case CakePayOrderStatus.new_: - return "New"; - case CakePayOrderStatus.expiredButStillPending: - return "Expired (pending)"; - case CakePayOrderStatus.expired: - return "Expired"; - case CakePayOrderStatus.failed: - return "Failed"; - case CakePayOrderStatus.paid: - return "Paid"; - case CakePayOrderStatus.paidPartial: - return "Partially paid"; - case CakePayOrderStatus.pendingPurchase: - return "Pending purchase"; - case CakePayOrderStatus.purchaseProcessing: - return "Processing"; - case CakePayOrderStatus.purchased: - return "Purchased"; - case CakePayOrderStatus.pendingEmail: - return "Pending email"; - case CakePayOrderStatus.complete: - return "Complete"; - case CakePayOrderStatus.pendingRefund: - return "Pending refund"; - case CakePayOrderStatus.refunded: - return "Refunded"; - } - } - - Color _statusColor(BuildContext context, CakePayOrderStatus status) { - final colors = Theme.of(context).extension()!; - switch (status) { - case CakePayOrderStatus.complete: - case CakePayOrderStatus.purchased: - return colors.accentColorGreen; - case CakePayOrderStatus.new_: - case CakePayOrderStatus.paid: - case CakePayOrderStatus.paidPartial: - return colors.accentColorBlue; - case CakePayOrderStatus.pendingPurchase: - case CakePayOrderStatus.purchaseProcessing: - case CakePayOrderStatus.pendingEmail: - case CakePayOrderStatus.expiredButStillPending: - return colors.accentColorYellow; - case CakePayOrderStatus.expired: - case CakePayOrderStatus.failed: - case CakePayOrderStatus.pendingRefund: - case CakePayOrderStatus.refunded: - return colors.textSubtitle1; - } - } - @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; @@ -385,13 +327,7 @@ class _CakePayOrderViewState extends ConsumerState { if (_loading) { return _scaffold( isDesktop: isDesktop, - child: const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), + child: const LoadingIndicator(width: 24, height: 24), ); } @@ -412,24 +348,33 @@ class _CakePayOrderViewState extends ConsumerState { final order = _order!; final paymentOptions = order.paymentOptions; - final statusBadge = Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: _statusColor(context, order.status).withValues(alpha: 0.2), - ), - child: Text( - _statusLabel(order.status), - style: - (isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context)) - .copyWith(color: _statusColor(context, order.status)), - ), - ); - final details = [ - Row(mainAxisAlignment: MainAxisAlignment.end, children: [statusBadge]), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + 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()!, + ), + ), + ), + ), + ], + ), SizedBox(height: isDesktop ? 8 : 6), RoundedWhiteContainer( child: GestureDetector( @@ -727,7 +672,7 @@ class _CakePayOrderViewState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - _statusLabel(status), + status.label, style: (isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) @@ -937,31 +882,33 @@ class _CakePayOrderViewState extends ConsumerState { Widget _scaffold({required bool isDesktop, required Widget child}) { return ConditionalParent( condition: isDesktop, - builder: (child) => DesktopDialog( - maxWidth: 580, - maxHeight: 650, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text("Order", style: STextStyles.desktopH3(context)), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 8, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text("Order", style: STextStyles.desktopH3(context)), + ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 8, + ), + child: child, ), - child: child, ), - ), - ], + ], + ), ), ), child: ConditionalParent( diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart index e1fd13513e..48b966507e 100644 --- a/lib/pages/cakepay/cakepay_orders_view.dart +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -10,6 +10,7 @@ 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/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'cakepay_order_view.dart'; @@ -38,18 +39,13 @@ class _CakePayOrdersViewState extends State { Future _syncFromApi() async { setState(() => _syncing = true); try { - final orderIds = CakePayService.instance.getOrderIds(); + 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) { - var order = resp.value!; - final override = CakePayService.devStatusOverrides[order.orderId]; - if (override != null) { - order = order.copyWith(status: override); - } - results.add(order); + results.add(resp.value!); } } @@ -193,14 +189,7 @@ class _CakePayOrdersViewState extends State { final content = Stack( children: [ list, - if (_syncing) - const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), + if (_syncing) const LoadingIndicator(width: 24, height: 24), ], ); diff --git a/lib/pages/cakepay/cakepay_vendors_view.dart b/lib/pages/cakepay/cakepay_vendors_view.dart index 2c7b3f5cbb..5c16cdd7dd 100644 --- a/lib/pages/cakepay/cakepay_vendors_view.dart +++ b/lib/pages/cakepay/cakepay_vendors_view.dart @@ -15,6 +15,7 @@ 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/icon_widgets/credit_card_icon.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_text_field.dart'; @@ -96,15 +97,19 @@ class _CakePayVendorsViewState extends State { }); } - void _onCardTapped(CakePayCard card) { + Future _onCardTapped(CakePayCard card) async { if (Util.isDesktop) { + // this pop makes going back annoying as the whole list needs to be + // searched again with API calls etc. Leaving in for now as this is how I + // found it and removing here could introduce worse issues somewhere else. Navigator.of(context, rootNavigator: true).pop(); - showDialog( + + await showDialog( context: context, builder: (_) => CakePayCardDetailView(card: card), ); } else { - Navigator.of( + await Navigator.of( context, ).pushNamed(CakePayCardDetailView.routeName, arguments: card); } @@ -165,7 +170,10 @@ class _CakePayVendorsViewState extends State { ), ), body: SafeArea( - child: Padding(padding: const EdgeInsets.all(16), child: child), + child: Padding( + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: child, + ), ), ), ), @@ -205,6 +213,9 @@ class _CakePayVendorsViewState extends State { shrinkWrap: isDesktop, primary: isDesktop ? false : null, itemCount: cards.length, + padding: isDesktop + ? null + : const EdgeInsets.only(bottom: 16), separatorBuilder: (_, __) => SizedBox(height: isDesktop ? 16 : 12), itemBuilder: (_, index) => _CardTile( @@ -256,9 +267,16 @@ class _SearchField extends StatelessWidget { focusNode, context, ).copyWith( - prefixIcon: const Padding( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 12), - child: Icon(Icons.search, size: 20), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), ), ), onSubmitted: onSubmitted, @@ -411,10 +429,15 @@ class _CardTile extends StatelessWidget { width: isDesktop ? 60 : 48, height: isDesktop ? 40 : 32, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Icon(Icons.card_giftcard, size: isDesktop ? 40 : 32), + errorBuilder: (_, __, ___) => CreditCardIcon( + width: isDesktop ? 40 : 32, + height: isDesktop ? 40 : 32, + ), ) - : Icon(Icons.card_giftcard, size: isDesktop ? 40 : 32), + : CreditCardIcon( + width: isDesktop ? 40 : 32, + height: isDesktop ? 40 : 32, + ), ), const SizedBox(width: 12), Expanded( @@ -445,7 +468,12 @@ class _CardTile extends StatelessWidget { ], ), ), - Icon(Icons.chevron_right, color: colors.textSubtitle1), + SvgPicture.asset( + Assets.svg.chevronRight, + width: 20, + height: 20, + colorFilter: ColorFilter.mode(colors.textSubtitle1, .srcIn), + ), ], ), ), diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart index 1a3a88db9b..700088664c 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart @@ -36,6 +36,7 @@ import '../../../widgets/dialogs/basic_dialog.dart'; import '../../../widgets/exchange/trocador/trocador_kyc_info_button.dart'; import '../../../widgets/exchange/trocador/trocador_rating_type_enum.dart'; import '../../../widgets/icon_widgets/exchange_icon.dart'; +import '../../../widgets/loading_indicator.dart'; class ExchangeOption extends ConsumerStatefulWidget { const ExchangeOption({ @@ -388,9 +389,7 @@ class _ProviderOptionState extends ConsumerState { if (loadingProgress == null) { return child; } else { - return const Center( - child: CircularProgressIndicator(), - ); + return const LoadingIndicator(); } }, errorBuilder: (context, error, stackTrace) { diff --git a/lib/pages/more_view/gift_cards_view.dart b/lib/pages/more_view/gift_cards_view.dart index 9fcf82bbca..48ff0f3646 100644 --- a/lib/pages/more_view/gift_cards_view.dart +++ b/lib/pages/more_view/gift_cards_view.dart @@ -1,17 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../services/event_bus/events/global/tor_connection_status_changed_event.dart'; import '../../services/tor_service.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/icon_widgets/credit_card_icon.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/tor_subscription.dart'; import '../cakepay/cakepay_orders_view.dart'; @@ -51,11 +50,7 @@ class _GiftCardsViewState extends ConsumerState { context, ).extension()!.background, appBar: AppBar( - leading: AppBarBackButton( - onPressed: () { - Navigator.of(context).pop(); - }, - ), + leading: const AppBarBackButton(), title: Text("Gift cards", style: STextStyles.navBarTitle(context)), ), body: SafeArea( @@ -69,11 +64,7 @@ class _GiftCardsViewState extends ConsumerState { children: [ Row( children: [ - SvgPicture.asset( - Assets.svg.creditCard, - width: 32, - height: 32, - ), + const CreditCardIcon(width: 32, height: 32), const SizedBox(width: 12), Expanded( child: Column( @@ -116,24 +107,26 @@ class _GiftCardsViewState extends ConsumerState { Row( children: [ Expanded( - child: PrimaryButton( - label: "Browse", + child: SecondaryButton( + label: "My Orders", enabled: !_torEnabled, onPressed: () { Navigator.of( context, - ).pushNamed(CakePayVendorsView.routeName); + ).pushNamed(CakePayOrdersView.routeName); }, ), ), + const SizedBox(width: 16), Expanded( - child: SecondaryButton( - label: "My Orders", + child: PrimaryButton( + label: "Browse", + enabled: !_torEnabled, onPressed: () { Navigator.of( context, - ).pushNamed(CakePayOrdersView.routeName); + ).pushNamed(CakePayVendorsView.routeName); }, ), ), diff --git a/lib/pages/settings_views/global_settings_view/hidden_settings.dart b/lib/pages/settings_views/global_settings_view/hidden_settings.dart index ca102c4c59..46ab31b91c 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -17,8 +17,6 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../../../db/isar/main_db.dart'; import '../../../notifications/show_flush_bar.dart'; import '../../../providers/providers.dart'; -import '../../../services/cakepay/cakepay_service.dart'; -import '../../../services/cakepay/src/models/order.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/constants.dart'; @@ -369,25 +367,6 @@ class HiddenSettings extends StatelessWidget { ); }, ), - const SizedBox(height: 12), - GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (_) => const _CakePayDevStatusDialog(), - ); - }, - child: RoundedWhiteContainer( - child: Text( - "CakePay status overrides", - style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - ), // const SizedBox( // height: 12, // ), @@ -428,124 +407,3 @@ class HiddenSettings extends StatelessWidget { ); } } - -class _CakePayDevStatusDialog extends StatefulWidget { - const _CakePayDevStatusDialog(); - - @override - State<_CakePayDevStatusDialog> createState() => - _CakePayDevStatusDialogState(); -} - -class _CakePayDevStatusDialogState extends State<_CakePayDevStatusDialog> { - late final List _orderIds; - - @override - void initState() { - super.initState(); - _orderIds = CakePayService.instance.getOrderIds(); - } - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).extension()!; - - return AlertDialog( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "CakePay Status Overrides", - style: STextStyles.pageTitleH2(context), - ), - if (CakePayService.devStatusOverrides.isNotEmpty) - TextButton( - onPressed: () { - setState(() { - CakePayService.devStatusOverrides.clear(); - }); - }, - child: Text("Clear all", style: STextStyles.link2(context)), - ), - ], - ), - content: SizedBox( - width: 400, - child: _orderIds.isEmpty - ? Text( - "No tracked CakePay orders.\n" - "Create an order first, then come back here to override " - "its status.", - style: STextStyles.itemSubtitle(context), - ) - : ListView.separated( - shrinkWrap: true, - itemCount: _orderIds.length, - separatorBuilder: (_, __) => const Divider(height: 16), - itemBuilder: (context, index) { - final id = _orderIds[index]; - final current = CakePayService.devStatusOverrides[id]; - - return Row( - children: [ - Expanded( - child: Text( - id.length > 12 ? "${id.substring(0, 12)}..." : id, - style: STextStyles.itemSubtitle12(context), - ), - ), - const SizedBox(width: 8), - DropdownButton( - value: current, - hint: Text( - "API default", - style: STextStyles.itemSubtitle12( - context, - ).copyWith(color: colors.textSubtitle2), - ), - underline: const SizedBox(), - isDense: true, - items: [ - DropdownMenuItem( - value: null, - child: Text( - "API default", - style: STextStyles.itemSubtitle12( - context, - ).copyWith(color: colors.textSubtitle2), - ), - ), - ...CakePayOrderStatus.values.map( - (s) => DropdownMenuItem( - value: s, - child: Text( - s.value, - style: STextStyles.itemSubtitle12(context), - ), - ), - ), - ], - onChanged: (value) { - setState(() { - if (value == null) { - CakePayService.devStatusOverrides.remove(id); - } else { - CakePayService.devStatusOverrides[id] = value; - } - }); - }, - ), - ], - ); - }, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text("Close", style: STextStyles.button(context)), - ), - ], - ); - } -} diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart index ace2f3d37d..f746b03c87 100644 --- a/lib/pages/shopinbit/shopinbit_offer_view.dart +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -11,6 +11,7 @@ import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_shipping_view.dart'; @@ -154,14 +155,6 @@ class _ShopInBitOfferViewState extends State { ], ); - const loadingOverlay = Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - if (isDesktop) { return DesktopDialog( maxWidth: 580, @@ -187,7 +180,12 @@ class _ShopInBitOfferViewState extends State { horizontal: 32, vertical: 16, ), - child: Stack(children: [content, if (_loading) loadingOverlay]), + child: Stack( + children: [ + content, + if (_loading) const LoadingIndicator(width: 24, height: 24), + ], + ), ), ), ], @@ -220,7 +218,7 @@ class _ShopInBitOfferViewState extends State { ), ), ), - if (_loading) loadingOverlay, + if (_loading) const LoadingIndicator(width: 24, height: 24), ], ); }, diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart index 0467d3fb7e..98136696d0 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -29,6 +29,7 @@ import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_send_from_view.dart'; @@ -471,14 +472,6 @@ class _ShopInBitPaymentViewState extends ConsumerState { Widget build(BuildContext context) { final isDesktop = Util.isDesktop; - const loadingOverlay = Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - // Build coin rows from _methods/_addresses final coinRows = []; for (int i = 0; i < _methods.length; i++) { @@ -737,7 +730,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { child: Stack( children: [ SingleChildScrollView(child: content), - if (_loading) loadingOverlay, + if (_loading) const LoadingIndicator(width: 24, height: 24), ], ), ), @@ -779,7 +772,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { ), ), ), - if (_loading) loadingOverlay, + if (_loading) const LoadingIndicator(width: 24, height: 24), ], ); }, diff --git a/lib/pages/shopinbit/shopinbit_step_1.dart b/lib/pages/shopinbit/shopinbit_step_1.dart index 6e6a097c42..a1fa23694c 100644 --- a/lib/pages/shopinbit/shopinbit_step_1.dart +++ b/lib/pages/shopinbit/shopinbit_step_1.dart @@ -2,15 +2,15 @@ import 'package:flutter/material.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/constants.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/stack_text_field.dart'; +import '../../widgets/dialogs/s_dialog.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; import '../exchange_view/sub_widgets/step_row.dart'; import 'shopinbit_step_2.dart'; @@ -27,173 +27,140 @@ class ShopInBitStep1 extends StatefulWidget { class _ShopInBitStep1State extends State { late final TextEditingController _nameController; - late final FocusNode _nameFocusNode; - bool get _canContinue => _nameController.text.trim().isNotEmpty; + bool _canContinue = false; + + void _continue() { + widget.model.displayName = _nameController.text.trim(); + Navigator.of( + context, + ).pushNamed(ShopInBitStep2.routeName, arguments: widget.model); + } @override void initState() { super.initState(); + _canContinue = widget.model.displayName.isNotEmpty; _nameController = TextEditingController(text: widget.model.displayName); - _nameFocusNode = FocusNode(); - - _nameFocusNode.addListener(() { - setState(() {}); - }); } @override void dispose() { _nameController.dispose(); - _nameFocusNode.dispose(); super.dispose(); } - void _continue() { - widget.model.displayName = _nameController.text.trim(); - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ShopInBitStep2(model: widget.model), - ); - } else { - Navigator.of( - context, - ).pushNamed(ShopInBitStep2.routeName, arguments: widget.model); - } - } - @override Widget build(BuildContext context) { final isDesktop = Util.isDesktop; - final content = Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - StepRow( - count: 4, - current: 0, - width: MediaQuery.of(context).size.width - 32, - ), - if (!isDesktop) const SizedBox(height: 14), - Text( - "Create your profile", - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - SizedBox(height: isDesktop ? 16 : 8), - Text( - "Enter a display name to use with ShopinBit.", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - SizedBox(height: isDesktop ? 32 : 24), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - controller: _nameController, - focusNode: _nameFocusNode, - 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( - "Display name", - _nameFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + return ConditionalParent( + condition: isDesktop, + builder: (child) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), ), + const DesktopDialogCloseButton(), + ], + ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: child, ), + ), + ], ), ), - const Spacer(), - PrimaryButton( - label: "Next", - enabled: _canContinue, - onPressed: _canContinue ? _continue : null, + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (child) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: child), + ), + ), + ); + }, + ), + ), + ), ), - ], - ); - - if (isDesktop) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 400, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - "ShopinBit", - style: STextStyles.desktopH3(context), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - child: content, + if (!isDesktop) + StepRow( + count: 4, + current: 0, + width: MediaQuery.of(context).size.width - 32, ), + const SizedBox(height: 14), + Text( + "Create your profile", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Enter a display name to use with ShopinBit.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), ), + SizedBox(height: isDesktop ? 32 : 24), + AdaptiveTextField( + labelText: "Display name", + controller: _nameController, + autocorrect: false, + enableSuggestions: false, + onChangedComprehensive: (value) { + if (mounted && _canContinue != value.isNotEmpty) { + setState(() => _canContinue = value.isNotEmpty); + } + }, + ), + isDesktop ? const SizedBox(height: 32) : const Spacer(), + PrimaryButton( + label: "Next", + enabled: _canContinue, + onPressed: _canContinue ? _continue : null, + ), + if (isDesktop) const SizedBox(height: 32), ], ), - ); - } - - return Background( - child: Scaffold( - backgroundColor: Theme.of(context).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton( - onPressed: () => Navigator.of(context).pop(), - ), - title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), - ), - ), - ); - }, - ), - ), ), ); } diff --git a/lib/pages/shopinbit/shopinbit_step_2.dart b/lib/pages/shopinbit/shopinbit_step_2.dart index 9df909ef52..b69cc87974 100644 --- a/lib/pages/shopinbit/shopinbit_step_2.dart +++ b/lib/pages/shopinbit/shopinbit_step_2.dart @@ -8,12 +8,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/dialogs/s_dialog.dart'; +import '../../widgets/rounded_container.dart'; import '../exchange_view/sub_widgets/step_row.dart'; -import 'shopinbit_step_1.dart'; import 'shopinbit_step_3.dart'; import 'shopinbit_step_4.dart'; @@ -31,6 +32,22 @@ class ShopInBitStep2 extends StatefulWidget { class _ShopInBitStep2State extends State { ShopInBitCategory? _selected; + void _continue() { + widget.model.category = _selected; + final skipGuidelines = ShopInBitService.instance.loadGuidelinesAccepted(); + + if (skipGuidelines) { + widget.model.guidelinesAccepted = true; + Navigator.of( + context, + ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); + } else { + Navigator.of( + context, + ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model); + } + } + @override void initState() { super.initState(); @@ -39,257 +56,209 @@ class _ShopInBitStep2State extends State { _selected = null; } - void _popBack() { - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ShopInBitStep1(model: widget.model), - ); - } else { - Navigator.of(context).pop(); - } - } - - void _continue() { - widget.model.category = _selected; - final skipGuidelines = ShopInBitService.instance.loadGuidelinesAccepted(); - - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - if (skipGuidelines) { - widget.model.guidelinesAccepted = true; - Navigator.of( - context, - ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); - } else { - Navigator.of( - context, - ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model); - } - } else { - if (skipGuidelines) { - widget.model.guidelinesAccepted = true; - Navigator.of( - context, - ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); - } else { - Navigator.of( - context, - ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model); - } - } - } + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; - Widget _categoryCard({ - required ShopInBitCategory category, - required String title, - required String description, - required String iconAsset, - required bool isDesktop, - }) { - final isSelected = _selected == category; - return GestureDetector( - onTap: () => setState(() => _selected = category), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(isDesktop ? 16 : 12), - border: Border.all( - color: isSelected - ? Theme.of(context).extension()!.textDark - : Theme.of(context).extension()!.background, - width: 2, - ), - color: Theme.of(context).extension()!.popupBG, - ), - padding: EdgeInsets.all(isDesktop ? 20 : 16), - child: Row( - children: [ - Container( - width: isDesktop ? 48 : 40, - height: isDesktop ? 48 : 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of( - context, - ).extension()!.textDark.withOpacity(0.1), - ), - alignment: Alignment.center, - child: SvgPicture.asset( - iconAsset, - width: isDesktop ? 24 : 20, - height: isDesktop ? 24 : 20, - color: Theme.of(context).extension()!.textDark, - ), - ), - SizedBox(width: isDesktop ? 16 : 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return ConditionalParent( + condition: isDesktop, + builder: (content) => SDialog( + child: SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title, - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.titleBold12(context), - ), - const SizedBox(height: 4), - Text( - description, - style: isDesktop - ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, - ), + Row( + children: [ + const AppBarBackButton(isCompact: true, iconSize: 23), + Text("ShopinBit", style: STextStyles.desktopH3(context)), + ], ), + const DesktopDialogCloseButton(), ], ), + Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: content, + ), + ), + ], + ), + ), + ), + child: ConditionalParent( + condition: !isDesktop, + builder: (content) => Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), ), - if (isSelected) - Icon( - Icons.check_circle, - color: Theme.of(context).extension()!.textDark, - size: isDesktop ? 24 : 20, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ); + }, ), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isDesktop) + StepRow( + count: 4, + current: 1, + width: MediaQuery.of(context).size.width - 32, + ), + const SizedBox(height: 14), + Text( + "Choose a service", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Select the type of service you need.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + SizedBox(height: isDesktop ? 32 : 24), + _CategoryCard( + category: .concierge, + title: "Concierge", + description: "Purchase products and services online.", + iconAsset: Assets.svg.dollarSign, + isSelected: _selected == .concierge, + onTap: (value) => setState(() => _selected = value), + ), + SizedBox(height: isDesktop ? 16 : 12), + _CategoryCard( + category: .travel, + title: "Travel", + description: "Book flights, hotels, and more.", + iconAsset: Assets.svg.circleArrowUpRight, + isSelected: _selected == .travel, + onTap: (value) => setState(() => _selected = value), + ), + SizedBox(height: isDesktop ? 16 : 12), + _CategoryCard( + category: .car, + title: "Car", + description: "Find and purchase vehicles.", + iconAsset: Assets.svg.boxAuto, + isSelected: _selected == .car, + onTap: (value) => setState(() => _selected = value), + ), + isDesktop ? const SizedBox(height: 32) : const Spacer(), + PrimaryButton( + label: "Next", + enabled: _selected != null, + onPressed: _selected != null ? _continue : null, + ), + if (isDesktop) const SizedBox(height: 32), ], ), ), ); } +} + +class _CategoryCard extends StatelessWidget { + const _CategoryCard({ + super.key, + required this.category, + required this.title, + required this.description, + required this.iconAsset, + required this.isSelected, + required this.onTap, + }); + + final ShopInBitCategory category; + final String title; + final String description; + final String iconAsset; + final bool isSelected; + final ValueChanged onTap; @override Widget build(BuildContext context) { + final StackColors colors = Theme.of(context).extension()!; final isDesktop = Util.isDesktop; - final content = Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - StepRow( - count: 4, - current: 1, - width: MediaQuery.of(context).size.width - 32, + return RoundedContainer( + color: colors.popupBG, + borderColor: colors.textFieldDefaultBG, + onPressed: () => onTap(category), + child: Row( + children: [ + Container( + width: isDesktop ? 48 : 40, + height: isDesktop ? 48 : 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.textDark.withOpacity(0.1), + ), + alignment: Alignment.center, + child: SvgPicture.asset( + iconAsset, + width: isDesktop ? 24 : 20, + height: isDesktop ? 24 : 20, + color: colors.textDark, + ), ), - if (!isDesktop) const SizedBox(height: 14), - Text( - "Choose a service", - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - SizedBox(height: isDesktop ? 16 : 8), - Text( - "Select the type of service you need.", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - SizedBox(height: isDesktop ? 32 : 24), - _categoryCard( - category: ShopInBitCategory.concierge, - title: "Concierge", - description: "Purchase products and services online.", - iconAsset: Assets.svg.dollarSign, - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 16 : 12), - _categoryCard( - category: ShopInBitCategory.travel, - title: "Travel", - description: "Book flights, hotels, and more.", - iconAsset: Assets.svg.circleArrowUpRight, - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 16 : 12), - _categoryCard( - category: ShopInBitCategory.car, - title: "Car", - description: "Find and purchase vehicles.", - iconAsset: Assets.svg.boxAuto, - isDesktop: isDesktop, - ), - const Spacer(), - PrimaryButton( - label: "Next", - enabled: _selected != null, - onPressed: _selected != null ? _continue : null, - ), - ], - ); - - if (isDesktop) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 700, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - AppBarBackButton( - isCompact: true, - iconSize: 23, - onPressed: _popBack, - ), - Text("ShopinBit", style: STextStyles.desktopH3(context)), - ], + Text( + title, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, + const SizedBox(height: 4), + Text( + description, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12( + context, + ).copyWith(color: colors.textSubtitle1), ), - child: content, - ), + ], ), - ], - ), - ); - } - - return Background( - child: PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, dynamic result) { - if (!didPop) { - _popBack(); - } - }, - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton(onPressed: _popBack), - title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), - ), - ), - ); - }, + if (isSelected) + SvgPicture.asset( + Assets.svg.checkCircle, + width: isDesktop ? 24 : 20, + height: isDesktop ? 24 : 20, + colorFilter: ColorFilter.mode(colors.textDark, .srcIn), ), - ), - ), + ], ), ); } diff --git a/lib/pages/shopinbit/shopinbit_step_3.dart b/lib/pages/shopinbit/shopinbit_step_3.dart index 21f7b146f7..4f6b799c45 100644 --- a/lib/pages/shopinbit/shopinbit_step_3.dart +++ b/lib/pages/shopinbit/shopinbit_step_3.dart @@ -12,7 +12,6 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/rounded_white_container.dart'; import '../exchange_view/sub_widgets/step_row.dart'; -import 'shopinbit_step_2.dart'; import 'shopinbit_step_4.dart'; class ShopInBitStep3 extends StatefulWidget { @@ -74,35 +73,14 @@ class _ShopInBitStep3State extends State { } } - void _popBack() { - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ShopInBitStep2(model: widget.model), - ); - } else { - Navigator.of(context).pop(); - } - } - void _continue() { widget.model.guidelinesAccepted = true; // Persist acceptance. ShopInBitService.instance.setGuidelinesAccepted(true); - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ShopInBitStep4(model: widget.model), - ); - } else { - Navigator.of( - context, - ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); - } + + Navigator.of( + context, + ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); } @override @@ -184,11 +162,7 @@ class _ShopInBitStep3State extends State { children: [ Row( children: [ - AppBarBackButton( - isCompact: true, - iconSize: 23, - onPressed: _popBack, - ), + const AppBarBackButton(isCompact: true, iconSize: 23), Text("ShopinBit", style: STextStyles.desktopH3(context)), ], ), @@ -213,9 +187,7 @@ class _ShopInBitStep3State extends State { child: Scaffold( backgroundColor: Theme.of(context).extension()!.background, appBar: AppBar( - leading: AppBarBackButton( - onPressed: () => Navigator.of(context).pop(), - ), + leading: const AppBarBackButton(), title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), ), body: SafeArea( diff --git a/lib/pages/shopinbit/shopinbit_step_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart index 3ead68b6e8..c5cfd4fff8 100644 --- a/lib/pages/shopinbit/shopinbit_step_4.dart +++ b/lib/pages/shopinbit/shopinbit_step_4.dart @@ -1,37 +1,20 @@ -import 'package:dropdown_button2/dropdown_button2.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'dart:async'; - -import '../../db/isar/main_db.dart'; -import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../notifications/show_flush_bar.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; -import '../../themes/stack_colors.dart'; -import '../../utilities/assets.dart'; -import '../../utilities/constants.dart'; -import '../../utilities/text_styles.dart'; -import '../../utilities/util.dart'; -import '../../widgets/background.dart'; -import '../../widgets/stack_dialog.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/rounded_white_container.dart'; -import '../../widgets/stack_text_field.dart'; -import '../exchange_view/sub_widgets/step_row.dart'; -import 'shopinbit_step_3.dart'; -import 'shopinbit_car_fee_view.dart'; -import 'shopinbit_order_created.dart'; -import 'shopinbit_tickets_view.dart'; - -class ShopInBitStep4 extends StatefulWidget { +import "package:flutter/material.dart"; + +import "../../models/shopinbit/shopinbit_order_model.dart"; +import "../../themes/stack_colors.dart"; +import "../../utilities/text_styles.dart"; +import "../../utilities/util.dart"; +import "../../widgets/background.dart"; +import "../../widgets/conditional_parent.dart"; +import "../../widgets/custom_buttons/app_bar_icon_button.dart"; +import "../../widgets/desktop/desktop_dialog.dart"; +import "../../widgets/desktop/desktop_dialog_close_button.dart"; +import "step_4_components/shopinbit_car_research_form.dart"; +import "step_4_components/shopinbit_concierge_form.dart"; +import "step_4_components/shopinbit_generic_form.dart"; +import "step_4_components/shopinbit_travel_form.dart"; + +class ShopInBitStep4 extends StatelessWidget { const ShopInBitStep4({super.key, required this.model}); static const String routeName = "/shopInBitStep4"; @@ -39,2431 +22,89 @@ class ShopInBitStep4 extends StatefulWidget { final ShopInBitOrderModel model; @override - State createState() => _ShopInBitStep4State(); -} - -class _ShopInBitStep4State extends State { - // Generic form controllers. - late final TextEditingController _descriptionController; - late final FocusNode _descriptionFocusNode; - final TextEditingController _countrySearchController = - TextEditingController(); - - // Concierge-specific controllers - late final TextEditingController _whatToPurchaseController; - late final FocusNode _whatToPurchaseFocusNode; - late final TextEditingController _budgetController; - late final FocusNode _budgetFocusNode; - String? _selectedCondition; - bool _noLimit = false; - bool _whatToPurchaseTouched = false; - bool _budgetTouched = false; - - // Car Research-specific controllers - late final TextEditingController _brandController; - late final FocusNode _brandFocusNode; - late final TextEditingController _modelController; - late final FocusNode _modelFocusNode; - late final TextEditingController _carDescriptionController; - late final FocusNode _carDescriptionFocusNode; - late final TextEditingController _carBudgetController; - late final FocusNode _carBudgetFocusNode; - String? _selectedCarCondition; - bool _feeAcknowledged = false; - bool _brandTouched = false; - bool _modelTouched = false; - bool _carDescriptionTouched = false; - bool _carBudgetTouched = false; - - // Travel-specific controllers - late final TextEditingController _departureCountryController; - late final FocusNode _departureCountryFocusNode; - String? _selectedDepartureCountryIso; - final TextEditingController _departureCountrySearchController = - TextEditingController(); - late final TextEditingController _arrangementDetailsController; - late final FocusNode _arrangementDetailsFocusNode; - bool _arrangementDetailsTouched = false; - late final TextEditingController _departureCityController; - late final FocusNode _departureCityFocusNode; - late final TextEditingController _destinationsController; - late final FocusNode _destinationsFocusNode; - late final TextEditingController _departureDateController; - late final FocusNode _departureDateFocusNode; - late final TextEditingController _returnDateController; - late final FocusNode _returnDateFocusNode; - late final TextEditingController _tripLengthController; - late final FocusNode _tripLengthFocusNode; - late final TextEditingController _travelBudgetController; - late final FocusNode _travelBudgetFocusNode; - - // Travel dropdown state - String? _selectedArrangement; - String? _selectedDateMode; - String? _selectedFlexibility; - String? _selectedYear; - String? _selectedMonthSeason; - bool _needsRecommendations = false; - int _adults = 1; - int _children = 0; - int _infants = 0; - int _pets = 0; - - // Travel touched booleans - bool _departureCountryTouched = false; - bool _departureCityTouched = false; - bool _destinationsTouched = false; - bool _departureDateTouched = false; - bool _returnDateTouched = false; - bool _tripLengthTouched = false; - bool _travelBudgetTouched = false; - - List> _countries = []; - String? _selectedCountryIso; - bool _loadingCountries = false; - - bool _submitting = false; - bool _privacyAccepted = false; - - Future _showOpenBrowserWarning(BuildContext context, String url) async { - final uri = Uri.parse(url); - final shouldContinue = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => Util.isDesktop - ? 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( - "You are about to open " - "${uri.scheme}://${uri.host} " - "in your browser.", - 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, - rootNavigator: true, - ).pop(false); - }, - ), - const SizedBox(width: 20), - PrimaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Continue", - onPressed: () { - Navigator.of( - context, - rootNavigator: true, - ).pop(true); - }, - ), - ], - ), - ], - ), - ), - ) - : StackDialog( - title: "Attention", - message: - "You are about to open " - "${uri.scheme}://${uri.host} " - "in your browser.", - leftButton: TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text( - "Cancel", - style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, - ), - ), - ), - rightButton: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text("Continue", style: STextStyles.button(context)), - ), - ), - ); - return shouldContinue ?? false; - } - - bool get _budgetIsValid { - final text = _budgetController.text.trim(); - if (text.isEmpty) return false; - final value = int.tryParse(text); - return value != null && value >= 1000 && value <= 100000; - } - - bool get _canContinue { - final cat = widget.model.category; - if (cat == ShopInBitCategory.concierge) { - return !_submitting && - _privacyAccepted && - _whatToPurchaseController.text.trim().length >= 10 && - _selectedCondition != null && - (_noLimit || _budgetIsValid) && - _selectedCountryIso != null; - } - if (cat == ShopInBitCategory.car) { - final carBudgetVal = int.tryParse(_carBudgetController.text.trim()); - return !_submitting && - _privacyAccepted && - _feeAcknowledged && - _brandController.text.trim().length >= 3 && - _modelController.text.trim().length >= 3 && - _carDescriptionController.text.trim().length >= 3 && - _selectedCarCondition != null && - carBudgetVal != null && - carBudgetVal >= 20000 && - _selectedCountryIso != null; - } - if (cat == ShopInBitCategory.travel) { - final travelBudgetVal = int.tryParse(_travelBudgetController.text.trim()); - final hasValidDates = _selectedDateMode == "Flexible dates" - ? (_selectedYear != null && - _selectedMonthSeason != null && - _tripLengthController.text.trim().isNotEmpty) - : (_selectedDateMode == "Exact dates" && - _departureDateController.text.trim().isNotEmpty && - _returnDateController.text.trim().isNotEmpty); - return !_submitting && - _privacyAccepted && - _selectedArrangement != null && - _arrangementDetailsController.text.trim().length >= 10 && - _selectedDepartureCountryIso != null && - _departureCityController.text.trim().isNotEmpty && - (_needsRecommendations || - _destinationsController.text.trim().isNotEmpty) && - _selectedDateMode != null && - hasValidDates && - _adults >= 1 && - travelBudgetVal != null && - travelBudgetVal >= 1000; - } - // generic fallback - return !_submitting && - _privacyAccepted && - _descriptionController.text.trim().isNotEmpty && - _selectedCountryIso != null; - } - - @override - void initState() { - super.initState(); - _descriptionController = TextEditingController( - text: widget.model.requestDescription, - ); - _descriptionFocusNode = FocusNode(); - _descriptionFocusNode.addListener(() => setState(() {})); - - // Concierge-specific init - _whatToPurchaseController = TextEditingController(); - _whatToPurchaseFocusNode = FocusNode(); - _whatToPurchaseFocusNode.addListener(() { - if (!_whatToPurchaseFocusNode.hasFocus) { - _whatToPurchaseTouched = true; - } - setState(() {}); - }); - _budgetController = TextEditingController(text: "1000"); - _budgetFocusNode = FocusNode(); - _budgetFocusNode.addListener(() { - if (!_budgetFocusNode.hasFocus) { - _budgetTouched = true; - } - setState(() {}); - }); - - // Car Research-specific init - _brandController = TextEditingController(); - _brandFocusNode = FocusNode(); - _brandFocusNode.addListener(() { - if (!_brandFocusNode.hasFocus) { - _brandTouched = true; - } - setState(() {}); - }); - _modelController = TextEditingController(); - _modelFocusNode = FocusNode(); - _modelFocusNode.addListener(() { - if (!_modelFocusNode.hasFocus) { - _modelTouched = true; - } - setState(() {}); - }); - _carDescriptionController = TextEditingController(); - _carDescriptionFocusNode = FocusNode(); - _carDescriptionFocusNode.addListener(() { - if (!_carDescriptionFocusNode.hasFocus) { - _carDescriptionTouched = true; - } - setState(() {}); - }); - _carBudgetController = TextEditingController(); - _carBudgetFocusNode = FocusNode(); - _carBudgetFocusNode.addListener(() { - if (!_carBudgetFocusNode.hasFocus) { - _carBudgetTouched = true; - } - setState(() {}); - }); - - // Travel-specific init - _departureCountryController = TextEditingController(); - _departureCountryFocusNode = FocusNode(); - _departureCountryFocusNode.addListener(() { - if (!_departureCountryFocusNode.hasFocus) { - _departureCountryTouched = true; - } - setState(() {}); - }); - _arrangementDetailsController = TextEditingController(); - _arrangementDetailsFocusNode = FocusNode(); - _arrangementDetailsFocusNode.addListener(() { - if (!_arrangementDetailsFocusNode.hasFocus) { - _arrangementDetailsTouched = true; - } - setState(() {}); - }); - _departureCityController = TextEditingController(); - _departureCityFocusNode = FocusNode(); - _departureCityFocusNode.addListener(() { - if (!_departureCityFocusNode.hasFocus) { - _departureCityTouched = true; - } - setState(() {}); - }); - _destinationsController = TextEditingController(); - _destinationsFocusNode = FocusNode(); - _destinationsFocusNode.addListener(() { - if (!_destinationsFocusNode.hasFocus) { - _destinationsTouched = true; - } - setState(() {}); - }); - _departureDateController = TextEditingController(); - _departureDateFocusNode = FocusNode(); - _departureDateFocusNode.addListener(() { - if (!_departureDateFocusNode.hasFocus) { - _departureDateTouched = true; - } - setState(() {}); - }); - _returnDateController = TextEditingController(); - _returnDateFocusNode = FocusNode(); - _returnDateFocusNode.addListener(() { - if (!_returnDateFocusNode.hasFocus) { - _returnDateTouched = true; - } - setState(() {}); - }); - _tripLengthController = TextEditingController(); - _tripLengthFocusNode = FocusNode(); - _tripLengthFocusNode.addListener(() { - if (!_tripLengthFocusNode.hasFocus) { - _tripLengthTouched = true; - } - setState(() {}); - }); - _travelBudgetController = TextEditingController(text: "5000"); - _travelBudgetFocusNode = FocusNode(); - _travelBudgetFocusNode.addListener(() { - if (!_travelBudgetFocusNode.hasFocus) { - _travelBudgetTouched = true; - } - setState(() {}); - }); - - if (widget.model.deliveryCountry.isNotEmpty) { - _selectedCountryIso = widget.model.deliveryCountry; - } - _fetchCountries(); - } - - @override - void dispose() { - _descriptionController.dispose(); - _descriptionFocusNode.dispose(); - _countrySearchController.dispose(); - _whatToPurchaseController.dispose(); - _whatToPurchaseFocusNode.dispose(); - _budgetController.dispose(); - _budgetFocusNode.dispose(); - _brandController.dispose(); - _brandFocusNode.dispose(); - _modelController.dispose(); - _modelFocusNode.dispose(); - _carDescriptionController.dispose(); - _carDescriptionFocusNode.dispose(); - _carBudgetController.dispose(); - _carBudgetFocusNode.dispose(); - _departureCountryController.dispose(); - _departureCountryFocusNode.dispose(); - _departureCountrySearchController.dispose(); - _arrangementDetailsController.dispose(); - _arrangementDetailsFocusNode.dispose(); - _departureCityController.dispose(); - _departureCityFocusNode.dispose(); - _destinationsController.dispose(); - _destinationsFocusNode.dispose(); - _departureDateController.dispose(); - _departureDateFocusNode.dispose(); - _returnDateController.dispose(); - _returnDateFocusNode.dispose(); - _tripLengthController.dispose(); - _tripLengthFocusNode.dispose(); - _travelBudgetController.dispose(); - _travelBudgetFocusNode.dispose(); - super.dispose(); - } - - void _popBack() { - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ShopInBitStep3(model: widget.model), - ); - } else { - Navigator.of(context).pop(); - } - } - - Future _fetchCountries() async { - setState(() => _loadingCountries = true); - try { - final resp = await ShopInBitService.instance.client.getCountries(); - if (resp.hasError || resp.value == null) return; - _countries = resp.value!; - if (_selectedCountryIso != null && - !_countries.any((c) => c['iso'] == _selectedCountryIso)) { - _selectedCountryIso = null; - } - } catch (_) { - // leave list empty; user will see no items - } finally { - if (mounted) setState(() => _loadingCountries = false); - } - } - - Future _submit() async { - // Format structured comment per category. - // Use ISO code for delivery country in comment: country labels can - // contain non-ASCII (e.g. "Åland Islands") which HttpClientRequest.write() - // encodes as Latin-1, corrupting the JSON body on mobile. - final countryIso = _selectedCountryIso!; - if (widget.model.category == ShopInBitCategory.concierge) { - final budgetText = _noLimit - ? "No limit" - : "${_budgetController.text.trim()} EUR"; - widget.model.requestDescription = - "What to purchase: ${_whatToPurchaseController.text.trim()}\n" - "Condition: $_selectedCondition\n" - "Budget: $budgetText\n" - "Delivery country: $countryIso"; - } else if (widget.model.category == ShopInBitCategory.car) { - widget.model.requestDescription = - "Brand: ${_brandController.text.trim()}\n" - "Model: ${_modelController.text.trim()}\n" - "Condition: $_selectedCarCondition\n" - "Description: ${_carDescriptionController.text.trim()}\n" - "Budget: ${_carBudgetController.text.trim()} EUR\n" - "Delivery country: $countryIso"; - } else if (widget.model.category == ShopInBitCategory.travel) { - final parts = [ - "Arrangement: $_selectedArrangement", - "Details: ${_arrangementDetailsController.text.trim()}", - "Departure: ${_departureCityController.text.trim()}, " - "${_selectedDepartureCountryIso ?? ''}", - ]; - - if (_needsRecommendations) { - parts.add("Destinations: Recommendations requested"); - } else { - parts.add("Destinations: ${_destinationsController.text.trim()}"); - } - - if (_selectedDateMode == "Exact dates") { - final flex = - _selectedFlexibility != null && _selectedFlexibility != "Exact" - ? " ($_selectedFlexibility)" - : ""; - parts.add( - "Dates: ${_departureDateController.text.trim()} - " - "${_returnDateController.text.trim()}$flex", - ); - } else if (_selectedDateMode == "Flexible dates") { - parts.add( - "Dates: $_selectedMonthSeason $_selectedYear, " - "${_tripLengthController.text.trim()} nights", - ); - } - - final travelers = []; - travelers.add("$_adults adult${_adults > 1 ? 's' : ''}"); - if (_children > 0) { - travelers.add("$_children child${_children > 1 ? 'ren' : ''}"); - } - if (_infants > 0) { - travelers.add("$_infants infant${_infants > 1 ? 's' : ''}"); - } - if (_pets > 0) { - travelers.add("$_pets pet${_pets > 1 ? 's' : ''}"); - } - parts.add("Travelers: ${travelers.join(', ')}"); - - parts.add("Budget: ${_travelBudgetController.text.trim()} EUR"); - - widget.model.requestDescription = parts.join("\n"); - } else { - widget.model.requestDescription = _descriptionController.text.trim(); - } - // Travel doesn't collect delivery country: use departure country or "DE" - // as a default since the API requires the field. - if (widget.model.category == ShopInBitCategory.travel) { - widget.model.deliveryCountry = "DE"; - } else { - widget.model.deliveryCountry = _selectedCountryIso!; - } - - if (widget.model.category == ShopInBitCategory.car) { - // Block if another car research flow is already in progress. - final existingPending = MainDB.instance - .getShopInBitTickets() - .where((t) => t.isPendingPayment) - .toList(); - - if (existingPending.isNotEmpty && mounted) { - final resumePrevious = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - title: const Text("In-Progress Car Research"), - content: const Text( - "You have an unfinished car research payment. " - "Would you like to resume it or start a new search?", - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text("Resume Previous"), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text("Start New"), - ), - ], - ), - ); - - if (resumePrevious == true && mounted) { - setState(() => _submitting = false); - unawaited( - Navigator.of(context).pushNamedAndRemoveUntil( - ShopInBitTicketsView.routeName, - (route) => route.isFirst, - ), - ); - return; - } - } - - if (!mounted) return; - - if (Util.isDesktop) { - Navigator.of(context, rootNavigator: true).pop(); - unawaited( - showDialog( - context: context, - builder: (_) => ShopInBitCarFeeView(model: widget.model), - ), - ); - } else { - unawaited( - Navigator.of( - context, - ).pushNamed(ShopInBitCarFeeView.routeName, arguments: widget.model), - ); - } - return; - } - - setState(() => _submitting = true); - try { - final service = ShopInBitService.instance; - final customerKey = await service.ensureCustomerKey(); - - assert( - widget.model.category != null, - 'Step 4 reached with null category: Step 2 must set category before reaching Step 4', - ); - - // API service_type: travel requests use "concierge" because the - // ShopinBit API routes both through the same concierge pipeline. - // Travel-specific details are captured in the structured comment field. - final categoryStr = switch (widget.model.category) { - ShopInBitCategory.concierge => "concierge", - ShopInBitCategory.travel => "concierge", - ShopInBitCategory.car => "car", - null => throw StateError('category must be non-null at Step 4 submit'), - }; - - final resp = await service.client.createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: categoryStr, - comment: widget.model.requestDescription, - deliveryCountry: widget.model.deliveryCountry, - ); - - if (resp.hasError) { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: resp.exception?.message ?? "Failed to create request", - context: context, - ), - ); - } - return; - } - - final ref = resp.value!; - widget.model.apiTicketId = ref.id; - widget.model.ticketId = ref.number; - widget.model.status = ShopInBitOrderStatus.pending; - await MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket()); - - if (!mounted) return; - 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), - ); - } - } catch (e) { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to create request: $e", - context: context, - ), - ); - } - } finally { - if (mounted) setState(() => _submitting = false); - } - } - - // Shared widgets. - Widget _buildCountryPicker(bool isDesktop) { - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: _selectedCountryIso, - items: _countries - .map( - (c) => DropdownMenuItem( - value: c['iso'] as String, - child: Text( - c['label'] as String, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ), - ) - .toList(), - onMenuStateChange: (isOpen) { - if (!isOpen) { - _countrySearchController.clear(); - } - }, - onChanged: _loadingCountries - ? null - : (value) { - setState(() { - _selectedCountryIso = value; - }); - }, - hint: Text( - _loadingCountries ? "Loading countries..." : "Delivery country", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - maxHeight: 300, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - dropdownSearchData: DropdownSearchData( - searchController: _countrySearchController, - searchInnerWidgetHeight: 48, - searchInnerWidget: TextFormField( - controller: _countrySearchController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - hintText: "Search...", - hintStyle: STextStyles.fieldLabel(context), - border: InputBorder.none, - ), - ), - searchMatchFn: (item, searchValue) { - final label = _countries - .where((c) => c['iso'] == item.value) - .map((c) => c['label'] as String) - .firstOrNull; - return label?.toLowerCase().contains(searchValue.toLowerCase()) ?? - false; - }, - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), - ), - ); - } - - Widget _buildDepartureCountryPicker(bool isDesktop) { - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: _selectedDepartureCountryIso, - items: _countries - .map( - (c) => DropdownMenuItem( - value: c['iso'] as String, - child: Text( - c['label'] as String, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ), - ) - .toList(), - onMenuStateChange: (isOpen) { - if (!isOpen) { - _departureCountrySearchController.clear(); - } - }, - onChanged: _loadingCountries - ? null - : (value) { - setState(() { - _selectedDepartureCountryIso = value; - _departureCountryTouched = true; - }); - }, - hint: Text( - _loadingCountries ? "Loading countries..." : "Departure country", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - maxHeight: 300, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - dropdownSearchData: DropdownSearchData( - searchController: _departureCountrySearchController, - searchInnerWidgetHeight: 48, - searchInnerWidget: TextFormField( - controller: _departureCountrySearchController, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - hintText: "Search...", - hintStyle: STextStyles.fieldLabel(context), - border: InputBorder.none, - ), - ), - searchMatchFn: (item, searchValue) { - final label = _countries - .where((c) => c['iso'] == item.value) - .map((c) => c['label'] as String) - .firstOrNull; - return label?.toLowerCase().contains(searchValue.toLowerCase()) ?? - false; - }, - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), - ), - ); - } - - Widget _buildPrivacyCheckbox(bool isDesktop) { - return GestureDetector( - onTap: () { - setState(() { - _privacyAccepted = !_privacyAccepted; - }); - }, - child: Container( - color: Colors.transparent, - child: Row( - crossAxisAlignment: isDesktop - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(top: isDesktop ? 3 : 0), - child: SizedBox( - width: 20, - height: 20, - child: IgnorePointer( - child: Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: _privacyAccepted, - onChanged: (_) {}, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: RichText( - text: TextSpan( - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - children: [ - const TextSpan( - text: "I have read and agree to the ShopinBit ", - ), - TextSpan( - text: "Privacy Policy", - style: STextStyles.richLink( - context, - ).copyWith(fontSize: isDesktop ? 18 : 14), - recognizer: TapGestureRecognizer() - ..onTap = () async { - const url = - "https://api.shopinbit.com/static/policy/privacy.html"; - final shouldOpen = await _showOpenBrowserWarning( - context, - url, - ); - if (shouldOpen) { - await launchUrl( - Uri.parse(url), - mode: LaunchMode.externalApplication, - ); - } - }, - ), - const TextSpan(text: "."), - ], - ), - ), - ), - ], - ), + Widget build(BuildContext context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => _ShopInBitStep4DesktopShell(content: child), + child: ConditionalParent( + condition: !Util.isDesktop, + builder: (child) => _ShopInBitStep4MobileShell(content: child), + child: switch (model.category) { + ShopInBitCategory.concierge => ShopInBitConciergeForm(model: model), + ShopInBitCategory.car => ShopInBitCarResearchForm(model: model), + ShopInBitCategory.travel => ShopInBitTravelForm(model: model), + null => ShopInBitGenericForm(model: model), + }, ), ); } +} - Widget _buildSubmitButton() { - return PrimaryButton( - label: _submitting ? "Submitting..." : "Submit request", - enabled: _canContinue, - onPressed: _canContinue ? _submit : null, - ); - } - - // Per-category form builders. - - Widget _buildConciergeContent(bool isDesktop) { - final whatToPurchaseError = - _whatToPurchaseTouched && - _whatToPurchaseController.text.trim().length < 10 - ? "Minimum 10 characters" - : null; - - final budgetError = _budgetTouched && !_noLimit && !_budgetIsValid - ? "Enter a value between 1,000 and 100,000" - : null; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - StepRow( - count: 4, - current: 3, - width: MediaQuery.of(context).size.width - 32, - ), - if (!isDesktop) const SizedBox(height: 14), - Text( - "What would you like to purchase?", - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - SizedBox(height: isDesktop ? 16 : 8), - Text( - "Tell us what you're looking for and we'll find it for you.", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - SizedBox(height: isDesktop ? 16 : 12), - - // What to purchase free-text field - TextField( - controller: _whatToPurchaseController, - focusNode: _whatToPurchaseFocusNode, - autocorrect: false, - enableSuggestions: false, - minLines: 3, - maxLines: 6, - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "Describe what you'd like to purchase (e.g., electronics, luxury goods, services...)", - _whatToPurchaseFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: whatToPurchaseError, - ), - ), - SizedBox(height: isDesktop ? 24 : 16), - - // Condition picker - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: _selectedCondition, - items: ["NEW", "USED"] - .map( - (c) => DropdownMenuItem( - value: c, - child: Text( - c, - style: isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ), - ) - .toList(), - onChanged: (value) { - setState(() { - _selectedCondition = value; - }); - }, - hint: Text( - "Condition", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), - ), - ), - SizedBox(height: isDesktop ? 24 : 16), - - // Budget field - TextField( - controller: _budgetController, - focusNode: _budgetFocusNode, - autocorrect: false, - enableSuggestions: false, - enabled: !_noLimit, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "Budget (\u20AC)", - _budgetFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - suffixText: "\u20AC", - errorText: budgetError, - ), - ), - SizedBox(height: isDesktop ? 12 : 8), - - // No budget limit checkbox - GestureDetector( - onTap: () { - setState(() { - _noLimit = !_noLimit; - }); - }, - child: Container( - color: Colors.transparent, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 20, - height: 20, - child: IgnorePointer( - child: Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: _noLimit, - onChanged: (_) {}, - ), - ), - ), - const SizedBox(width: 12), - Text( - "No budget limit", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - ], - ), - ), - ), - SizedBox(height: isDesktop ? 12 : 12), - - // Country picker (shared) - _buildCountryPicker(isDesktop), - SizedBox(height: isDesktop ? 12 : 12), - - // Privacy checkbox (shared) - _buildPrivacyCheckbox(isDesktop), - SizedBox(height: isDesktop ? 16 : 12), - - // Submit button (shared) - _buildSubmitButton(), - ], - ); - } - - Widget _buildCarContent(bool isDesktop) { - final brandError = _brandTouched && _brandController.text.trim().length < 3 - ? "Minimum 3 characters" - : null; - - final modelError = _modelTouched && _modelController.text.trim().length < 3 - ? "Minimum 3 characters" - : null; - - final carDescriptionError = - _carDescriptionTouched && - _carDescriptionController.text.trim().length < 3 - ? "Minimum 3 characters" - : null; - - final carBudgetText = _carBudgetController.text.trim(); - final carBudgetVal = int.tryParse(carBudgetText); - final carBudgetError = - _carBudgetTouched && - (carBudgetText.isEmpty || - carBudgetVal == null || - carBudgetVal < 20000) - ? "Minimum budget is 20,000\u20AC" - : null; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - StepRow( - count: 4, - current: 3, - width: MediaQuery.of(context).size.width - 32, - ), - if (!isDesktop) const SizedBox(height: 14), - Text( - "Car Research request", - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - SizedBox(height: isDesktop ? 16 : 8), - Text( - "Tell us about the car you're looking for.", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - SizedBox(height: isDesktop ? 32 : 24), - - // Country picker (shared) - _buildCountryPicker(isDesktop), - SizedBox(height: isDesktop ? 24 : 16), - - // Brand field - TextField( - controller: _brandController, - focusNode: _brandFocusNode, - 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( - "Car brand (e.g., BMW, Mercedes, Toyota...)", - _brandFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: brandError, - ), - ), - SizedBox(height: isDesktop ? 24 : 16), - - // Model field - TextField( - controller: _modelController, - focusNode: _modelFocusNode, - 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( - "Car model (e.g., 3 Series, E-Class, Camry...)", - _modelFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: modelError, - ), - ), - SizedBox(height: isDesktop ? 24 : 16), - - // Condition picker - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: _selectedCarCondition, - items: ["NEW", "PREOWNED"] - .map( - (c) => DropdownMenuItem( - value: c, - child: Text( - c, - style: isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ), - ) - .toList(), - onChanged: (value) { - setState(() { - _selectedCarCondition = value; - }); - }, - hint: Text( - "Condition", - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of(context) - .extension()! - .textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), - ), - ), - SizedBox(height: isDesktop ? 24 : 16), - - // Description field (multiline) - TextField( - controller: _carDescriptionController, - focusNode: _carDescriptionFocusNode, - autocorrect: false, - enableSuggestions: false, - minLines: 3, - maxLines: 6, - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "Describe your requirements (year, mileage, features...)", - _carDescriptionFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: carDescriptionError, - ), - ), - SizedBox(height: isDesktop ? 24 : 16), +class _ShopInBitStep4DesktopShell extends StatelessWidget { + const _ShopInBitStep4DesktopShell({required this.content}); - // Budget field - TextField( - controller: _carBudgetController, - focusNode: _carBudgetFocusNode, - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "Budget (\u20AC, minimum 20,000)", - _carBudgetFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - suffixText: "\u20AC", - errorText: carBudgetError, - ), - ), - SizedBox(height: isDesktop ? 24 : 16), + final Widget content; - // Research fee info box - RoundedWhiteContainer( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + @override + Widget build(BuildContext context) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 750, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Icon( - Icons.info_outline, - size: 20, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconLeft, - ), - const SizedBox(width: 12), - Expanded( - child: RichText( - text: TextSpan( - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - children: [ - TextSpan( - text: "Research fee: ", - style: isDesktop - ? STextStyles.desktopTextSmall( - context, - ).copyWith(fontWeight: FontWeight.bold) - : STextStyles.w500_14( - context, - ).copyWith(fontWeight: FontWeight.bold), - ), - const TextSpan( - text: - "\u20AC223 (incl. VAT): one-time payment, credited toward your purchase.", - ), - ], - ), - ), + Row( + children: [ + const AppBarBackButton(isCompact: true, iconSize: 23), + Text("ShopinBit", style: STextStyles.desktopH3(context)), + ], ), + const DesktopDialogCloseButton(), ], ), - ), - SizedBox(height: isDesktop ? 16 : 12), - - // Fee acknowledgement checkbox - GestureDetector( - onTap: () { - setState(() { - _feeAcknowledged = !_feeAcknowledged; - }); - }, - child: Container( - color: Colors.transparent, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 20, - height: 20, - child: IgnorePointer( - child: Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: _feeAcknowledged, - onChanged: (_) {}, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - "I acknowledge the \u20AC223 research fee", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - ), - ], - ), - ), - ), - SizedBox(height: isDesktop ? 16 : 12), - - // Privacy checkbox (shared) - _buildPrivacyCheckbox(isDesktop), - SizedBox(height: isDesktop ? 16 : 12), - - // Submit button (shared) - _buildSubmitButton(), - ], - ); - } - - Widget _buildGenericContent(bool isDesktop) { - const descriptionTitle = "Describe your travel request"; - const descriptionSubtitle = "Provide details about your trip."; - const descriptionPlaceholder = - "Describe your travel request (destinations, dates, passengers)"; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - StepRow( - count: 4, - current: 3, - width: MediaQuery.of(context).size.width - 32, - ), - if (!isDesktop) const SizedBox(height: 14), - Text( - descriptionTitle, - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - SizedBox(height: isDesktop ? 16 : 8), - Text( - descriptionSubtitle, - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - SizedBox(height: isDesktop ? 32 : 24), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - controller: _descriptionController, - focusNode: _descriptionFocusNode, - autocorrect: false, - enableSuggestions: false, - minLines: 3, - maxLines: 6, - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - descriptionPlaceholder, - _descriptionFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), - ), - SizedBox(height: isDesktop ? 24 : 16), - - // Country picker (shared) - _buildCountryPicker(isDesktop), - SizedBox(height: isDesktop ? 16 : 12), - - // Privacy checkbox (shared) - _buildPrivacyCheckbox(isDesktop), - SizedBox(height: isDesktop ? 16 : 12), - - // Submit button (shared) - _buildSubmitButton(), - ], - ); - } - - // Travel form helpers. - Widget _buildTravelDropdown({ - required String? value, - required List items, - required String hint, - required ValueChanged onChanged, - required bool isDesktop, - }) { - return ClipRRect( - borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - value: value, - items: items - .map( - (c) => DropdownMenuItem( - value: c, - child: Text( - c, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - ) - : STextStyles.w500_14(context), - ), - ), - ) - .toList(), - onChanged: onChanged, - hint: Text( - hint, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldDefaultSearchIconLeft, - ) - : STextStyles.fieldLabel(context), - ), - isExpanded: true, - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - ), - iconStyleData: IconStyleData( - icon: Padding( - padding: const EdgeInsets.only(right: 10), - child: SvgPicture.asset( - Assets.svg.chevronDown, - width: 12, - height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, - ), - ), - ), - dropdownStyleData: DropdownStyleData( - offset: const Offset(0, 0), - elevation: 0, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: SingleChildScrollView(child: content), ), ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - ), + ], ), ); } +} - Widget _buildTravelerCounter({ - required String label, - required int value, - required int min, - required int max, - required ValueChanged onChanged, - required bool isDesktop, - }) { - return Row( - children: [ - Text( - label, - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - const Spacer(), - InkWell( - onTap: value > min ? () => onChanged(value - 1) : null, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - child: Center( - child: Text( - "-", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - ), - ), - ), - const SizedBox(width: 16), - SizedBox( - width: 24, - child: Center( - child: Text( - "$value", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - ), - ), - const SizedBox(width: 16), - InkWell( - onTap: value < max ? () => onChanged(value + 1) : null, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Theme.of( - context, - ).extension()!.textFieldDefaultBG, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - ), - child: Center( - child: Text( - "+", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - ), - ), - ), - ], - ); - } - - Widget _buildTravelContent(bool isDesktop) { - final departureCountryError = - _departureCountryTouched && - _departureCountryController.text.trim().isEmpty - ? "Required" - : null; - - final departureCityError = - _departureCityTouched && _departureCityController.text.trim().isEmpty - ? "Required" - : null; - - final destinationsError = - _destinationsTouched && - _destinationsController.text.trim().isEmpty && - !_needsRecommendations - ? "Required (or check 'I need recommendations')" - : null; - - final departureDateError = - _departureDateTouched && _departureDateController.text.trim().isEmpty - ? "Required" - : null; - - final returnDateError = - _returnDateTouched && _returnDateController.text.trim().isEmpty - ? "Required" - : null; - - final tripLengthError = - _tripLengthTouched && _tripLengthController.text.trim().isEmpty - ? "Required" - : null; - - final travelBudgetText = _travelBudgetController.text.trim(); - final travelBudgetVal = int.tryParse(travelBudgetText); - final travelBudgetError = - _travelBudgetTouched && - (travelBudgetText.isEmpty || - travelBudgetVal == null || - travelBudgetVal < 1000) - ? "Minimum budget is 1,000 EUR" - : null; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isDesktop) - StepRow( - count: 4, - current: 3, - width: MediaQuery.of(context).size.width - 32, - ), - if (!isDesktop) const SizedBox(height: 14), - Text( - "Travel request", - style: isDesktop - ? STextStyles.desktopH2(context) - : STextStyles.pageTitleH1(context), - ), - SizedBox(height: isDesktop ? 16 : 8), - Text( - "Tell us about your trip and we'll arrange everything.", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.itemSubtitle(context), - ), - SizedBox(height: isDesktop ? 32 : 24), - - // === Trip Type === - Text( - "Trip type", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - SizedBox(height: isDesktop ? 12 : 8), - _buildTravelDropdown( - value: _selectedArrangement, - items: const [ - "Flights Only", - "Hotels Only", - "Flights + Hotels", - "Full Service", - ], - hint: "Arrangement type", - onChanged: (val) => setState(() => _selectedArrangement = val), - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 16 : 12), - TextField( - controller: _arrangementDetailsController, - focusNode: _arrangementDetailsFocusNode, - minLines: 3, - maxLines: 6, - 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( - "Describe your specific requirements (luggage, cabin class, hotel stars, etc.)", - _arrangementDetailsFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: - _arrangementDetailsTouched && - _arrangementDetailsController.text.trim().length < 10 - ? "Minimum 10 characters" - : null, - ), - ), - - // === Where === - SizedBox(height: isDesktop ? 24 : 16), - Text( - "Where", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - SizedBox(height: isDesktop ? 12 : 8), - _buildDepartureCountryPicker(isDesktop), - SizedBox(height: isDesktop ? 16 : 12), - TextField( - controller: _departureCityController, - focusNode: _departureCityFocusNode, - 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( - "Departure city", - _departureCityFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: departureCityError, - ), - ), - SizedBox(height: isDesktop ? 16 : 12), - TextField( - controller: _destinationsController, - focusNode: _destinationsFocusNode, - autocorrect: false, - enableSuggestions: false, - enabled: !_needsRecommendations, - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "e.g. Paris, France; Rome, Italy", - _destinationsFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: destinationsError, - ), - ), - SizedBox(height: isDesktop ? 12 : 8), - GestureDetector( - onTap: () { - setState(() { - _needsRecommendations = !_needsRecommendations; - }); - }, - child: Container( - color: Colors.transparent, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 20, - height: 20, - child: IgnorePointer( - child: Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: _needsRecommendations, - onChanged: (_) {}, - ), - ), - ), - const SizedBox(width: 12), - Text( - "I need recommendations", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - ], - ), - ), - ), - - // === When === - SizedBox(height: isDesktop ? 24 : 16), - Text( - "When", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - SizedBox(height: isDesktop ? 12 : 8), - _buildTravelDropdown( - value: _selectedDateMode, - items: const ["Exact dates", "Flexible dates"], - hint: "Date mode", - onChanged: (val) => setState(() => _selectedDateMode = val), - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 16 : 12), - - if (_selectedDateMode == "Exact dates") ...[ - TextField( - controller: _departureDateController, - focusNode: _departureDateFocusNode, - readOnly: true, - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 3650)), - ); - if (picked != null) { - final formatted = - "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}"; - setState(() { - _departureDateController.text = formatted; - _departureDateTouched = true; - }); - } - }, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "DD/MM/YYYY", - _departureDateFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - labelText: "Departure date", - suffixIcon: const Icon(Icons.calendar_today, size: 18), - errorText: departureDateError, - ), - ), - SizedBox(height: isDesktop ? 16 : 12), - TextField( - controller: _returnDateController, - focusNode: _returnDateFocusNode, - readOnly: true, - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 3650)), - ); - if (picked != null) { - final formatted = - "${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year}"; - setState(() { - _returnDateController.text = formatted; - _returnDateTouched = true; - }); - } - }, - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "DD/MM/YYYY", - _returnDateFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - labelText: "Return date", - suffixIcon: const Icon(Icons.calendar_today, size: 18), - errorText: returnDateError, - ), - ), - SizedBox(height: isDesktop ? 16 : 12), - _buildTravelDropdown( - value: _selectedFlexibility, - items: const [ - "Exact", - "\u00B1 1 day", - "\u00B1 2-3 days", - "+ 1 week", - ], - hint: "Flexibility", - onChanged: (val) => setState(() => _selectedFlexibility = val), - isDesktop: isDesktop, - ), - ], - - if (_selectedDateMode == "Flexible dates") ...[ - _buildTravelDropdown( - value: _selectedYear, - items: ["${DateTime.now().year}", "${DateTime.now().year + 1}"], - hint: "Year", - onChanged: (val) => setState(() => _selectedYear = val), - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 16 : 12), - _buildTravelDropdown( - value: _selectedMonthSeason, - items: const [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ], - hint: "Month or season", - onChanged: (val) => setState(() => _selectedMonthSeason = val), - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 16 : 12), - TextField( - controller: _tripLengthController, - focusNode: _tripLengthFocusNode, - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "Number of nights", - _tripLengthFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - errorText: tripLengthError, - ), - ), - ], - - // === Who === - SizedBox(height: isDesktop ? 24 : 16), - Text( - "Who", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - SizedBox(height: isDesktop ? 12 : 8), - _buildTravelerCounter( - label: "Adults", - value: _adults, - min: 1, - max: 20, - onChanged: (v) => setState(() => _adults = v), - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 12 : 8), - _buildTravelerCounter( - label: "Children", - value: _children, - min: 0, - max: 20, - onChanged: (v) => setState(() => _children = v), - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 12 : 8), - _buildTravelerCounter( - label: "Infants", - value: _infants, - min: 0, - max: 20, - onChanged: (v) => setState(() => _infants = v), - isDesktop: isDesktop, - ), - SizedBox(height: isDesktop ? 12 : 8), - _buildTravelerCounter( - label: "Pets", - value: _pets, - min: 0, - max: 20, - onChanged: (v) => setState(() => _pets = v), - isDesktop: isDesktop, - ), - - // === Budget === - SizedBox(height: isDesktop ? 24 : 16), - Text( - "Budget", - style: isDesktop - ? STextStyles.desktopTextSmall(context) - : STextStyles.w500_14(context), - ), - SizedBox(height: isDesktop ? 12 : 8), - TextField( - controller: _travelBudgetController, - focusNode: _travelBudgetFocusNode, - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - onChanged: (_) => setState(() {}), - style: isDesktop - ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, - height: 1.8, - ) - : STextStyles.field(context), - decoration: - standardInputDecoration( - "Minimum 1000 EUR", - _travelBudgetFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - suffixText: "EUR", - errorText: travelBudgetError, - ), - ), +class _ShopInBitStep4MobileShell extends StatelessWidget { + const _ShopInBitStep4MobileShell({required this.content}); - // Travel doesn't need delivery country: destinations are in the form. - SizedBox(height: isDesktop ? 16 : 12), - _buildPrivacyCheckbox(isDesktop), - SizedBox(height: isDesktop ? 16 : 12), - _buildSubmitButton(), - ], - ); - } + final Widget content; @override Widget build(BuildContext context) { - final isDesktop = Util.isDesktop; - - final Widget content; - switch (widget.model.category) { - case ShopInBitCategory.concierge: - content = _buildConciergeContent(isDesktop); - break; - case ShopInBitCategory.car: - content = _buildCarContent(isDesktop); - break; - case ShopInBitCategory.travel: - content = _buildTravelContent(isDesktop); - break; - case null: - content = _buildGenericContent(isDesktop); - break; - } - - if (isDesktop) { - return DesktopDialog( - maxWidth: 580, - maxHeight: 750, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - AppBarBackButton( - isCompact: true, - iconSize: 23, - onPressed: _popBack, - ), - Text("ShopinBit", style: STextStyles.desktopH3(context)), - ], - ), - const DesktopDialogCloseButton(), - ], - ), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - child: SingleChildScrollView(child: content), - ), - ), - ], - ), - ); - } - return Background( - child: PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, dynamic result) { - if (!didPop) { - _popBack(); - } - }, - child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, - appBar: AppBar( - leading: AppBarBackButton(onPressed: _popBack), - title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), - ), - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - return Padding( - padding: const EdgeInsets.all(16), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 32, - ), - child: IntrinsicHeight(child: content), + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: const AppBarBackButton(), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, ), + child: IntrinsicHeight(child: content), ), - ); - }, - ), + ), + ); + }, ), ), ), diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 85ceb97cf0..671ee89d09 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -15,6 +15,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/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_offer_view.dart'; @@ -32,51 +33,6 @@ class ShopInBitTicketDetail extends StatefulWidget { class _ShopInBitTicketDetailState extends State { late final TextEditingController _messageController; - String _statusLabel(ShopInBitOrderStatus status) { - switch (status) { - case ShopInBitOrderStatus.pending: - return "Pending"; - case ShopInBitOrderStatus.reviewing: - return "Under review"; - case ShopInBitOrderStatus.offerAvailable: - return "Offer available"; - case ShopInBitOrderStatus.accepted: - return "Accepted"; - case ShopInBitOrderStatus.paymentPending: - return "Awaiting payment"; - case ShopInBitOrderStatus.paid: - return "Paid"; - case ShopInBitOrderStatus.shipping: - return "Shipping"; - case ShopInBitOrderStatus.delivered: - return "Delivered"; - case ShopInBitOrderStatus.closed: - return "Closed"; - case ShopInBitOrderStatus.cancelled: - return "Cancelled"; - case ShopInBitOrderStatus.refunded: - return "Refunded"; - } - } - - Color _statusColor(BuildContext context, ShopInBitOrderStatus status) { - switch (status) { - case ShopInBitOrderStatus.delivered: - return Theme.of(context).extension()!.accentColorGreen; - case ShopInBitOrderStatus.offerAvailable: - return Theme.of(context).extension()!.accentColorBlue; - case ShopInBitOrderStatus.pending: - case ShopInBitOrderStatus.reviewing: - return Theme.of(context).extension()!.accentColorYellow; - case ShopInBitOrderStatus.closed: - case ShopInBitOrderStatus.cancelled: - case ShopInBitOrderStatus.refunded: - return Theme.of(context).extension()!.textSubtitle1; - default: - return Theme.of(context).extension()!.accentColorDark; - } - } - bool _sending = false; bool _loading = false; bool _retrying = false; @@ -112,44 +68,39 @@ class _ShopInBitTicketDetailState extends State { final client = ShopInBitService.instance.client; final id = widget.model.apiTicketId; - // Car research tickets created via /car-research/log-payment are not - // accessible via /tickets/:id/* endpoints (API returns 403). Skip - // those calls for car tickets to avoid log spam. Local data is used. - if (!_isCarResearch) { - 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, + 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, - ); - } + 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, + ); } } @@ -424,15 +375,21 @@ class _ShopInBitTicketDetailState extends State { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: _statusColor(context, model.status).withOpacity(0.2), + color: model.status + .getColor(Theme.of(context).extension()!) + .withOpacity(0.2), ), child: Text( - _statusLabel(model.status), + model.status.label, style: (isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) : STextStyles.itemSubtitle12(context)) - .copyWith(color: _statusColor(context, model.status)), + .copyWith( + color: model.status.getColor( + Theme.of(context).extension()!, + ), + ), ), ), ], @@ -497,14 +454,7 @@ class _ShopInBitTicketDetailState extends State { return _chatBubble(message, isDesktop); }, ), - if (_loading) - const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), + if (_loading) const LoadingIndicator(width: 24, height: 24), ], ), ); diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index ce62d3be35..0ff73b6813 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -15,6 +15,7 @@ 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/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_car_fee_view.dart'; import 'shopinbit_car_research_payment_view.dart'; @@ -122,7 +123,7 @@ class _ShopInBitTicketsViewState extends State { if (localIdx < 0) continue; // Car research tickets return 403 on /tickets/:id/* endpoints. - if (_tickets[localIdx].category == ShopInBitCategory.car) continue; + // if (_tickets[localIdx].category == ShopInBitCategory.car) continue; final statusResp = await service.client.getTicketStatus(ref.id); if (statusResp.hasError || statusResp.value == null) continue; @@ -171,51 +172,6 @@ class _ShopInBitTicketsViewState extends State { } } - String _statusLabel(ShopInBitOrderStatus status) { - switch (status) { - case ShopInBitOrderStatus.pending: - return "Pending"; - case ShopInBitOrderStatus.reviewing: - return "Under review"; - case ShopInBitOrderStatus.offerAvailable: - return "Offer available"; - case ShopInBitOrderStatus.accepted: - return "Accepted"; - case ShopInBitOrderStatus.paymentPending: - return "Awaiting payment"; - case ShopInBitOrderStatus.paid: - return "Paid"; - case ShopInBitOrderStatus.shipping: - return "Shipping"; - case ShopInBitOrderStatus.delivered: - return "Delivered"; - case ShopInBitOrderStatus.closed: - return "Closed"; - case ShopInBitOrderStatus.cancelled: - return "Cancelled"; - case ShopInBitOrderStatus.refunded: - return "Refunded"; - } - } - - Color _statusColor(BuildContext context, ShopInBitOrderStatus status) { - switch (status) { - case ShopInBitOrderStatus.delivered: - return Theme.of(context).extension()!.accentColorGreen; - case ShopInBitOrderStatus.offerAvailable: - return Theme.of(context).extension()!.accentColorBlue; - case ShopInBitOrderStatus.pending: - case ShopInBitOrderStatus.reviewing: - return Theme.of(context).extension()!.accentColorYellow; - case ShopInBitOrderStatus.closed: - case ShopInBitOrderStatus.cancelled: - case ShopInBitOrderStatus.refunded: - return Theme.of(context).extension()!.textSubtitle1; - default: - return Theme.of(context).extension()!.accentColorDark; - } - } - String _categoryLabel(ShopInBitCategory? category) { switch (category) { case ShopInBitCategory.concierge: @@ -358,13 +314,16 @@ class _ShopInBitTicketsViewState extends State { ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: _statusColor( - context, - ticket.status, - ).withOpacity(0.2), + color: ticket.status + .getColor( + Theme.of( + context, + ).extension()!, + ) + .withOpacity(0.2), ), child: Text( - _statusLabel(ticket.status), + ticket.status.label, style: (isDesktop ? STextStyles.desktopTextExtraExtraSmall( @@ -374,9 +333,10 @@ class _ShopInBitTicketsViewState extends State { context, )) .copyWith( - color: _statusColor( - context, - ticket.status, + color: ticket.status.getColor( + Theme.of( + context, + ).extension()!, ), ), ), @@ -446,14 +406,7 @@ class _ShopInBitTicketsViewState extends State { final content = Stack( children: [ list, - if (_syncing) - const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), + if (_syncing) const LoadingIndicator(width: 24, height: 24), ], ); 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 new file mode 100644 index 0000000000..bf4da508a9 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_car_research_form.dart @@ -0,0 +1,347 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +import "../../../db/isar/main_db.dart"; +import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../themes/stack_colors.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; +import "../../../widgets/rounded_white_container.dart"; +import "../shopinbit_car_fee_view.dart"; +import "../shopinbit_tickets_view.dart"; +import "shopinbit_country_picker.dart"; +import "shopinbit_labeled_checkbox.dart"; +import "shopinbit_privacy_checkbox.dart"; +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"]; + +const int _minCarBudget = 20000; +const int _minCarFieldLength = 3; + +class ShopInBitCarResearchForm extends StatefulWidget { + const ShopInBitCarResearchForm({super.key, required this.model}); + + final ShopInBitOrderModel model; + + @override + State createState() => + _ShopInBitCarResearchFormState(); +} + +class _ShopInBitCarResearchFormState extends State { + final TextEditingController _brandController = TextEditingController(); + final FocusNode _brandFocusNode = FocusNode(); + bool _brandTouched = false; + + final TextEditingController _modelController = TextEditingController(); + final FocusNode _modelFocusNode = FocusNode(); + bool _modelTouched = false; + + final TextEditingController _carDescriptionController = + TextEditingController(); + final FocusNode _carDescriptionFocusNode = FocusNode(); + bool _carDescriptionTouched = false; + + final TextEditingController _carBudgetController = TextEditingController(); + final FocusNode _carBudgetFocusNode = FocusNode(); + bool _carBudgetTouched = false; + + String? _selectedCarCondition; + bool _feeAcknowledged = false; + String? _selectedCountryIso; + bool _privacyAccepted = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _wireTouchOnBlur(_brandFocusNode, () => _brandTouched = true); + _wireTouchOnBlur(_modelFocusNode, () => _modelTouched = true); + _wireTouchOnBlur( + _carDescriptionFocusNode, + () => _carDescriptionTouched = true, + ); + _wireTouchOnBlur(_carBudgetFocusNode, () => _carBudgetTouched = true); + if (widget.model.deliveryCountry.isNotEmpty) { + _selectedCountryIso = widget.model.deliveryCountry; + } + } + + void _wireTouchOnBlur(FocusNode node, VoidCallback markTouched) { + node.addListener(() { + if (!node.hasFocus) markTouched(); + setState(() {}); + }); + } + + @override + void dispose() { + _brandController.dispose(); + _brandFocusNode.dispose(); + _modelController.dispose(); + _modelFocusNode.dispose(); + _carDescriptionController.dispose(); + _carDescriptionFocusNode.dispose(); + _carBudgetController.dispose(); + _carBudgetFocusNode.dispose(); + super.dispose(); + } + + bool get _canContinue { + final int? carBudgetValue = int.tryParse(_carBudgetController.text.trim()); + return !_submitting && + _privacyAccepted && + _feeAcknowledged && + _brandController.text.trim().length >= _minCarFieldLength && + _modelController.text.trim().length >= _minCarFieldLength && + _carDescriptionController.text.trim().length >= _minCarFieldLength && + _selectedCarCondition != null && + carBudgetValue != null && + carBudgetValue >= _minCarBudget && + _selectedCountryIso != null; + } + + Future _submit() async { + setState(() => _submitting = true); + try { + final String countryIso = _selectedCountryIso!; + + widget.model + ..requestDescription = + "Brand: ${_brandController.text.trim()}\n" + "Model: ${_modelController.text.trim()}\n" + "Condition: $_selectedCarCondition\n" + "Description: ${_carDescriptionController.text.trim()}\n" + "Budget: ${_carBudgetController.text.trim()} EUR\n" + "Delivery country: $countryIso" + ..deliveryCountry = countryIso; + + // Block if another car research flow is already in progress. + final existingPending = MainDB.instance + .getShopInBitTickets() + .where((t) => t.isPendingPayment) + .toList(); + + if (existingPending.isNotEmpty && mounted) { + 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?", + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text("Resume Previous"), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text("Start New"), + ), + ], + ), + ); + + if (resumePrevious == true && mounted) { + unawaited( + Navigator.of(context).pushNamedAndRemoveUntil( + ShopInBitTicketsView.routeName, + (route) => route.isFirst, + ), + ); + return; + } + } + + 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), + ); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + final String? brandError = + _brandTouched && + _brandController.text.trim().length < _minCarFieldLength + ? "Minimum $_minCarFieldLength characters" + : null; + + final String? modelError = + _modelTouched && + _modelController.text.trim().length < _minCarFieldLength + ? "Minimum $_minCarFieldLength characters" + : null; + + final String? carDescriptionError = + _carDescriptionTouched && + _carDescriptionController.text.trim().length < _minCarFieldLength + ? "Minimum $_minCarFieldLength characters" + : null; + + final String carBudgetText = _carBudgetController.text.trim(); + final int? carBudgetValue = int.tryParse(carBudgetText); + final String? carBudgetError = + _carBudgetTouched && + (carBudgetText.isEmpty || + carBudgetValue == null || + carBudgetValue < _minCarBudget) + ? "Minimum budget is 20,000\u20AC" + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ShopInBitStep4Header( + title: "Car Research request", + subtitle: "Tell us about the car you're looking for.", + ), + SizedBox(height: isDesktop ? 32 : 24), + ShopInBitCountryPicker( + selectedIso: _selectedCountryIso, + onChanged: (iso) => setState(() => _selectedCountryIso = iso), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4TextField( + controller: _brandController, + focusNode: _brandFocusNode, + hintText: "Car brand (e.g., BMW, Mercedes, Toyota...)", + errorText: brandError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4TextField( + controller: _modelController, + focusNode: _modelFocusNode, + hintText: "Car model (e.g., 3 Series, E-Class, Camry...)", + errorText: modelError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4Dropdown( + value: _selectedCarCondition, + items: _carConditions, + hintText: "Condition", + onChanged: (value) => setState(() => _selectedCarCondition = value), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4TextField( + controller: _carDescriptionController, + focusNode: _carDescriptionFocusNode, + hintText: + "Describe your requirements " + "(year, mileage, features...)", + minLines: 3, + maxLines: 6, + errorText: carDescriptionError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4TextField( + controller: _carBudgetController, + focusNode: _carBudgetFocusNode, + hintText: "Budget (\u20AC, minimum 20,000)", + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + suffixText: "\u20AC", + errorText: carBudgetError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + _CarResearchFeeInfo(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitLabeledCheckbox( + value: _feeAcknowledged, + onChanged: (v) => setState(() => _feeAcknowledged = v), + label: "I acknowledge the \u20AC223 research fee", + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitPrivacyCheckbox( + value: _privacyAccepted, + onChanged: (v) => setState(() => _privacyAccepted = v), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4SubmitButton( + submitting: _submitting, + enabled: _canContinue, + onPressed: _submit, + ), + ], + ); + } +} + +/// Info box showing the €223 (incl. VAT) research fee disclosure. +class _CarResearchFeeInfo extends StatelessWidget { + const _CarResearchFeeInfo({required this.isDesktop}); + + final bool isDesktop; + + @override + Widget build(BuildContext context) { + final TextStyle baseStyle = isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context); + + return RoundedWhiteContainer( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Theme.of( + context, + ).extension()!.textFieldActiveSearchIconLeft, + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: baseStyle, + children: [ + TextSpan( + text: "Research fee: ", + style: baseStyle.copyWith(fontWeight: FontWeight.bold), + ), + const TextSpan( + text: + "\u20AC223 (incl. VAT): one-time payment, " + "credited toward your purchase.", + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart new file mode 100644 index 0000000000..282d826132 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart @@ -0,0 +1,191 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../utilities/util.dart"; +import "shopinbit_country_picker.dart"; +import "shopinbit_labeled_checkbox.dart"; +import "shopinbit_privacy_checkbox.dart"; +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"]; + +const int _minConciergeBudget = 1000; +const int _maxConciergeBudget = 100000; + +class ShopInBitConciergeForm extends StatefulWidget { + const ShopInBitConciergeForm({super.key, required this.model}); + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitConciergeFormState(); +} + +class _ShopInBitConciergeFormState extends State { + final TextEditingController _whatToPurchaseController = + TextEditingController(); + final FocusNode _whatToPurchaseFocusNode = FocusNode(); + bool _whatToPurchaseTouched = false; + + final TextEditingController _budgetController = TextEditingController( + text: "1000", + ); + final FocusNode _budgetFocusNode = FocusNode(); + bool _budgetTouched = false; + + String? _selectedCondition; + bool _noLimit = false; + String? _selectedCountryIso; + bool _privacyAccepted = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _whatToPurchaseFocusNode.addListener(() { + if (!_whatToPurchaseFocusNode.hasFocus) _whatToPurchaseTouched = true; + setState(() {}); + }); + _budgetFocusNode.addListener(() { + if (!_budgetFocusNode.hasFocus) _budgetTouched = true; + setState(() {}); + }); + if (widget.model.deliveryCountry.isNotEmpty) { + _selectedCountryIso = widget.model.deliveryCountry; + } + } + + @override + void dispose() { + _whatToPurchaseController.dispose(); + _whatToPurchaseFocusNode.dispose(); + _budgetController.dispose(); + _budgetFocusNode.dispose(); + super.dispose(); + } + + bool get _budgetIsValid { + final String text = _budgetController.text.trim(); + if (text.isEmpty) return false; + final int? value = int.tryParse(text); + return value != null && + value >= _minConciergeBudget && + value <= _maxConciergeBudget; + } + + bool get _canContinue => + !_submitting && + _privacyAccepted && + _whatToPurchaseController.text.trim().length >= 10 && + _selectedCondition != null && + (_noLimit || _budgetIsValid) && + _selectedCountryIso != null; + + Future _submit() async { + setState(() => _submitting = true); + + final String countryIso = _selectedCountryIso!; + final String budgetText = _noLimit + ? "No limit" + : "${_budgetController.text.trim()} EUR"; + + widget.model + ..requestDescription = + "What to purchase: ${_whatToPurchaseController.text.trim()}\n" + "Condition: $_selectedCondition\n" + "Budget: $budgetText\n" + "Delivery country: $countryIso" + ..deliveryCountry = countryIso; + + try { + await submitShopInBitRequest(context, widget.model); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + final String? whatToPurchaseError = + _whatToPurchaseTouched && + _whatToPurchaseController.text.trim().length < 10 + ? "Minimum 10 characters" + : null; + + final String? budgetError = _budgetTouched && !_noLimit && !_budgetIsValid + ? "Enter a value between 1,000 and 100,000" + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ShopInBitStep4Header( + title: "What would you like to purchase?", + subtitle: + "Tell us what you're looking for and we'll find it " + "for you.", + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4TextField( + controller: _whatToPurchaseController, + focusNode: _whatToPurchaseFocusNode, + hintText: + "Describe what you'd like to purchase " + "(e.g., electronics, luxury goods, services...)", + minLines: 3, + maxLines: 6, + errorText: whatToPurchaseError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4Dropdown( + value: _selectedCondition, + items: _conciergeConditions, + hintText: "Condition", + onChanged: (value) => setState(() => _selectedCondition = value), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitStep4TextField( + controller: _budgetController, + focusNode: _budgetFocusNode, + hintText: "Budget (\u20AC)", + enabled: !_noLimit, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + suffixText: "\u20AC", + errorText: budgetError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitLabeledCheckbox( + value: _noLimit, + onChanged: (v) => setState(() => _noLimit = v), + label: "No budget limit", + ), + SizedBox(height: isDesktop ? 12 : 12), + ShopInBitCountryPicker( + selectedIso: _selectedCountryIso, + onChanged: (iso) => setState(() => _selectedCountryIso = iso), + ), + SizedBox(height: isDesktop ? 12 : 12), + ShopInBitPrivacyCheckbox( + value: _privacyAccepted, + onChanged: (v) => setState(() => _privacyAccepted = v), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4SubmitButton( + submitting: _submitting, + enabled: _canContinue, + onPressed: _submit, + ), + ], + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart new file mode 100644 index 0000000000..f5ae405b20 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart @@ -0,0 +1,164 @@ +import "package:dropdown_button2/dropdown_button2.dart"; +import "package:flutter/material.dart"; +import "package:flutter_svg/svg.dart"; + +import "../../../services/shopinbit/shopinbit_service.dart"; +import "../../../themes/stack_colors.dart"; +import "../../../utilities/assets.dart"; +import "../../../utilities/constants.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; + +class ShopInBitCountryPicker extends StatefulWidget { + const ShopInBitCountryPicker({ + super.key, + required this.selectedIso, + required this.onChanged, + this.hintText = "Delivery country", + }); + + final String? selectedIso; + final ValueChanged onChanged; + final String hintText; + + @override + State createState() => _ShopInBitCountryPickerState(); +} + +class _ShopInBitCountryPickerState extends State { + final TextEditingController _searchController = TextEditingController(); + List> _countries = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + _fetchCountries(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _fetchCountries() async { + setState(() => _loading = true); + try { + final resp = await ShopInBitService.instance.client.getCountries(); + if (resp.hasError || resp.value == null) return; + _countries = resp.value!; + if (widget.selectedIso != null && + !_countries.any((c) => c["iso"] == widget.selectedIso)) { + widget.onChanged(null); + } + } catch (_) { + // Leave list empty; user will see no items. + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final StackColors stackColors = Theme.of(context).extension()!; + + final TextStyle itemStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: stackColors.textFieldActiveText) + : STextStyles.w500_14(context); + + final TextStyle hintStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).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, + ), + ), + ), + 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, + ), + ), + ), + 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), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart b/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart new file mode 100644 index 0000000000..f31741f195 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart @@ -0,0 +1,112 @@ +import "package:flutter/material.dart"; + +import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../utilities/util.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. +/// +/// Note: the original code used the travel copy for this fallback; that +/// behaviour is preserved here. +class ShopInBitGenericForm extends StatefulWidget { + const ShopInBitGenericForm({super.key, required this.model}); + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitGenericFormState(); +} + +class _ShopInBitGenericFormState extends State { + late final TextEditingController _descriptionController; + final FocusNode _descriptionFocusNode = FocusNode(); + + String? _selectedCountryIso; + bool _privacyAccepted = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _descriptionController = TextEditingController( + text: widget.model.requestDescription, + ); + _descriptionFocusNode.addListener(() => setState(() {})); + + if (widget.model.deliveryCountry.isNotEmpty) { + _selectedCountryIso = widget.model.deliveryCountry; + } + } + + @override + void dispose() { + _descriptionController.dispose(); + _descriptionFocusNode.dispose(); + super.dispose(); + } + + bool get _canContinue => + !_submitting && + _privacyAccepted && + _descriptionController.text.trim().isNotEmpty && + _selectedCountryIso != null; + + Future _submit() async { + setState(() => _submitting = true); + widget.model + ..requestDescription = _descriptionController.text.trim() + ..deliveryCountry = _selectedCountryIso!; + try { + await submitShopInBitRequest(context, widget.model); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ShopInBitStep4Header( + title: "Describe your travel request", + subtitle: "Provide details about your trip.", + ), + SizedBox(height: isDesktop ? 32 : 24), + ShopInBitStep4TextField( + controller: _descriptionController, + focusNode: _descriptionFocusNode, + hintText: + "Describe your travel request (destinations, dates, passengers)", + minLines: 3, + maxLines: 6, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 24 : 16), + ShopInBitCountryPicker( + selectedIso: _selectedCountryIso, + onChanged: (iso) => setState(() => _selectedCountryIso = iso), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitPrivacyCheckbox( + value: _privacyAccepted, + onChanged: (v) => setState(() => _privacyAccepted = v), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4SubmitButton( + submitting: _submitting, + enabled: _canContinue, + onPressed: _submit, + ), + ], + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart b/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart new file mode 100644 index 0000000000..6f4014f88b --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_labeled_checkbox.dart @@ -0,0 +1,49 @@ +import "package:flutter/material.dart"; + +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; + +class ShopInBitLabeledCheckbox extends StatelessWidget { + const ShopInBitLabeledCheckbox({ + super.key, + required this.value, + required this.onChanged, + required this.label, + }); + + final bool value; + final ValueChanged onChanged; + final String label; + + @override + Widget build(BuildContext context) { + final TextStyle labelStyle = Util.isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context); + + return GestureDetector( + onTap: () => onChanged(!value), + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: IgnorePointer( + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: value, + onChanged: (_) {}, + ), + ), + ), + const SizedBox(width: 12), + Expanded(child: Text(label, style: labelStyle)), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart b/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart new file mode 100644 index 0000000000..72d95050d5 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_privacy_checkbox.dart @@ -0,0 +1,163 @@ +import "package:flutter/gestures.dart"; +import "package:flutter/material.dart"; +import "package:url_launcher/url_launcher.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/stack_dialog.dart"; + +const String _shopInBitPrivacyUrl = + "https://api.shopinbit.com/static/policy/privacy.html"; + +class ShopInBitPrivacyCheckbox extends StatelessWidget { + const ShopInBitPrivacyCheckbox({ + super.key, + required this.value, + required this.onChanged, + }); + + final bool value; + final ValueChanged onChanged; + + Future _openPrivacyPolicy(BuildContext context) async { + final bool shouldOpen = await _showOpenBrowserWarning( + context, + _shopInBitPrivacyUrl, + ); + if (shouldOpen) { + await launchUrl( + Uri.parse(_shopInBitPrivacyUrl), + mode: LaunchMode.externalApplication, + ); + } + } + + Future _showOpenBrowserWarning(BuildContext context, String url) async { + final Uri uri = Uri.parse(url); + final String message = + "You are about to open ${uri.scheme}://${uri.host} in your browser."; + + final bool? shouldContinue = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Util.isDesktop + ? _DesktopBrowserWarning(message: message) + : StackDialog( + title: "Attention", + message: message, + leftButton: SecondaryButton( + label: "Cancel", + onPressed: () => Navigator.of(context).pop(false), + ), + rightButton: PrimaryButton( + label: "Continue", + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ); + return shouldContinue ?? false; + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + return GestureDetector( + onTap: () => onChanged(!value), + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: isDesktop + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(top: isDesktop ? 3 : 0), + child: SizedBox( + width: 20, + height: 20, + child: IgnorePointer( + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: value, + onChanged: (_) {}, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context), + children: [ + const TextSpan( + text: "I have read and agree to the ShopinBit ", + ), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: isDesktop ? 18 : 14), + recognizer: TapGestureRecognizer() + ..onTap = () => _openPrivacyPolicy(context), + ), + const TextSpan(text: "."), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +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 new file mode 100644 index 0000000000..baae092879 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_dropdown.dart @@ -0,0 +1,93 @@ +import "package:dropdown_button2/dropdown_button2.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/text_styles.dart"; +import "../../../utilities/util.dart"; + +class ShopInBitStep4Dropdown extends StatelessWidget { + const ShopInBitStep4Dropdown({ + super.key, + required this.value, + required this.items, + required this.hintText, + required this.onChanged, + }); + + final String? value; + final List items; + final String hintText; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final stackColors = Theme.of(context).extension()!; + + final itemStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith(color: stackColors.textFieldActiveText) + : STextStyles.w500_14(context); + + final hintStyle = Util.isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).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, + ), + ), + ), + 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, + ), + ), + ), + 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 new file mode 100644 index 0000000000..4c81d7df15 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_header.dart @@ -0,0 +1,46 @@ +import "package:flutter/material.dart"; + +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; +import "../../exchange_view/sub_widgets/step_row.dart"; + +class ShopInBitStep4Header extends StatelessWidget { + const ShopInBitStep4Header({ + super.key, + required this.title, + required this.subtitle, + }); + + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!Util.isDesktop) ...[ + StepRow( + count: 4, + current: 3, + width: MediaQuery.of(context).size.width - 32, + ), + const SizedBox(height: 14), + ], + Text( + title, + style: Util.isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: Util.isDesktop ? 16 : 8), + Text( + subtitle, + style: Util.isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart new file mode 100644 index 0000000000..ede142409d --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit.dart @@ -0,0 +1,96 @@ +import "dart:async"; + +import "package:flutter/material.dart"; + +import "../../../db/isar/main_db.dart"; +import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../notifications/show_flush_bar.dart"; +import "../../../services/shopinbit/shopinbit_service.dart"; +import "../../../utilities/util.dart"; +import "../shopinbit_order_created.dart"; + +/// Submits a ShopinBit request to the API and navigates to the order-created +/// view on success. +/// +/// Used by the concierge, travel and generic flows. The car flow has its own +/// pre-payment branching (fee view) and does not call this helper. +Future submitShopInBitRequest( + BuildContext context, + ShopInBitOrderModel model, +) async { + try { + final ShopInBitService service = ShopInBitService.instance; + final String customerKey = await service.ensureCustomerKey(); + + assert( + model.category != null, + "Step 4 reached with null category: Step 2 must set category before" + " reaching Step 4", + ); + + // API service_type: travel requests use "concierge" because the + // ShopinBit API routes both through the same concierge pipeline. + // Travel-specific details are captured in the structured comment field. + final String categoryStr = switch (model.category) { + ShopInBitCategory.concierge => "concierge", + ShopInBitCategory.travel => "concierge", + ShopInBitCategory.car => "car", + null => throw StateError("category must be non-null at Step 4 submit"), + }; + + final resp = await service.client.createRequest( + customerPseudonym: model.displayName, + externalCustomerKey: customerKey, + serviceType: categoryStr, + comment: model.requestDescription, + deliveryCountry: model.deliveryCountry, + ); + + if (resp.hasError) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: resp.exception?.message ?? "Failed to create request", + context: context, + ), + ); + } + return; + } + + final ref = resp.value!; + model + ..apiTicketId = ref.id + ..ticketId = ref.number + ..status = ShopInBitOrderStatus.pending; + await MainDB.instance.putShopInBitTicket(model.toIsarTicket()); + + 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), + ); + } + } catch (e) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Failed to create request: $e", + context: context, + ), + ); + } + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart new file mode 100644 index 0000000000..ac38c46bb9 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_submit_button.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; + +import "../../../widgets/desktop/primary_button.dart"; + +class ShopInBitStep4SubmitButton extends StatelessWidget { + const ShopInBitStep4SubmitButton({ + super.key, + required this.submitting, + required this.enabled, + required this.onPressed, + }); + + final bool submitting; + final bool enabled; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return PrimaryButton( + label: submitting ? "Submitting..." : "Submit request", + enabled: enabled, + onPressed: enabled ? onPressed : null, + ); + } +} 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 new file mode 100644 index 0000000000..7cdc97a30c --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_step4_text_field.dart @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000000..bfddd184a1 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart @@ -0,0 +1,544 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; +import "shopinbit_country_picker.dart"; +import "shopinbit_labeled_checkbox.dart"; +import "shopinbit_privacy_checkbox.dart"; +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"; +const String _flexibleDates = "Flexible dates"; + +const List _arrangements = [ + "Flights Only", + "Hotels Only", + "Flights + Hotels", + "Full Service", +]; + +const List _dateModes = [_exactDates, _flexibleDates]; + +const List _flexibilities = [ + "Exact", + "\u00B1 1 day", + "\u00B1 2-3 days", + "+ 1 week", +]; + +const List _months = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const int _minTravelBudget = 1000; +const int _minArrangementDetailsLength = 10; + +/// Travel request form. Collects arrangement type, departure / destinations, +/// dates (either exact or flexible), travelers and budget, then submits via +/// the shared submit helper. +class ShopInBitTravelForm extends StatefulWidget { + const ShopInBitTravelForm({super.key, required this.model}); + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitTravelFormState(); +} + +class _ShopInBitTravelFormState extends State { + final TextEditingController _arrangementDetailsController = + TextEditingController(); + final FocusNode _arrangementDetailsFocusNode = FocusNode(); + bool _arrangementDetailsTouched = false; + + final TextEditingController _departureCityController = + TextEditingController(); + final FocusNode _departureCityFocusNode = FocusNode(); + bool _departureCityTouched = false; + + final TextEditingController _destinationsController = TextEditingController(); + 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; + + final TextEditingController _tripLengthController = TextEditingController(); + final FocusNode _tripLengthFocusNode = FocusNode(); + bool _tripLengthTouched = false; + + final TextEditingController _travelBudgetController = TextEditingController( + text: "5000", + ); + final FocusNode _travelBudgetFocusNode = FocusNode(); + bool _travelBudgetTouched = false; + + String? _selectedArrangement; + String? _selectedDepartureCountryIso; + String? _selectedDateMode; + String? _selectedFlexibility; + String? _selectedYear; + String? _selectedMonthSeason; + bool _needsRecommendations = false; + + int _adults = 1; + int _children = 0; + int _infants = 0; + int _pets = 0; + + bool _privacyAccepted = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _wireTouchOnBlur( + _arrangementDetailsFocusNode, + () => _arrangementDetailsTouched = true, + ); + _wireTouchOnBlur( + _departureCityFocusNode, + () => _departureCityTouched = true, + ); + _wireTouchOnBlur(_destinationsFocusNode, () => _destinationsTouched = true); + _wireTouchOnBlur( + _departureDateFocusNode, + () => _departureDateTouched = true, + ); + _wireTouchOnBlur(_returnDateFocusNode, () => _returnDateTouched = true); + _wireTouchOnBlur(_tripLengthFocusNode, () => _tripLengthTouched = true); + _wireTouchOnBlur(_travelBudgetFocusNode, () => _travelBudgetTouched = true); + } + + void _wireTouchOnBlur(FocusNode node, VoidCallback markTouched) { + node.addListener(() { + if (!node.hasFocus) markTouched(); + setState(() {}); + }); + } + + @override + void dispose() { + _arrangementDetailsController.dispose(); + _arrangementDetailsFocusNode.dispose(); + _departureCityController.dispose(); + _departureCityFocusNode.dispose(); + _destinationsController.dispose(); + _destinationsFocusNode.dispose(); + _departureDateController.dispose(); + _departureDateFocusNode.dispose(); + _returnDateController.dispose(); + _returnDateFocusNode.dispose(); + _tripLengthController.dispose(); + _tripLengthFocusNode.dispose(); + _travelBudgetController.dispose(); + _travelBudgetFocusNode.dispose(); + super.dispose(); + } + + bool get _hasValidDates => switch (_selectedDateMode) { + _flexibleDates => + _selectedYear != null && + _selectedMonthSeason != null && + _tripLengthController.text.trim().isNotEmpty, + _exactDates => + _departureDateController.text.trim().isNotEmpty && + _returnDateController.text.trim().isNotEmpty, + _ => false, + }; + + bool get _canContinue { + final int? travelBudgetValue = int.tryParse( + _travelBudgetController.text.trim(), + ); + return !_submitting && + _privacyAccepted && + _selectedArrangement != null && + _arrangementDetailsController.text.trim().length >= + _minArrangementDetailsLength && + _selectedDepartureCountryIso != null && + _departureCityController.text.trim().isNotEmpty && + (_needsRecommendations || + _destinationsController.text.trim().isNotEmpty) && + _selectedDateMode != null && + _hasValidDates && + _adults >= 1 && + travelBudgetValue != null && + 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"); + return "$day/$month/${date.year}"; + } + + String _buildRequestDescription() { + final List parts = [ + "Arrangement: $_selectedArrangement", + "Details: ${_arrangementDetailsController.text.trim()}", + "Departure: ${_departureCityController.text.trim()}, " + "${_selectedDepartureCountryIso ?? ''}", + ]; + + if (_needsRecommendations) { + parts.add("Destinations: Recommendations requested"); + } else { + parts.add("Destinations: ${_destinationsController.text.trim()}"); + } + + if (_selectedDateMode == _exactDates) { + final String flex = + _selectedFlexibility != null && _selectedFlexibility != "Exact" + ? " ($_selectedFlexibility)" + : ""; + parts.add( + "Dates: ${_departureDateController.text.trim()} - " + "${_returnDateController.text.trim()}$flex", + ); + } else if (_selectedDateMode == _flexibleDates) { + parts.add( + "Dates: $_selectedMonthSeason $_selectedYear, " + "${_tripLengthController.text.trim()} nights", + ); + } + + final List travelers = ["$_adults adult${_adults > 1 ? 's' : ''}"]; + if (_children > 0) { + travelers.add("$_children child${_children > 1 ? 'ren' : ''}"); + } + if (_infants > 0) { + travelers.add("$_infants infant${_infants > 1 ? 's' : ''}"); + } + if (_pets > 0) { + travelers.add("$_pets pet${_pets > 1 ? 's' : ''}"); + } + parts.add("Travelers: ${travelers.join(', ')}"); + + parts.add("Budget: ${_travelBudgetController.text.trim()} EUR"); + + return parts.join("\n"); + } + + Future _submit() async { + setState(() => _submitting = true); + widget.model + ..requestDescription = _buildRequestDescription() + // Travel doesn't collect a delivery country: default to "DE" since the + // API requires the field. Travel destinations are captured in the + // structured comment field. + ..deliveryCountry = "DE"; + try { + await submitShopInBitRequest(context, widget.model); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final bool isDesktop = Util.isDesktop; + + final String? arrangementDetailsError = + _arrangementDetailsTouched && + _arrangementDetailsController.text.trim().length < + _minArrangementDetailsLength + ? "Minimum $_minArrangementDetailsLength characters" + : null; + + final String? departureCityError = + _departureCityTouched && _departureCityController.text.trim().isEmpty + ? "Required" + : null; + + final String? destinationsError = + _destinationsTouched && + !_needsRecommendations && + _destinationsController.text.trim().isEmpty + ? "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" + : null; + + final String travelBudgetText = _travelBudgetController.text.trim(); + final int? travelBudgetValue = int.tryParse(travelBudgetText); + final String? travelBudgetError = + _travelBudgetTouched && + (travelBudgetText.isEmpty || + travelBudgetValue == null || + travelBudgetValue < _minTravelBudget) + ? "Minimum budget is 1,000 EUR" + : null; + + final int currentYear = DateTime.now().year; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const ShopInBitStep4Header( + title: "Travel request", + subtitle: "Tell us about your trip and we'll arrange everything.", + ), + SizedBox(height: isDesktop ? 32 : 24), + + _TravelSectionLabel(text: "Trip type", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitStep4Dropdown( + value: _selectedArrangement, + items: _arrangements, + hintText: "Arrangement type", + onChanged: (value) => setState(() => _selectedArrangement = value), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4TextField( + controller: _arrangementDetailsController, + focusNode: _arrangementDetailsFocusNode, + hintText: + "Describe your specific requirements " + "(luggage, cabin class, hotel stars, etc.)", + minLines: 3, + maxLines: 6, + errorText: arrangementDetailsError, + onChanged: (_) => setState(() {}), + ), + + SizedBox(height: isDesktop ? 24 : 16), + _TravelSectionLabel(text: "Where", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitCountryPicker( + selectedIso: _selectedDepartureCountryIso, + onChanged: (iso) => + setState(() => _selectedDepartureCountryIso = iso), + hintText: "Departure country", + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4TextField( + controller: _departureCityController, + focusNode: _departureCityFocusNode, + hintText: "Departure city", + errorText: departureCityError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4TextField( + controller: _destinationsController, + focusNode: _destinationsFocusNode, + hintText: "e.g. Paris, France; Rome, Italy", + enabled: !_needsRecommendations, + errorText: destinationsError, + onChanged: (_) => setState(() {}), + ), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitLabeledCheckbox( + value: _needsRecommendations, + onChanged: (v) => setState(() => _needsRecommendations = v), + label: "I need recommendations", + ), + + SizedBox(height: isDesktop ? 24 : 16), + _TravelSectionLabel(text: "When", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitStep4Dropdown( + value: _selectedDateMode, + items: _dateModes, + hintText: "Date mode", + onChanged: (value) => setState(() => _selectedDateMode = value), + ), + SizedBox(height: isDesktop ? 16 : 12), + + 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, + ), + 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), + ShopInBitStep4Dropdown( + value: _selectedFlexibility, + items: _flexibilities, + hintText: "Flexibility", + onChanged: (value) => setState(() => _selectedFlexibility = value), + ), + ], + + if (_selectedDateMode == _flexibleDates) ...[ + ShopInBitStep4Dropdown( + value: _selectedYear, + items: ["$currentYear", "${currentYear + 1}"], + hintText: "Year", + onChanged: (value) => setState(() => _selectedYear = value), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4Dropdown( + value: _selectedMonthSeason, + items: _months, + hintText: "Month or season", + onChanged: (value) => setState(() => _selectedMonthSeason = value), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4TextField( + controller: _tripLengthController, + focusNode: _tripLengthFocusNode, + hintText: "Number of nights", + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + errorText: tripLengthError, + onChanged: (_) => setState(() {}), + ), + ], + + SizedBox(height: isDesktop ? 24 : 16), + _TravelSectionLabel(text: "Who", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitTravelerCounter( + label: "Adults", + value: _adults, + min: 1, + onChanged: (v) => setState(() => _adults = v), + ), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitTravelerCounter( + label: "Children", + value: _children, + onChanged: (v) => setState(() => _children = v), + ), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitTravelerCounter( + label: "Infants", + value: _infants, + onChanged: (v) => setState(() => _infants = v), + ), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitTravelerCounter( + label: "Pets", + value: _pets, + onChanged: (v) => setState(() => _pets = v), + ), + + SizedBox(height: isDesktop ? 24 : 16), + _TravelSectionLabel(text: "Budget", isDesktop: isDesktop), + SizedBox(height: isDesktop ? 12 : 8), + ShopInBitStep4TextField( + controller: _travelBudgetController, + focusNode: _travelBudgetFocusNode, + hintText: "Minimum 1000 EUR", + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + suffixText: "EUR", + 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), + ShopInBitPrivacyCheckbox( + value: _privacyAccepted, + onChanged: (v) => setState(() => _privacyAccepted = v), + ), + SizedBox(height: isDesktop ? 16 : 12), + ShopInBitStep4SubmitButton( + submitting: _submitting, + enabled: _canContinue, + onPressed: _submit, + ), + ], + ); + } +} + +/// Bold-ish section header used inside the travel form ("Trip type", "Where", +/// "When", "Who", "Budget"). +class _TravelSectionLabel extends StatelessWidget { + const _TravelSectionLabel({required this.text, required this.isDesktop}); + + final String text; + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context), + ); + } +} diff --git a/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart b/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart new file mode 100644 index 0000000000..fb5ab6d412 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_traveler_counter.dart @@ -0,0 +1,85 @@ +import "package:flutter/material.dart"; + +import "../../../themes/stack_colors.dart"; +import "../../../utilities/constants.dart"; +import "../../../utilities/text_styles.dart"; +import "../../../utilities/util.dart"; + +/// Label + minus/value/plus counter row used in the travel form to set the +/// number of adults, children, infants and pets. +class ShopInBitTravelerCounter extends StatelessWidget { + const ShopInBitTravelerCounter({ + super.key, + required this.label, + required this.value, + required this.onChanged, + this.min = 0, + this.max = 20, + }); + + final String label; + final int value; + final int min; + final int max; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final TextStyle textStyle = Util.isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context); + + return Row( + children: [ + Text(label, style: textStyle), + const Spacer(), + _CounterButton( + symbol: "-", + onTap: value > min ? () => onChanged(value - 1) : null, + textStyle: textStyle, + ), + const SizedBox(width: 16), + SizedBox( + width: 24, + child: Center(child: Text("$value", style: textStyle)), + ), + const SizedBox(width: 16), + _CounterButton( + symbol: "+", + onTap: value < max ? () => onChanged(value + 1) : null, + textStyle: textStyle, + ), + ], + ); + } +} + +class _CounterButton extends StatelessWidget { + const _CounterButton({ + required this.symbol, + required this.onTap, + required this.textStyle, + }); + + final String symbol; + final VoidCallback? onTap; + final TextStyle textStyle; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Theme.of(context).extension()!.textFieldDefaultBG, + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Center(child: Text(symbol, style: textStyle)), + ), + ); + } +} diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 12affd42b9..83a4d6e8fa 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -71,6 +71,7 @@ import '../../widgets/custom_buttons/blue_text_button.dart'; import '../../widgets/custom_loading_overlay.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/frost_scaffold.dart'; +import '../../widgets/icon_widgets/credit_card_icon.dart'; import '../../widgets/loading_indicator.dart'; import '../../widgets/small_tor_icon.dart'; import '../../widgets/stack_dialog.dart'; @@ -96,6 +97,8 @@ import '../exchange_view/wallet_initiated_exchange_view.dart'; import '../finalize_view/finalize_view.dart'; import '../masternodes/masternodes_home_view.dart'; import '../monkey/monkey_view.dart'; +import '../more_view/gift_cards_view.dart'; +import '../more_view/services_view.dart'; import '../namecoin_names/namecoin_names_home_view.dart'; import '../notification_views/notifications_view.dart'; import '../ordinals/ordinals_view.dart'; @@ -109,8 +112,6 @@ import '../settings_views/wallet_settings_view/wallet_network_settings_view/wall import '../settings_views/wallet_settings_view/wallet_settings_view.dart'; import '../signing/signing_view.dart'; import '../spark_names/spark_names_home_view.dart'; -import '../more_view/gift_cards_view.dart'; -import '../more_view/services_view.dart'; import '../token_view/my_tokens_view.dart'; import 'sub_widgets/transactions_list.dart'; import 'sub_widgets/wallet_summary.dart'; @@ -1364,8 +1365,7 @@ class _WalletViewState extends ConsumerState { ), WalletNavigationBarItemData( label: "Gift cards", - icon: SvgPicture.asset( - Assets.svg.creditCard, + icon: CreditCardIcon( height: 20, width: 20, color: Theme.of( diff --git a/lib/pages_desktop_specific/services/sub_widgets/desktop_gift_cards_view.dart b/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart similarity index 90% rename from lib/pages_desktop_specific/services/sub_widgets/desktop_gift_cards_view.dart rename to lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart index 7693f43572..964028acb8 100644 --- a/lib/pages_desktop_specific/services/sub_widgets/desktop_gift_cards_view.dart +++ b/lib/pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.dart'; import '../../../app_config.dart'; import '../../../pages/cakepay/cakepay_orders_view.dart'; @@ -8,10 +7,10 @@ import '../../../pages/cakepay/cakepay_vendors_view.dart'; import '../../../services/event_bus/events/global/tor_connection_status_changed_event.dart'; import '../../../services/tor_service.dart'; import '../../../themes/stack_colors.dart'; -import '../../../utilities/assets.dart'; import '../../../utilities/text_styles.dart'; import '../../../widgets/desktop/primary_button.dart'; import '../../../widgets/desktop/secondary_button.dart'; +import '../../../widgets/icon_widgets/credit_card_icon.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/tor_subscription.dart'; @@ -53,17 +52,9 @@ class _DesktopGiftCardsViewState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: SvgPicture.asset( - Assets.svg.creditCard, - width: 48, - height: 48, - colorFilter: ColorFilter.mode( - Theme.of(context).extension()!.textDark, - BlendMode.srcIn, - ), - ), + const Padding( + padding: EdgeInsets.all(8.0), + child: CreditCardIcon(width: 48, height: 48), ), Padding( padding: const EdgeInsets.all(10), diff --git a/lib/pages_desktop_specific/services/desktop_services_view.dart b/lib/pages_desktop_specific/services/desktop_services_view.dart index 26b6e9e59f..f94f708831 100644 --- a/lib/pages_desktop_specific/services/desktop_services_view.dart +++ b/lib/pages_desktop_specific/services/desktop_services_view.dart @@ -9,8 +9,8 @@ import '../../utilities/text_styles.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; import '../settings/settings_menu_item.dart'; -import 'sub_widgets/desktop_gift_cards_view.dart'; -import 'sub_widgets/desktop_shopinbit_view.dart'; +import 'cakepay/desktop_gift_cards_view.dart'; +import 'shopin_bit/desktop_shopinbit_view.dart'; final selectedServicesMenuItemStateProvider = StateProvider((_) => 0); diff --git a/lib/pages_desktop_specific/services/sub_widgets/desktop_shopinbit_view.dart b/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart similarity index 83% rename from lib/pages_desktop_specific/services/sub_widgets/desktop_shopinbit_view.dart rename to lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart index e5c9e596a2..956a77cd73 100644 --- a/lib/pages_desktop_specific/services/sub_widgets/desktop_shopinbit_view.dart +++ b/lib/pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart @@ -10,7 +10,6 @@ import '../../../db/isar/main_db.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/desktop/current_desktop_menu_item.dart'; import '../../../services/shopinbit/shopinbit_service.dart'; @@ -21,11 +20,13 @@ 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/rounded_container.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../../widgets/textfields/adaptive_text_field.dart'; import '../../desktop_menu.dart'; import '../../settings/settings_menu.dart'; +import 'sub_widgets/desktop_shopin_bit_first_run.dart'; class DesktopShopInBitView extends ConsumerStatefulWidget { const DesktopShopInBitView({super.key}); @@ -89,7 +90,7 @@ class _DesktopServicesViewState extends ConsumerState { return shouldContinue ?? false; } - void _showShopDialog(BuildContext context) async { + Future _showShopDialog(BuildContext context) async { final service = ShopInBitService.instance; final model = ShopInBitOrderModel(); bool isFirstRun = false; @@ -111,98 +112,17 @@ class _DesktopServicesViewState extends ConsumerState { } } - if (!mounted) return; + if (!context.mounted) return; if (isFirstRun) { // First run: show service overview then go directly to Step2 // (name was just entered in setup dialog, no need to show Step1 again). - showDialog( + await showDialog( context: context, barrierDismissible: false, - builder: (dialogContext) => DesktopDialog( - maxWidth: 550, - maxHeight: 300, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("ShopinBit", style: STextStyles.desktopH2(dialogContext)), - const SizedBox(height: 16), - RichText( - text: TextSpan( - style: STextStyles.desktopTextSmall(dialogContext), - children: const [ - TextSpan( - text: - "Please note the following before proceeding:" - "\n\n\u2022 Minimum order amount: 1,000 EUR" - "\n\u2022 Service fee: 10% of the order total", - ), - ], - ), - ), - const Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SecondaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Cancel", - onPressed: () { - Navigator.of(dialogContext, rootNavigator: true).pop(); - }, - ), - const SizedBox(width: 20), - PrimaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Continue", - onPressed: () async { - Navigator.of(dialogContext, rootNavigator: true).pop(); - await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ShopInBitStep2(model: model), - ); - if (mounted) setState(() {}); - }, - ), - ], - ), - const Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SecondaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Cancel", - onPressed: () { - Navigator.of(dialogContext, rootNavigator: true).pop(); - }, - ), - const SizedBox(width: 20), - PrimaryButton( - width: 200, - buttonHeight: ButtonHeight.l, - label: "Continue", - onPressed: () async { - Navigator.of(dialogContext, rootNavigator: true).pop(); - await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => ShopInBitStep1(model: model), - ); - if (mounted) setState(() {}); - }, - ), - ], - ), - ], - ), - ), + builder: (_) => NestedNavigatorDialog( + initialRoute: DesktopShopinBitFirstRun.routeName, + initialRouteArgs: model, ), ); } else { @@ -210,8 +130,13 @@ class _DesktopServicesViewState extends ConsumerState { await showDialog( context: context, barrierDismissible: false, - builder: (_) => ShopInBitStep1(model: model), + builder: (_) => NestedNavigatorDialog( + initialRoute: ShopInBitStep1.routeName, + initialRouteArgs: model, + ), ); + + // TODO: figure out and comment why this is needed if (mounted) setState(() {}); } } 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 new file mode 100644 index 0000000000..69b6dfffbd --- /dev/null +++ b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import '../../../../models/shopinbit/shopinbit_order_model.dart'; +import '../../../../pages/shopinbit/shopinbit_step_1.dart'; +import '../../../../utilities/text_styles.dart'; +import '../../../../widgets/desktop/primary_button.dart'; +import '../../../../widgets/desktop/secondary_button.dart'; +import '../../../../widgets/dialogs/s_dialog.dart'; + +class DesktopShopinBitFirstRun extends StatelessWidget { + const DesktopShopinBitFirstRun({super.key, required this.model}); + + static const routeName = "/desktopShopinBitFirstRun"; + + final ShopInBitOrderModel model; + + @override + Widget build(BuildContext context) { + return SDialog( + child: SizedBox( + width: 580, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("ShopinBit", style: STextStyles.desktopH2(context)), + const SizedBox(height: 16), + RichText( + text: TextSpan( + style: STextStyles.desktopTextSmall(context), + children: const [ + TextSpan( + text: + "Please note the following before proceeding:" + "\n\n\u2022 Minimum order amount: 1,000 EUR" + "\n\u2022 Service fee: 10% of the order total", + ), + ], + ), + ), + const Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SecondaryButton( + width: 220, + buttonHeight: ButtonHeight.l, + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + PrimaryButton( + width: 220, + buttonHeight: ButtonHeight.l, + label: "Continue", + onPressed: () => Navigator.of( + context, + ).pushNamed(ShopInBitStep1.routeName, arguments: model), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 0f0fbbf58e..d08cdb1655 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -241,9 +241,9 @@ import 'pages_desktop_specific/password/create_password_view.dart'; import 'pages_desktop_specific/password/delete_password_warning_view.dart'; import 'pages_desktop_specific/password/forgot_password_desktop_view.dart'; import 'pages_desktop_specific/password/forgotten_passphrase_restore_from_swb.dart'; +import 'pages_desktop_specific/services/cakepay/desktop_gift_cards_view.dart'; import 'pages_desktop_specific/services/desktop_services_view.dart'; -import 'pages_desktop_specific/services/sub_widgets/desktop_gift_cards_view.dart'; -import 'pages_desktop_specific/services/sub_widgets/desktop_shopinbit_view.dart'; +import 'pages_desktop_specific/services/shopin_bit/desktop_shopinbit_view.dart'; import 'pages_desktop_specific/settings/desktop_settings_view.dart'; import 'pages_desktop_specific/settings/settings_menu/advanced_settings/advanced_settings.dart'; import 'pages_desktop_specific/settings/settings_menu/appearance_settings/appearance_settings.dart'; diff --git a/lib/services/cakepay/cakepay_service.dart b/lib/services/cakepay/cakepay_service.dart index 1016bc4b77..48db6a917c 100644 --- a/lib/services/cakepay/cakepay_service.dart +++ b/lib/services/cakepay/cakepay_service.dart @@ -1,51 +1,42 @@ -import '../../db/hive/db.dart'; +import 'package:drift/drift.dart'; + +import '../../db/drift/shared_database.dart'; import '../../external_api_keys.dart'; import 'src/client.dart'; -import 'src/models/order.dart'; class CakePayService { static final instance = CakePayService._(); CakePayService._(); - /// Dev-only: override order statuses for local UI testing. - /// Keys are order IDs, values are the status to pretend the API returned. - static final Map devStatusOverrides = {}; - CakePayClient? _client; CakePayClient get client { return _client ??= CakePayClient(apiToken: kCakePayApiToken); } - // Mirrors ShopInBit's local ticket storage pattern but uses lightweight - // Hive prefs instead of a full Isar collection, since CakePay orders can - // be fetched individually via getOrder() with the seller key. - - static const _kCakePayOrderIds = "cakePayOrderIds"; - - /// Persist a newly-created order ID so the orders list view can find it - /// later without requiring Knox user auth. - void addOrderId(String orderId) { - final ids = getOrderIds(); - if (!ids.contains(orderId)) { - ids.insert(0, orderId); - DB.instance.put( - boxName: DB.boxNamePrefs, - key: _kCakePayOrderIds, - value: ids, - ); - } + Future addOrderId(String orderId) async { + final db = SharedDrift.get(); + + await db.transaction(() async { + await db + .into(db.cakepayOrders) + .insert( + CakepayOrdersCompanion.insert(orderId: orderId), + mode: .insertOrIgnore, + ); + }); } /// Return locally-tracked order IDs (most recent first). - List getOrderIds() { - final raw = DB.instance.get( - boxName: DB.boxNamePrefs, - key: _kCakePayOrderIds, - ); - if (raw is List) { - return raw.cast().toList(); - } - return []; + Future> getOrderIds() async { + final db = SharedDrift.get(); + + final rows = + await (db.select(db.cakepayOrders)..orderBy([ + (t) => OrderingTerm(expression: t.rowId, mode: OrderingMode.desc), + ])) + .get(); + + return rows.map((row) => row.orderId).toList(); } } diff --git a/lib/services/cakepay/src/models/card.dart b/lib/services/cakepay/src/models/card.dart index 2fed2f47e0..83d2eb3bc1 100644 --- a/lib/services/cakepay/src/models/card.dart +++ b/lib/services/cakepay/src/models/card.dart @@ -1,3 +1,5 @@ +import "package:decimal/decimal.dart"; + class CakePayCard { final int id; final String name; @@ -9,11 +11,11 @@ class CakePayCard { final String? cardImageUrl; final String? country; final String? currencyCode; - final List denominations; - final double? minValue; - final double? maxValue; - final double? minValueUsd; - final double? maxValueUsd; + final List denominations; + final Decimal? minValue; + final Decimal? maxValue; + final Decimal? minValueUsd; + final Decimal? maxValueUsd; final bool available; final String? lastUpdated; @@ -38,72 +40,84 @@ class CakePayCard { }); factory CakePayCard.fromJson(Map json) { - final rawDenoms = json['denominations'] ?? json['denominations_list']; - final denominations = []; + final dynamic rawDenoms = + json["denominations"] ?? json["denominations_list"]; + final List denominations = []; if (rawDenoms is List) { - for (final d in rawDenoms) { - if (d is num) { - denominations.add(d.toDouble()); - } else if (d is String) { - final parsed = double.tryParse(d); - if (parsed != null) denominations.add(parsed); - } else if (d is Map) { - final v = d['value']; - if (v is num) { - denominations.add(v.toDouble()); - } else if (v is String) { - final parsed = double.tryParse(v); - if (parsed != null) denominations.add(parsed); - } - } + for (final dynamic d in rawDenoms) { + final Decimal? parsed = _toDecimal(d is Map ? d["value"] : d); + if (parsed != null) denominations.add(parsed); } } return CakePayCard( - id: json['id'] as int? ?? 0, - name: (json['name'] ?? '') as String, - type: json['type'] as String?, - description: json['description'] as String?, - termsAndConditions: json['terms_and_conditions'] as String?, - howToUse: json['how_to_use'] as String?, - expiryAndValidity: json['expiry_and_validity'] as String?, - cardImageUrl: json['card_image_url'] as String?, - country: json['country'] is Map - ? (json['country'] as Map)['name'] as String? - : json['country'] as String?, - currencyCode: json['currency_code'] as String?, + id: json["id"] as int? ?? 0, + name: (json["name"] ?? "") as String, + type: json["type"] as String?, + description: json["description"] as String?, + termsAndConditions: json["terms_and_conditions"] as String?, + howToUse: json["how_to_use"] as String?, + expiryAndValidity: json["expiry_and_validity"] as String?, + cardImageUrl: json["card_image_url"] as String?, + country: json["country"] is Map + ? (json["country"] as Map)["name"] as String? + : json["country"] as String?, + currencyCode: json["currency_code"] as String?, denominations: denominations, - minValue: _toDouble(json['min_value']), - maxValue: _toDouble(json['max_value']), - minValueUsd: _toDouble(json['min_value_usd']), - maxValueUsd: _toDouble(json['max_value_usd']), - available: json['available'] as bool? ?? true, - lastUpdated: json['last_updated'] as String?, + minValue: _toDecimal(json["min_value"]), + maxValue: _toDecimal(json["max_value"]), + minValueUsd: _toDecimal(json["min_value_usd"]), + maxValueUsd: _toDecimal(json["max_value_usd"]), + available: json["available"] as bool? ?? true, + lastUpdated: json["last_updated"] as String?, ); } + Map toMap() { + return { + "id": id, + "name": name, + "type": type, + "description": description, + "terms_and_conditions": termsAndConditions, + "how_to_use": howToUse, + "expiry_and_validity": expiryAndValidity, + "card_image_url": cardImageUrl, + "country": country, + "currency_code": currencyCode, + "denominations": denominations.map((Decimal d) => d.toString()).toList(), + "min_value": minValue?.toString(), + "max_value": maxValue?.toString(), + "min_value_usd": minValueUsd?.toString(), + "max_value_usd": maxValueUsd?.toString(), + "available": available, + "last_updated": lastUpdated, + }; + } + bool get isFixedDenomination => denominations.isNotEmpty; bool get isRangeDenomination => denominations.isEmpty && minValue != null && maxValue != null; String get denominationRange { if (isFixedDenomination) { - return denominations.map((d) => d.toStringAsFixed(0)).join(', '); + return denominations.map((Decimal d) => d.toStringAsFixed(0)).join(", "); } if (isRangeDenomination) { - return '${minValue!.toStringAsFixed(0)} - ${maxValue!.toStringAsFixed(0)}'; + return "${minValue!.toStringAsFixed(0)} - ${maxValue!.toStringAsFixed(0)}"; } - return ''; + return ""; } @override - String toString() => 'CakePayCard($id, $name)'; + String toString() => toMap().toString(); } -double? _toDouble(dynamic v) { +Decimal? _toDecimal(dynamic v) { if (v == null) return null; - if (v is double) return v; - if (v is int) return v.toDouble(); - if (v is String) return double.tryParse(v); + if (v is Decimal) return v; + if (v is int) return Decimal.fromInt(v); + if (v is double) return Decimal.parse(v.toString()); + if (v is String) return Decimal.tryParse(v); return null; } diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index eec6dd3604..2f8e91d065 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -16,10 +16,10 @@ enum TicketState { final String value; const TicketState(this.value); - static TicketState fromString(String s) { + static TicketState fromString(String value) { return TicketState.values.firstWhere( - (e) => e.value == s, - orElse: () => TicketState.newTicket, + (e) => e.value == value, + orElse: () => throw Exception("Unknown TicketState string found: $value"), ); } } @@ -104,9 +104,7 @@ class TicketFull { } } -int _toInt(dynamic v) { - if (v is int) return v; - if (v is String) return int.parse(v); - if (v is double) return v.toInt(); - return 0; +int _toInt(dynamic value) { + if (value is int) return value; + return int.parse(value.toString()); } diff --git a/lib/services/shopinbit/src/models/webhook_event.dart b/lib/services/shopinbit/src/models/webhook_event.dart index 7bf41694e8..67a160b2cf 100644 --- a/lib/services/shopinbit/src/models/webhook_event.dart +++ b/lib/services/shopinbit/src/models/webhook_event.dart @@ -5,10 +5,11 @@ enum WebhookEventType { final String value; const WebhookEventType(this.value); - static WebhookEventType fromString(String s) { + static WebhookEventType fromString(String value) { return WebhookEventType.values.firstWhere( - (e) => e.value == s, - orElse: () => WebhookEventType.ticketStateChanged, + (e) => e.value == value, + orElse: () => + throw Exception("Unknown WebhookEventType string found: $value"), ); } } diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart new file mode 100644 index 0000000000..ae54f23f28 --- /dev/null +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import 'nested_navigator_dialog_route_generator.dart'; + +class NestedNavigatorDialog extends StatefulWidget { + const NestedNavigatorDialog({ + super.key, + required this.initialRoute, + this.initialRouteArgs, + this.navigatorKey, + }); + + final String initialRoute; + final Object? initialRouteArgs; + final GlobalKey? navigatorKey; + + @override + State createState() => _NestedNavigatorDialogState(); +} + +class _NestedNavigatorDialogState extends State { + late final _CloseOnEmptyObserver _observer; + late final GlobalKey _navigatorKey; + + NavigatorState? _parentNavigator; + + void _close() { + if (mounted) _parentNavigator?.pop(); + } + + @override + void initState() { + super.initState(); + _observer = _CloseOnEmptyObserver(_close); + _navigatorKey = widget.navigatorKey ?? GlobalKey(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _parentNavigator = Navigator.of(context); + } + + @override + Widget build(BuildContext context) { + return Dialog( + 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, + ), + ), + ], + ), + ); + } +} + +class _CloseOnEmptyObserver extends NavigatorObserver { + _CloseOnEmptyObserver(this.onEmpty); + + final VoidCallback onEmpty; + + @override + void didPop(Route route, Route? previousRoute) { + if (previousRoute == null) onEmpty(); + } +} 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 new file mode 100644 index 0000000000..7d924861bc --- /dev/null +++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart @@ -0,0 +1,160 @@ +import 'dart:math'; + +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 '../../../pages/shopinbit/shopinbit_step_3.dart'; +import '../../../pages/shopinbit/shopinbit_step_4.dart'; +import '../../../pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../utilities/util.dart'; +import '../../conditional_parent.dart'; +import '../../desktop/desktop_dialog_close_button.dart'; +import '../s_dialog.dart'; + +abstract final class NestedNavigatorDialogRouteGenerator { + static Route generateRoute(RouteSettings settings) { + final args = settings.arguments; + + switch (settings.name) { + case DesktopShopinBitFirstRun.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => DesktopShopinBitFirstRun(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + + case ShopInBitStep1.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitStep1(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + + case ShopInBitStep2.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitStep2(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + + case ShopInBitStep3.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitStep3(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + + case ShopInBitStep4.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + builder: (_) => ShopInBitStep4(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError( + "${settings.name} invalid args\n" + "Got ${args.runtimeType}\n" + "Expected ShopInBitOrderModel", + ); + + default: + return _routeError("Unknown route name: ${settings.name}"); + } + } + + static Route getRoute({ + required WidgetBuilder builder, + RouteSettings? settings, + }) { + return PageRouteBuilder( + settings: settings, + opaque: false, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 220), + reverseTransitionDuration: const Duration(milliseconds: 220), + pageBuilder: (BuildContext context, _, __) => builder(context), + transitionsBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition( + opacity: animation, + child: FadeTransition( + opacity: Tween( + begin: 1, + end: 0, + ).animate(secondaryAnimation), + child: child, + ), + ); + }, + ); + } + + static Route _routeError(String message) { + return getRoute( + builder: (context) => SDialog( + child: ConditionalParent( + condition: Util.isDesktop, + builder: (child) => SizedBox( + width: 580, + child: Column( + mainAxisSize: .min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Navigation Error", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + child, + const SizedBox(height: 32), + ], + ), + ), + child: Text( + "Error handling route, this is not supposed to happen. " + "Contact developers.\n$message", + ), + ), + ), + ); + } +} diff --git a/lib/widgets/dialogs/s_dialog.dart b/lib/widgets/dialogs/s_dialog.dart index a6b32148c4..6bf66ecf4a 100644 --- a/lib/widgets/dialogs/s_dialog.dart +++ b/lib/widgets/dialogs/s_dialog.dart @@ -29,30 +29,26 @@ class SDialog extends StatelessWidget { return Padding( padding: margin ?? EdgeInsets.all(Util.isDesktop ? 32 : 16), child: Column( - mainAxisAlignment: mainAxisAlignment ?? + mainAxisAlignment: + mainAxisAlignment ?? (Util.isDesktop ? MainAxisAlignment.center : MainAxisAlignment.end), crossAxisAlignment: crossAxisAlignment ?? CrossAxisAlignment.center, + mainAxisSize: .min, children: [ Flexible( child: Material( borderRadius: BorderRadius.circular(20), child: Container( decoration: BoxDecoration( - color: background ?? + color: + background ?? Theme.of(context).extension()!.popupBG, - borderRadius: BorderRadius.circular( - 20, - ), + borderRadius: BorderRadius.circular(20), ), child: ConditionalParent( condition: contentCanScroll, - builder: (child) => SingleChildScrollView( - child: child, - ), - child: Padding( - padding: padding, - child: child, - ), + builder: (child) => SingleChildScrollView(child: child), + child: Padding(padding: padding, child: child), ), ), ), diff --git a/lib/widgets/icon_widgets/credit_card_icon.dart b/lib/widgets/icon_widgets/credit_card_icon.dart new file mode 100644 index 0000000000..369792e562 --- /dev/null +++ b/lib/widgets/icon_widgets/credit_card_icon.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; + +class CreditCardIcon extends StatelessWidget { + const CreditCardIcon({ + super.key, + this.width = 32, + this.height = 32, + this.color, + }); + + final double width; + final double height; + final Color? color; + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + Assets.svg.creditCard, + width: width, + height: height, + colorFilter: ColorFilter.mode( + color ?? Theme.of(context).extension()!.textDark3, + BlendMode.srcIn, + ), + ); + } +} diff --git a/lib/widgets/textfields/adaptive_text_field.dart b/lib/widgets/textfields/adaptive_text_field.dart index e57746a80f..da30057e8b 100644 --- a/lib/widgets/textfields/adaptive_text_field.dart +++ b/lib/widgets/textfields/adaptive_text_field.dart @@ -26,6 +26,7 @@ class AdaptiveTextField extends StatefulWidget { this.minLines, this.maxLines, this.showPasteClearButton = false, + this.keyboardType, }); final String? labelText; @@ -50,6 +51,8 @@ class AdaptiveTextField extends StatefulWidget { /// If this is not null, [showPasteClearButton] will be ignored. final List? suffixIcons; + final TextInputType? keyboardType; + @override State createState() => _AdaptiveTextFieldState(); } @@ -112,6 +115,7 @@ class _AdaptiveTextFieldState extends State { autocorrect: widget.autocorrect, enableSuggestions: widget.enableSuggestions, onSubmitted: widget.onSubmitted, + keyboardType: widget.keyboardType, decoration: standardInputDecoration( widget.labelText,