diff --git a/lib/db/drift/shared_database.dart b/lib/db/drift/shared_database.dart new file mode 100644 index 0000000000..e83028d42b --- /dev/null +++ b/lib/db/drift/shared_database.dart @@ -0,0 +1,110 @@ +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}; +} + +class ShopinBitSettings extends Table { + // Single row table - always row 0 + IntColumn get id => integer().withDefault(const Constant(0))(); + + BoolColumn get guidelinesAccepted => + boolean().withDefault(const Constant(false))(); + BoolColumn get setupComplete => + boolean().withDefault(const Constant(false))(); + TextColumn get displayName => text().nullable()(); + + @override + Set get primaryKey => {id}; +} + +@DriftAccessor(tables: [ShopinBitSettings]) +class ShopinBitSettingsDao extends DatabaseAccessor + with _$ShopinBitSettingsDaoMixin { + ShopinBitSettingsDao(super.db); + + Future getSettings() async { + final ShopinBitSetting? row = await (select( + shopinBitSettings, + )..where((t) => t.id.equals(0))).getSingleOrNull(); + if (row != null) return row; + + return into( + shopinBitSettings, + ).insertReturning(ShopinBitSettingsCompanion.insert(id: const Value(0))); + } + + Future setGuidelinesAccepted(bool accepted) => + _update(ShopinBitSettingsCompanion(guidelinesAccepted: Value(accepted))); + + Future setSetupComplete(bool complete) => + _update(ShopinBitSettingsCompanion(setupComplete: Value(complete))); + + Future setDisplayName(String name) => + _update(ShopinBitSettingsCompanion(displayName: Value(name))); + + Future _update(ShopinBitSettingsCompanion changes) async { + await getSettings(); // ensure row exists + await (update( + shopinBitSettings, + )..where((t) => t.id.equals(0))).write(changes); + } +} + +@DriftDatabase( + tables: [CakepayOrders, ShopinBitSettings], + daos: [ShopinBitSettingsDao], +) +final class SharedDatabase extends _$SharedDatabase { + SharedDatabase._([QueryExecutor? executor]) + : super(executor ?? _openConnection()); + + @override + int get schemaVersion => 2; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (m, from, to) async { + if (from == 1 && to == 2) { + await m.createTable(shopinBitSettings); + } + }, + ); + + 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..9b7d8d7a31 --- /dev/null +++ b/lib/db/drift/shared_database.g.dart @@ -0,0 +1,845 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shared_database.dart'; + +// ignore_for_file: type=lint +mixin _$ShopinBitSettingsDaoMixin on DatabaseAccessor { + $ShopinBitSettingsTable get shopinBitSettings => + attachedDatabase.shopinBitSettings; + ShopinBitSettingsDaoManager get managers => ShopinBitSettingsDaoManager(this); +} + +class ShopinBitSettingsDaoManager { + final _$ShopinBitSettingsDaoMixin _db; + ShopinBitSettingsDaoManager(this._db); + $$ShopinBitSettingsTableTableManager get shopinBitSettings => + $$ShopinBitSettingsTableTableManager( + _db.attachedDatabase, + _db.shopinBitSettings, + ); +} + +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(); + } +} + +class $ShopinBitSettingsTable extends ShopinBitSettings + with TableInfo<$ShopinBitSettingsTable, ShopinBitSetting> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ShopinBitSettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _guidelinesAcceptedMeta = + const VerificationMeta('guidelinesAccepted'); + @override + late final GeneratedColumn guidelinesAccepted = GeneratedColumn( + 'guidelines_accepted', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("guidelines_accepted" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _setupCompleteMeta = const VerificationMeta( + 'setupComplete', + ); + @override + late final GeneratedColumn setupComplete = GeneratedColumn( + 'setup_complete', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("setup_complete" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); + static const VerificationMeta _displayNameMeta = const VerificationMeta( + 'displayName', + ); + @override + late final GeneratedColumn displayName = GeneratedColumn( + 'display_name', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + guidelinesAccepted, + setupComplete, + displayName, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'shopin_bit_settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('guidelines_accepted')) { + context.handle( + _guidelinesAcceptedMeta, + guidelinesAccepted.isAcceptableOrUnknown( + data['guidelines_accepted']!, + _guidelinesAcceptedMeta, + ), + ); + } + if (data.containsKey('setup_complete')) { + context.handle( + _setupCompleteMeta, + setupComplete.isAcceptableOrUnknown( + data['setup_complete']!, + _setupCompleteMeta, + ), + ); + } + if (data.containsKey('display_name')) { + context.handle( + _displayNameMeta, + displayName.isAcceptableOrUnknown( + data['display_name']!, + _displayNameMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ShopinBitSetting map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ShopinBitSetting( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + guidelinesAccepted: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}guidelines_accepted'], + )!, + setupComplete: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}setup_complete'], + )!, + displayName: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}display_name'], + ), + ); + } + + @override + $ShopinBitSettingsTable createAlias(String alias) { + return $ShopinBitSettingsTable(attachedDatabase, alias); + } +} + +class ShopinBitSetting extends DataClass + implements Insertable { + final int id; + final bool guidelinesAccepted; + final bool setupComplete; + final String? displayName; + const ShopinBitSetting({ + required this.id, + required this.guidelinesAccepted, + required this.setupComplete, + this.displayName, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['guidelines_accepted'] = Variable(guidelinesAccepted); + map['setup_complete'] = Variable(setupComplete); + if (!nullToAbsent || displayName != null) { + map['display_name'] = Variable(displayName); + } + return map; + } + + ShopinBitSettingsCompanion toCompanion(bool nullToAbsent) { + return ShopinBitSettingsCompanion( + id: Value(id), + guidelinesAccepted: Value(guidelinesAccepted), + setupComplete: Value(setupComplete), + displayName: displayName == null && nullToAbsent + ? const Value.absent() + : Value(displayName), + ); + } + + factory ShopinBitSetting.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ShopinBitSetting( + id: serializer.fromJson(json['id']), + guidelinesAccepted: serializer.fromJson(json['guidelinesAccepted']), + setupComplete: serializer.fromJson(json['setupComplete']), + displayName: serializer.fromJson(json['displayName']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'guidelinesAccepted': serializer.toJson(guidelinesAccepted), + 'setupComplete': serializer.toJson(setupComplete), + 'displayName': serializer.toJson(displayName), + }; + } + + ShopinBitSetting copyWith({ + int? id, + bool? guidelinesAccepted, + bool? setupComplete, + Value displayName = const Value.absent(), + }) => ShopinBitSetting( + id: id ?? this.id, + guidelinesAccepted: guidelinesAccepted ?? this.guidelinesAccepted, + setupComplete: setupComplete ?? this.setupComplete, + displayName: displayName.present ? displayName.value : this.displayName, + ); + ShopinBitSetting copyWithCompanion(ShopinBitSettingsCompanion data) { + return ShopinBitSetting( + id: data.id.present ? data.id.value : this.id, + guidelinesAccepted: data.guidelinesAccepted.present + ? data.guidelinesAccepted.value + : this.guidelinesAccepted, + setupComplete: data.setupComplete.present + ? data.setupComplete.value + : this.setupComplete, + displayName: data.displayName.present + ? data.displayName.value + : this.displayName, + ); + } + + @override + String toString() { + return (StringBuffer('ShopinBitSetting(') + ..write('id: $id, ') + ..write('guidelinesAccepted: $guidelinesAccepted, ') + ..write('setupComplete: $setupComplete, ') + ..write('displayName: $displayName') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, guidelinesAccepted, setupComplete, displayName); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ShopinBitSetting && + other.id == this.id && + other.guidelinesAccepted == this.guidelinesAccepted && + other.setupComplete == this.setupComplete && + other.displayName == this.displayName); +} + +class ShopinBitSettingsCompanion extends UpdateCompanion { + final Value id; + final Value guidelinesAccepted; + final Value setupComplete; + final Value displayName; + const ShopinBitSettingsCompanion({ + this.id = const Value.absent(), + this.guidelinesAccepted = const Value.absent(), + this.setupComplete = const Value.absent(), + this.displayName = const Value.absent(), + }); + ShopinBitSettingsCompanion.insert({ + this.id = const Value.absent(), + this.guidelinesAccepted = const Value.absent(), + this.setupComplete = const Value.absent(), + this.displayName = const Value.absent(), + }); + static Insertable custom({ + Expression? id, + Expression? guidelinesAccepted, + Expression? setupComplete, + Expression? displayName, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (guidelinesAccepted != null) 'guidelines_accepted': guidelinesAccepted, + if (setupComplete != null) 'setup_complete': setupComplete, + if (displayName != null) 'display_name': displayName, + }); + } + + ShopinBitSettingsCompanion copyWith({ + Value? id, + Value? guidelinesAccepted, + Value? setupComplete, + Value? displayName, + }) { + return ShopinBitSettingsCompanion( + id: id ?? this.id, + guidelinesAccepted: guidelinesAccepted ?? this.guidelinesAccepted, + setupComplete: setupComplete ?? this.setupComplete, + displayName: displayName ?? this.displayName, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (guidelinesAccepted.present) { + map['guidelines_accepted'] = Variable(guidelinesAccepted.value); + } + if (setupComplete.present) { + map['setup_complete'] = Variable(setupComplete.value); + } + if (displayName.present) { + map['display_name'] = Variable(displayName.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ShopinBitSettingsCompanion(') + ..write('id: $id, ') + ..write('guidelinesAccepted: $guidelinesAccepted, ') + ..write('setupComplete: $setupComplete, ') + ..write('displayName: $displayName') + ..write(')')) + .toString(); + } +} + +abstract class _$SharedDatabase extends GeneratedDatabase { + _$SharedDatabase(QueryExecutor e) : super(e); + $SharedDatabaseManager get managers => $SharedDatabaseManager(this); + late final $CakepayOrdersTable cakepayOrders = $CakepayOrdersTable(this); + late final $ShopinBitSettingsTable shopinBitSettings = + $ShopinBitSettingsTable(this); + late final ShopinBitSettingsDao shopinBitSettingsDao = ShopinBitSettingsDao( + this as SharedDatabase, + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + cakepayOrders, + shopinBitSettings, + ]; +} + +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() + >; +typedef $$ShopinBitSettingsTableCreateCompanionBuilder = + ShopinBitSettingsCompanion Function({ + Value id, + Value guidelinesAccepted, + Value setupComplete, + Value displayName, + }); +typedef $$ShopinBitSettingsTableUpdateCompanionBuilder = + ShopinBitSettingsCompanion Function({ + Value id, + Value guidelinesAccepted, + Value setupComplete, + Value displayName, + }); + +class $$ShopinBitSettingsTableFilterComposer + extends Composer<_$SharedDatabase, $ShopinBitSettingsTable> { + $$ShopinBitSettingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get guidelinesAccepted => $composableBuilder( + column: $table.guidelinesAccepted, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get setupComplete => $composableBuilder( + column: $table.setupComplete, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnFilters(column), + ); +} + +class $$ShopinBitSettingsTableOrderingComposer + extends Composer<_$SharedDatabase, $ShopinBitSettingsTable> { + $$ShopinBitSettingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get guidelinesAccepted => $composableBuilder( + column: $table.guidelinesAccepted, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get setupComplete => $composableBuilder( + column: $table.setupComplete, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$ShopinBitSettingsTableAnnotationComposer + extends Composer<_$SharedDatabase, $ShopinBitSettingsTable> { + $$ShopinBitSettingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get guidelinesAccepted => $composableBuilder( + column: $table.guidelinesAccepted, + builder: (column) => column, + ); + + GeneratedColumn get setupComplete => $composableBuilder( + column: $table.setupComplete, + builder: (column) => column, + ); + + GeneratedColumn get displayName => $composableBuilder( + column: $table.displayName, + builder: (column) => column, + ); +} + +class $$ShopinBitSettingsTableTableManager + extends + RootTableManager< + _$SharedDatabase, + $ShopinBitSettingsTable, + ShopinBitSetting, + $$ShopinBitSettingsTableFilterComposer, + $$ShopinBitSettingsTableOrderingComposer, + $$ShopinBitSettingsTableAnnotationComposer, + $$ShopinBitSettingsTableCreateCompanionBuilder, + $$ShopinBitSettingsTableUpdateCompanionBuilder, + ( + ShopinBitSetting, + BaseReferences< + _$SharedDatabase, + $ShopinBitSettingsTable, + ShopinBitSetting + >, + ), + ShopinBitSetting, + PrefetchHooks Function() + > { + $$ShopinBitSettingsTableTableManager( + _$SharedDatabase db, + $ShopinBitSettingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ShopinBitSettingsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ShopinBitSettingsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ShopinBitSettingsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value guidelinesAccepted = const Value.absent(), + Value setupComplete = const Value.absent(), + Value displayName = const Value.absent(), + }) => ShopinBitSettingsCompanion( + id: id, + guidelinesAccepted: guidelinesAccepted, + setupComplete: setupComplete, + displayName: displayName, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + Value guidelinesAccepted = const Value.absent(), + Value setupComplete = const Value.absent(), + Value displayName = const Value.absent(), + }) => ShopinBitSettingsCompanion.insert( + id: id, + guidelinesAccepted: guidelinesAccepted, + setupComplete: setupComplete, + displayName: displayName, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$ShopinBitSettingsTableProcessedTableManager = + ProcessedTableManager< + _$SharedDatabase, + $ShopinBitSettingsTable, + ShopinBitSetting, + $$ShopinBitSettingsTableFilterComposer, + $$ShopinBitSettingsTableOrderingComposer, + $$ShopinBitSettingsTableAnnotationComposer, + $$ShopinBitSettingsTableCreateCompanionBuilder, + $$ShopinBitSettingsTableUpdateCompanionBuilder, + ( + ShopinBitSetting, + BaseReferences< + _$SharedDatabase, + $ShopinBitSettingsTable, + ShopinBitSetting + >, + ), + ShopinBitSetting, + PrefetchHooks Function() + >; + +class $SharedDatabaseManager { + final _$SharedDatabase _db; + $SharedDatabaseManager(this._db); + $$CakepayOrdersTableTableManager get cakepayOrders => + $$CakepayOrdersTableTableManager(_db, _db.cakepayOrders); + $$ShopinBitSettingsTableTableManager get shopinBitSettings => + $$ShopinBitSettingsTableTableManager(_db, _db.shopinBitSettings); +} 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/more_view/services_view.dart b/lib/pages/more_view/services_view.dart index aa4d7acdaa..210c45458a 100644 --- a/lib/pages/more_view/services_view.dart +++ b/lib/pages/more_view/services_view.dart @@ -1,10 +1,12 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../db/isar/main_db.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import '../../utilities/text_styles.dart'; @@ -14,23 +16,21 @@ import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; import '../shopinbit/shopinbit_settings_view.dart'; import '../shopinbit/shopinbit_setup_view.dart'; -import '../shopinbit/shopinbit_step_1.dart'; import '../shopinbit/shopinbit_step_2.dart'; import '../shopinbit/shopinbit_tickets_view.dart'; -class ServicesView extends StatefulWidget { +class ServicesView extends ConsumerStatefulWidget { const ServicesView({super.key}); static const String routeName = "/servicesView"; @override - State createState() => _ServicesViewState(); + ConsumerState createState() => _ServicesViewState(); } -class _ServicesViewState extends State { +class _ServicesViewState extends ConsumerState { Future _showOpenBrowserWarning(BuildContext context, String url) async { final uri = Uri.parse(url); final shouldContinue = await showDialog( @@ -69,7 +69,7 @@ class _ServicesViewState extends State { return shouldContinue ?? false; } - void _showShopDialog(BuildContext context) { + void _showShopDialog() { showDialog( context: context, barrierDismissible: true, @@ -142,12 +142,17 @@ class _ServicesViewState extends State { onPressed: () async { Navigator.of(dialogContext).pop(); final model = ShopInBitOrderModel(); - final service = ShopInBitService.instance; + final settings = await ref + .read(pSharedDrift) + .shopinBitSettingsDao + .getSettings(); - if (service.loadSetupComplete()) { + if (!mounted) return; + + if (settings.setupComplete) { // Returning user: pre-load display name, // skip Step 1, go to Step 2 - final savedName = service.loadDisplayName(); + final savedName = settings.displayName; if (savedName != null && savedName.isNotEmpty) { model.displayName = savedName; } @@ -303,7 +308,7 @@ class _ServicesViewState extends State { PrimaryButton( label: "Shop with ShopinBit", enabled: true, - onPressed: () => _showShopDialog(context), + onPressed: _showShopDialog, ), const SizedBox(height: 12), Builder( 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_car_fee_view.dart b/lib/pages/shopinbit/shopinbit_car_fee_view.dart index 7f691375d2..4f88893a77 100644 --- a/lib/pages/shopinbit/shopinbit_car_fee_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -3,12 +3,13 @@ import 'dart:convert'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../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 '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/address.dart'; import '../../services/shopinbit/src/models/car_research.dart'; import '../../themes/stack_colors.dart'; @@ -17,7 +18,6 @@ import '../../utilities/constants.dart'; import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; -import '../more_view/services_view.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog.dart'; @@ -25,10 +25,11 @@ import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_text_field.dart'; +import '../more_view/services_view.dart'; import 'shopinbit_car_research_payment_view.dart'; import 'shopinbit_step_2.dart'; -class ShopInBitCarFeeView extends StatefulWidget { +class ShopInBitCarFeeView extends ConsumerStatefulWidget { const ShopInBitCarFeeView({super.key, required this.model}); static const String routeName = "/shopInBitCarFee"; @@ -36,10 +37,11 @@ class ShopInBitCarFeeView extends StatefulWidget { final ShopInBitOrderModel model; @override - State createState() => _ShopInBitCarFeeViewState(); + ConsumerState createState() => + _ShopInBitCarFeeViewState(); } -class _ShopInBitCarFeeViewState extends State { +class _ShopInBitCarFeeViewState extends ConsumerState { late final TextEditingController _nameController; late final TextEditingController _streetController; late final TextEditingController _cityController; @@ -179,7 +181,7 @@ class _ShopInBitCarFeeViewState extends State { Future _fetchCountries() async { setState(() => _loadingCountries = true); try { - final resp = await ShopInBitService.instance.client.getCountries(); + final resp = await ref.read(pShopinBitService).client.getCountries(); if (resp.hasError || resp.value == null) return; _countries = resp.value!; if (_selectedCountryIso != null && @@ -209,7 +211,7 @@ class _ShopInBitCarFeeViewState extends State { if (_submitting) return; setState(() => _submitting = true); try { - await ShopInBitService.instance.ensureCustomerKey(); + await ref.read(pShopinBitService).ensureCustomerKey(); // Delivery address (always provided) final deliveryName = _splitFullName(_nameController.text); @@ -221,7 +223,8 @@ class _ShopInBitCarFeeViewState extends State { country: _selectedCountryIso!, ); - // Billing address: use separate billing fields if different, else use delivery + // Billing address: use separate billing fields if different, + // else use delivery final Address billing; if (_differentBilling) { final billingName = _splitFullName(_billingNameController.text); @@ -244,7 +247,9 @@ class _ShopInBitCarFeeViewState extends State { ); } - final resp = await ShopInBitService.instance.client + final resp = await ref + .read(pShopinBitService) + .client .createCarResearchInvoice(billing: billing); if (resp.hasError || resp.value == null) { @@ -264,7 +269,8 @@ class _ShopInBitCarFeeViewState extends State { final invoice = resp.value!; // Persist pending state so the user can resume if they close the dialog. - // Sentinel ticketId; unique-replace index ensures at most one pending record. + // Sentinel ticketId; unique-replace index ensures at most one pending + // record. widget.model.ticketId = "pending-car-research"; widget.model.carResearchInvoiceId = invoice.btcpayInvoice; widget.model.isPendingPayment = true; @@ -328,7 +334,9 @@ class _ShopInBitCarFeeViewState extends State { // a fee field. Today the endpoint returns only {status, additional}, so // we source the displayed amount from the BIP21 payment URIs instead. try { - final resp = await ShopInBitService.instance.client + final resp = await ref + .read(pShopinBitService) + .client .getCarResearchInvoiceStatus(invoice.btcpayInvoice); if (resp.hasError || resp.value == null) { Logging.instance.i( @@ -471,9 +479,12 @@ class _ShopInBitCarFeeViewState extends State { Assets.svg.chevronDown, width: 12, height: 6, - color: Theme.of( - context, - ).extension()!.textFieldActiveSearchIconRight, + colorFilter: ColorFilter.mode( + Theme.of( + context, + ).extension()!.textFieldActiveSearchIconRight, + .srcIn, + ), ), ), ), diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart index 0073ee831e..38db1c73b8 100644 --- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -10,9 +10,9 @@ import '../../db/isar/main_db.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; import '../../services/shopinbit/src/models/car_research.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/address_utils.dart'; @@ -22,16 +22,16 @@ import '../../utilities/logger.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; import '../../wallets/crypto_currency/crypto_currency.dart'; -import '../more_view/services_view.dart'; import '../../widgets/background.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; -import '../../widgets/stack_dialog.dart'; import '../../widgets/qr.dart'; import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../more_view/services_view.dart'; import 'shopinbit_order_created.dart'; import 'shopinbit_send_from_view.dart'; import 'shopinbit_tickets_view.dart'; @@ -240,7 +240,8 @@ class _ShopInBitCarResearchPaymentViewState showFloatingFlushBar( type: FlushBarType.info, message: - "Payment not yet confirmed. Please wait a moment and try again.", + "Payment not yet confirmed. " + "Please wait a moment and try again.", context: context, ), ); @@ -345,7 +346,9 @@ class _ShopInBitCarResearchPaymentViewState Future _pollStatus() async { try { - final resp = await ShopInBitService.instance.client + final resp = await ref + .read(pShopinBitService) + .client .getCarResearchInvoiceStatus(widget.invoice.btcpayInvoice); if (resp.hasError || resp.value == null) { if (mounted) { @@ -394,8 +397,9 @@ class _ShopInBitCarResearchPaymentViewState if (_flowState == _PaymentFlowState.loggingPayment || _flowState == _PaymentFlowState.creatingRequest || _flowState == _PaymentFlowState.complete || - _flowState == _PaymentFlowState.error) + _flowState == _PaymentFlowState.error) { return; + } // Skip logCarResearchPayment if the fee was already logged. final existingFeeTicket = widget.model.feeTicketNumber; @@ -426,17 +430,22 @@ class _ShopInBitCarResearchPaymentViewState setState(() => _flowState = _PaymentFlowState.creatingRequest); _pollTimer?.cancel(); try { - final customerKey = await ShopInBitService.instance.ensureCustomerKey(); + final customerKey = await ref + .read(pShopinBitService) + .ensureCustomerKey(); final comment = "${widget.model.requestDescription}\n\n" "The Client paid the car research fee (#$existingFeeTicket)"; - final reqResp = await ShopInBitService.instance.client.createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); + final reqResp = await ref + .read(pShopinBitService) + .client + .createRequest( + customerPseudonym: widget.model.displayName, + externalCustomerKey: customerKey, + serviceType: "car", + comment: comment, + deliveryCountry: widget.model.deliveryCountry, + ); if (reqResp.hasError || reqResp.value == null) { if (mounted) { setState(() => _flowState = _PaymentFlowState.error); @@ -517,7 +526,9 @@ class _ShopInBitCarResearchPaymentViewState _pollTimer?.cancel(); try { - final logResp = await ShopInBitService.instance.client + final logResp = await ref + .read(pShopinBitService) + .client .logCarResearchPayment(widget.invoice.btcpayInvoice); if (logResp.hasError || logResp.value == null) { if (mounted) { @@ -535,7 +546,8 @@ class _ShopInBitCarResearchPaymentViewState final feeResult = logResp.value!; - // Persist feeTicketNumber on the existing model (a new DB row creates a spurious list entry). + // Persist feeTicketNumber on the existing model (a new DB row creates a + // spurious list entry). widget.model.feeTicketNumber = feeResult.ticketNumber; widget.model.needsCreateRequest = true; await MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket()); @@ -543,18 +555,21 @@ class _ShopInBitCarResearchPaymentViewState if (!mounted) return; setState(() => _flowState = _PaymentFlowState.creatingRequest); - final customerKey = await ShopInBitService.instance.ensureCustomerKey(); + final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); final comment = "${widget.model.requestDescription}\n\n" "The Client paid the car research fee (#${feeResult.ticketNumber})"; - final reqResp = await ShopInBitService.instance.client.createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); + final reqResp = await ref + .read(pShopinBitService) + .client + .createRequest( + customerPseudonym: widget.model.displayName, + externalCustomerKey: customerKey, + serviceType: "car", + comment: comment, + deliveryCountry: widget.model.deliveryCountry, + ); if (reqResp.hasError || reqResp.value == null) { // createRequest failed: fee receipt already persisted, show retry @@ -645,13 +660,16 @@ class _ShopInBitCarResearchPaymentViewState "${widget.model.requestDescription}\n\n" "The Client paid the car research fee (#$feeTicketNumber)"; - final reqResp = await ShopInBitService.instance.client.createRequest( - customerPseudonym: widget.model.displayName, - externalCustomerKey: customerKey, - serviceType: "car", - comment: comment, - deliveryCountry: widget.model.deliveryCountry, - ); + final reqResp = await ref + .read(pShopinBitService) + .client + .createRequest( + customerPseudonym: widget.model.displayName, + externalCustomerKey: customerKey, + serviceType: "car", + comment: comment, + deliveryCountry: widget.model.deliveryCountry, + ); if (reqResp.hasError || reqResp.value == null) { if (mounted) { diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart index ace2f3d37d..98946c14dd 100644 --- a/lib/pages/shopinbit/shopinbit_offer_view.dart +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; @@ -11,10 +12,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/loading_indicator.dart'; import '../../widgets/rounded_white_container.dart'; import 'shopinbit_shipping_view.dart'; -class ShopInBitOfferView extends StatefulWidget { +class ShopInBitOfferView extends ConsumerStatefulWidget { const ShopInBitOfferView({super.key, required this.model}); static const String routeName = "/shopInBitOffer"; @@ -22,10 +24,10 @@ class ShopInBitOfferView extends StatefulWidget { final ShopInBitOrderModel model; @override - State createState() => _ShopInBitOfferViewState(); + ConsumerState createState() => _ShopInBitOfferViewState(); } -class _ShopInBitOfferViewState extends State { +class _ShopInBitOfferViewState extends ConsumerState { bool _loading = false; @override @@ -39,9 +41,10 @@ class _ShopInBitOfferViewState extends State { Future _loadOffer() async { setState(() => _loading = true); try { - final resp = await ShopInBitService.instance.client.getTicketFull( - widget.model.apiTicketId, - ); + final resp = await ref + .read(pShopinBitService) + .client + .getTicketFull(widget.model.apiTicketId); if (!resp.hasError && resp.value != null) { final t = resp.value!; widget.model.setOffer( @@ -154,14 +157,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 +182,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 +220,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..eea1f437c8 100644 --- a/lib/pages/shopinbit/shopinbit_payment_view.dart +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -11,9 +11,9 @@ import '../../app_config.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; import '../../services/shopinbit/src/models/payment.dart'; import '../../themes/coin_icon_provider.dart'; import '../../themes/stack_colors.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'; @@ -107,9 +108,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { Future _pollPayment() async { try { - final resp = await ShopInBitService.instance.client.getPayment( - widget.model.apiTicketId, - ); + final resp = await ref + .read(pShopinBitService) + .client + .getPayment(widget.model.apiTicketId); if (!resp.hasError && resp.value != null && mounted) { setState(() => _applyPaymentInfo(resp.value!)); if (_isTerminal) { @@ -122,9 +124,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { Future _loadPayment() async { setState(() => _loading = true); try { - final resp = await ShopInBitService.instance.client.getPayment( - widget.model.apiTicketId, - ); + final resp = await ref + .read(pShopinBitService) + .client + .getPayment(widget.model.apiTicketId); if (!resp.hasError && resp.value != null) { _applyPaymentInfo(resp.value!); } @@ -141,10 +144,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { Future _refreshInvoice() async { setState(() => _loading = true); try { - final resp = await ShopInBitService.instance.client.getPayment( - widget.model.apiTicketId, - retry: true, - ); + final resp = await ref + .read(pShopinBitService) + .client + .getPayment(widget.model.apiTicketId, retry: true); if (!resp.hasError && resp.value != null) { _applyPaymentInfo(resp.value!); } @@ -159,9 +162,10 @@ class _ShopInBitPaymentViewState extends ConsumerState { _pollTimer?.cancel(); setState(() => _loading = true); try { - final resp = await ShopInBitService.instance.client.getPayment( - widget.model.apiTicketId, - ); + final resp = await ref + .read(pShopinBitService) + .client + .getPayment(widget.model.apiTicketId); if (!resp.hasError && resp.value != null && mounted) { setState(() => _applyPaymentInfo(resp.value!)); final status = resp.value!.status; @@ -471,14 +475,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 +733,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { child: Stack( children: [ SingleChildScrollView(child: content), - if (_loading) loadingOverlay, + if (_loading) const LoadingIndicator(width: 24, height: 24), ], ), ), @@ -779,7 +775,7 @@ class _ShopInBitPaymentViewState extends ConsumerState { ), ), ), - if (_loading) loadingOverlay, + if (_loading) const LoadingIndicator(width: 24, height: 24), ], ); }, diff --git a/lib/pages/shopinbit/shopinbit_send_from_view.dart b/lib/pages/shopinbit/shopinbit_send_from_view.dart index 0060cf596f..d2c08e26b3 100644 --- a/lib/pages/shopinbit/shopinbit_send_from_view.dart +++ b/lib/pages/shopinbit/shopinbit_send_from_view.dart @@ -9,6 +9,8 @@ import '../../app_config.dart'; import '../../models/isar/models/blockchain_data/address.dart'; import '../../models/isar/models/ethereum/eth_contract.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../pages_desktop_specific/desktop_home_view.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; import '../../themes/coin_icon_provider.dart'; @@ -25,7 +27,6 @@ import '../../wallets/crypto_currency/crypto_currency.dart'; import '../../wallets/isar/providers/eth/token_balance_provider.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; import '../../wallets/models/tx_data.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; import '../../wallets/wallet/impl/ethereum_wallet.dart'; import '../../wallets/wallet/intermediate/external_wallet.dart'; import '../../wallets/wallet/wallet.dart'; @@ -36,7 +37,6 @@ import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_dialog_close_button.dart'; import '../../widgets/rounded_white_container.dart'; import '../../widgets/stack_dialog.dart'; -import '../../pages_desktop_specific/desktop_home_view.dart'; import '../home_view/home_view.dart'; import '../send_view/sub_widgets/building_transaction_dialog.dart'; import 'shopinbit_confirm_send_view.dart'; @@ -250,7 +250,7 @@ class _ShopInBitSendFromCardState extends ConsumerState { Amount? sendAmount = amount; if (sendAmount == null) { - if (ShopInBitService.instance.client.sandbox) { + if (ref.read(pShopinBitService).client.sandbox) { sendAmount = Amount( rawValue: BigInt.from(10000), fractionDigits: fractionDigits, diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart index 0682b74e6e..50b62043da 100644 --- a/lib/pages/shopinbit/shopinbit_settings_view.dart +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -6,18 +6,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../notifications/show_flush_bar.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; +import '../../providers/providers.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/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_container.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'; class ShopInBitSettingsView extends ConsumerStatefulWidget { const ShopInBitSettingsView({super.key}); @@ -31,11 +35,8 @@ class ShopInBitSettingsView extends ConsumerStatefulWidget { class _ShopInBitSettingsViewState extends ConsumerState { final _manualKeyController = TextEditingController(); - final _manualKeyFocusNode = FocusNode(); final _verifyKeyController = TextEditingController(); - final _verifyKeyFocusNode = FocusNode(); - late final TextEditingController _displayNameController; - late final FocusNode _displayNameFocusNode; + final _displayNameController = TextEditingController(); String? _currentKey; bool _loading = false; @@ -44,20 +45,29 @@ class _ShopInBitSettingsViewState extends ConsumerState { @override void initState() { super.initState(); - _currentKey = ShopInBitService.instance.loadCustomerKey(); - final savedName = ShopInBitService.instance.loadDisplayName(); - _displayNameController = TextEditingController(text: savedName ?? ''); - _displayNameFocusNode = FocusNode(); + + // not the greatest solution but its the least invasive with the current + // ui code impl + () async { + final settings = await ref + .read(pSharedDrift) + .shopinBitSettingsDao + .getSettings(); + final key = await ref.read(pShopinBitService).loadCustomerKey(); + if (mounted) { + setState(() { + _currentKey = key; + _displayNameController.text = settings.displayName ?? ""; + }); + } + }(); } @override void dispose() { _manualKeyController.dispose(); - _manualKeyFocusNode.dispose(); _verifyKeyController.dispose(); - _verifyKeyFocusNode.dispose(); _displayNameController.dispose(); - _displayNameFocusNode.dispose(); super.dispose(); } @@ -66,7 +76,7 @@ class _ShopInBitSettingsViewState extends ConsumerState { if (name.isEmpty) return; setState(() => _savingName = true); try { - await ShopInBitService.instance.setDisplayName(name); + await ref.read(pSharedDrift).shopinBitSettingsDao.setDisplayName(name); if (mounted) { unawaited( showFloatingFlushBar( @@ -91,11 +101,11 @@ class _ShopInBitSettingsViewState extends ConsumerState { try { final String key; if (_currentKey != null) { - final resp = await ShopInBitService.instance.client.generateKey(); + final resp = await ref.read(pShopinBitService).client.generateKey(); key = resp.valueOrThrow; - await ShopInBitService.instance.setCustomerKey(key); + await ref.read(pShopinBitService).setCustomerKey(key); } else { - key = await ShopInBitService.instance.ensureCustomerKey(); + key = await ref.read(pShopinBitService).ensureCustomerKey(); } setState(() => _currentKey = key); if (mounted) { @@ -133,7 +143,7 @@ class _ShopInBitSettingsViewState extends ConsumerState { setState(() => _loading = true); try { - await ShopInBitService.instance.setCustomerKey(newKey); + await ref.read(pShopinBitService).setCustomerKey(newKey); setState(() { _currentKey = newKey; _manualKeyController.clear(); @@ -165,79 +175,165 @@ class _ShopInBitSettingsViewState extends ConsumerState { Future _showChangeWarning() async { final result = await showDialog( context: context, - barrierDismissible: true, - builder: (context) => StackDialogBase( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Save your current key", - style: STextStyles.pageTitleH2(context), - ), - const SizedBox(height: 8), - SelectableText( - "Your current customer key is:", - style: STextStyles.smallMed14(context), - ), - const SizedBox(height: 8), - RoundedContainer( - color: Theme.of( - context, - ).extension()!.warningBackground, - child: SelectableText( - _currentKey!, - style: STextStyles.smallMed14(context).copyWith( - color: Theme.of( - context, - ).extension()!.warningForeground, - ), - ), - ), - const SizedBox(height: 8), - SelectableText( - "Changing your key will disconnect you from " - "existing ShopinBit conversations. Make sure " - "you have saved your current key before " - "proceeding.", - style: STextStyles.smallMed14(context), - ), - const SizedBox(height: 20), - Row( + builder: (context) { + // TODO: this conditional can probably be merged when we have time + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 550, + maxHeight: double.infinity, + child: Column( children: [ - Expanded( - child: TextButton( - style: Theme.of(context) - .extension()! - .getSecondaryEnabledButtonStyle(context), - onPressed: () => Navigator.of(context).pop(false), - child: Text( - "Cancel", - style: STextStyles.button(context).copyWith( - color: Theme.of( - context, - ).extension()!.accentColorDark, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(32), + child: Text( + "Save your current key", + style: STextStyles.desktopH3(context), ), ), + const DesktopDialogCloseButton(), + ], + ), + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Your current customer key is:", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + const SizedBox(height: 8), + RoundedWhiteContainer( + borderColor: Theme.of( + context, + ).extension()!.textSubtitle6, + child: SelectableText( + _currentKey!, + style: STextStyles.desktopTextSmall(context), + ), + ), + const SizedBox(height: 16), + Text( + "Changing your key will disconnect you from " + "existing ShopinBit requests. Make sure " + "you have saved your current key before " + "proceeding.", + style: STextStyles.desktopTextExtraExtraSmall(context), + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(false), + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "I saved my key", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(null), + ), + ), + ], + ), + ], ), ), - const SizedBox(width: 8), - Expanded( - child: TextButton( - style: Theme.of(context) - .extension()! - .getPrimaryEnabledButtonStyle(context), - onPressed: () => Navigator.of(context).pop(null), - child: Text( - "I saved my key", - style: STextStyles.button(context), + ], + ), + ); + } else { + return StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Save your current key", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 8), + SelectableText( + "Your current customer key is:", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 8), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.warningBackground, + child: SelectableText( + _currentKey!, + style: STextStyles.smallMed14(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, ), ), ), + const SizedBox(height: 8), + SelectableText( + "Changing your key will disconnect you from " + "existing ShopinBit conversations. Make sure " + "you have saved your current key before " + "proceeding.", + style: STextStyles.smallMed14(context), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getSecondaryEnabledButtonStyle(context), + onPressed: () => Navigator.of(context).pop(false), + child: Text( + "Cancel", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextButton( + style: Theme.of(context) + .extension()! + .getPrimaryEnabledButtonStyle(context), + onPressed: () => Navigator.of(context).pop(null), + child: Text( + "I saved my key", + style: STextStyles.button(context), + ), + ), + ), + ], + ), ], ), - ], - ), - ), + ); + } + }, ); if (result == false || !mounted) return false; @@ -250,81 +346,150 @@ class _ShopInBitSettingsViewState extends ConsumerState { return showDialog( context: context, barrierDismissible: true, - builder: (ctx) { + builder: (context) { return StatefulBuilder( builder: (ctx, setDialogState) { final matches = _verifyKeyController.text.trim() == _currentKey; - return StackDialogBase( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Verify your key", style: STextStyles.pageTitleH2(ctx)), - const SizedBox(height: 8), - Text( - "Enter your current customer key to " - "confirm you have saved it.", - style: STextStyles.smallMed14(ctx), - ), - const SizedBox(height: 16), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, + + // TODO: this conditional can probably be merged when we have time + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 550, + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(32), + child: Text( + "Verify your key", + style: STextStyles.desktopH3(ctx), + ), + ), + const DesktopDialogCloseButton(), + ], ), - child: TextField( - controller: _verifyKeyController, - focusNode: _verifyKeyFocusNode, - style: STextStyles.field(ctx), - decoration: standardInputDecoration( - "Enter current key", - _verifyKeyFocusNode, - ctx, + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 32, + bottom: 32, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Enter your current customer key to " + "confirm you have saved it.", + style: STextStyles.desktopTextExtraExtraSmall(ctx), + ), + const SizedBox(height: 16), + AdaptiveTextField( + labelText: "Enter current key", + controller: _verifyKeyController, + onChangedComprehensive: (_) => + setDialogState(() {}), + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: () => Navigator.of( + ctx, + rootNavigator: true, + ).pop(false), + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Confirm", + buttonHeight: ButtonHeight.l, + enabled: matches, + onPressed: () => Navigator.of( + ctx, + rootNavigator: true, + ).pop(true), + ), + ), + ], + ), + ], ), - onChanged: (_) => setDialogState(() {}), ), - ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: TextButton( - style: Theme.of(ctx) - .extension()! - .getSecondaryEnabledButtonStyle(ctx), - onPressed: () => Navigator.of(ctx).pop(false), - child: Text( - "Cancel", - style: STextStyles.button(ctx).copyWith( - color: Theme.of( - ctx, - ).extension()!.accentColorDark, + ], + ), + ); + } else { + return StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Verify your key", + style: STextStyles.pageTitleH2(ctx), + ), + const SizedBox(height: 8), + Text( + "Enter your current customer key to " + "confirm you have saved it.", + style: STextStyles.smallMed14(ctx), + ), + const SizedBox(height: 16), + AdaptiveTextField( + labelText: "Enter current key", + controller: _verifyKeyController, + onChangedComprehensive: (_) => setDialogState(() {}), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TextButton( + style: Theme.of(ctx) + .extension()! + .getSecondaryEnabledButtonStyle(ctx), + onPressed: () => Navigator.of(ctx).pop(false), + child: Text( + "Cancel", + style: STextStyles.button(ctx).copyWith( + color: Theme.of( + ctx, + ).extension()!.accentColorDark, + ), ), ), ), - ), - const SizedBox(width: 8), - Expanded( - child: TextButton( - style: matches - ? Theme.of(ctx) - .extension()! - .getPrimaryEnabledButtonStyle(ctx) - : Theme.of(ctx) - .extension()! - .getPrimaryDisabledButtonStyle(ctx), - onPressed: matches - ? () => Navigator.of(ctx).pop(true) - : null, - child: Text( - "Confirm", - style: STextStyles.button(ctx), + const SizedBox(width: 8), + Expanded( + child: TextButton( + style: matches + ? Theme.of(ctx) + .extension()! + .getPrimaryEnabledButtonStyle(ctx) + : Theme.of(ctx) + .extension()! + .getPrimaryDisabledButtonStyle(ctx), + onPressed: matches + ? () => Navigator.of(ctx).pop(true) + : null, + child: Text( + "Confirm", + style: STextStyles.button(ctx), + ), ), ), - ), - ], - ), - ], - ), - ); + ], + ), + ], + ), + ); + } }, ); }, @@ -333,213 +498,388 @@ class _ShopInBitSettingsViewState extends ConsumerState { @override Widget build(BuildContext context) { - 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.only(left: 12, top: 12, right: 12), - child: SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight - 24, + // TODO: this conditional can probably be merged when we have time + if (Util.isDesktop) { + return SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(right: 30), + child: RoundedWhiteContainer( + radiusMultiplier: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: SvgPicture.asset( + Assets.svg.key, + width: 48, + height: 48, + ), ), - child: IntrinsicHeight( - child: Padding( - padding: const EdgeInsets.all(4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Customer Key", - style: STextStyles.titleBold12(context), + Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Customer Key", + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(height: 16), + Text( + "Your customer key identifies you to ShopinBit. " + "Save it to restore access to your conversations " + "on another device. If you change it, you will " + "lose access to existing conversations.", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 20), + if (_currentKey != null) ...[ + Text( + "Current key", + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark3, ), - const SizedBox(height: 8), - Text( - "Your customer key identifies you " - "to ShopinBit. Save it to restore " - "access to your conversations on " - "another device. If you change it, " - "you will lose access to existing " - "conversations.", - style: STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 8), + Row( + children: [ + SelectableText( + _currentKey!, + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () async { + await Clipboard.setData( + ClipboardData(text: _currentKey!), + ); + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Key copied to clipboard", + context: context, + ), + ); + } + }, + child: SvgPicture.asset( + Assets.svg.copy, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.textDark3, ), - const SizedBox(height: 16), - if (_currentKey != null) ...[ - RoundedContainer( - color: Theme.of(context) - .extension()! - .textFieldDefaultBG, - child: Row( - children: [ - Expanded( - child: SelectableText( - _currentKey!, - style: STextStyles.field(context), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: () async { - await Clipboard.setData( - ClipboardData( - text: _currentKey!, + ), + ], + ), + const SizedBox(height: 20), + ] else + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + "No key set", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ), + PrimaryButton( + width: 210, + buttonHeight: ButtonHeight.m, + enabled: !_loading, + label: _currentKey == null + ? "Generate key" + : "Generate new key", + onPressed: _generate, + ), + const SizedBox(height: 20), + Text( + "Restore key", + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(height: 8), + Text( + "Enter a previously saved customer key to " + "restore access to your ShopinBit " + "conversations.", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: 512, + child: AdaptiveTextField( + labelText: "Enter customer key", + controller: _manualKeyController, + onChangedComprehensive: (_) => setState(() {}), + ), + ), + const SizedBox(height: 16), + PrimaryButton( + width: 210, + buttonHeight: ButtonHeight.m, + enabled: + !_loading && + _manualKeyController.text.trim().isNotEmpty, + label: "Set key", + onPressed: _setManualKey, + ), + const SizedBox(height: 20), + Text( + "Display Name", + style: STextStyles.desktopTextSmall(context), + ), + const SizedBox(height: 8), + Text( + "The name ShopinBit staff will see " + "when communicating with you.", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: 512, + child: AdaptiveTextField( + labelText: "Display name", + controller: _displayNameController, + onChangedComprehensive: (_) => setState(() {}), + ), + ), + const SizedBox(height: 16), + PrimaryButton( + width: 210, + buttonHeight: ButtonHeight.m, + enabled: + !_savingName && + _displayNameController.text.trim().isNotEmpty, + label: "Save", + onPressed: _saveDisplayName, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } else { + 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.only(left: 12, top: 12, right: 12), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 24, + ), + child: IntrinsicHeight( + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Customer Key", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 8), + Text( + "Your customer key identifies you " + "to ShopinBit. Save it to restore " + "access to your conversations on " + "another device. If you change it, " + "you will lose access to existing " + "conversations.", + style: STextStyles.itemSubtitle12( + context, + ), + ), + const SizedBox(height: 16), + if (_currentKey != null) ...[ + RoundedContainer( + color: Theme.of(context) + .extension()! + .textFieldDefaultBG, + child: Row( + children: [ + Expanded( + child: SelectableText( + _currentKey!, + style: STextStyles.field( + context, ), - ); - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: - "Key copied to clipboard", - context: context, + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () async { + await Clipboard.setData( + ClipboardData( + text: _currentKey!, ), ); - } - }, - child: SvgPicture.asset( - Assets.svg.copy, - width: 20, - height: 20, - color: Theme.of(context) - .extension()! - .textDark3, + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: + "Key copied to clipboard", + context: context, + ), + ); + } + }, + child: SvgPicture.asset( + Assets.svg.copy, + width: 20, + height: 20, + color: Theme.of(context) + .extension()! + .textDark3, + ), ), - ), - ], + ], + ), ), + ] else + Text( + "No key set", + style: STextStyles.itemSubtitle( + context, + ), + ), + const SizedBox(height: 16), + PrimaryButton( + label: _currentKey == null + ? "Generate key" + : "Generate new key", + enabled: !_loading, + onPressed: _generate, ), - ] else - Text( - "No key set", - style: STextStyles.itemSubtitle(context), - ), - const SizedBox(height: 16), - PrimaryButton( - label: _currentKey == null - ? "Generate key" - : "Generate new key", - enabled: !_loading, - onPressed: _generate, - ), - ], + ], + ), ), - ), - const SizedBox(height: 12), - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Restore key", - style: STextStyles.titleBold12(context), - ), - const SizedBox(height: 8), - Text( - "Enter a previously saved customer " - "key to restore access to your " - "ShopinBit conversations.", - style: STextStyles.itemSubtitle12(context), - ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, + const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Restore key", + style: STextStyles.titleBold12(context), ), - child: TextField( - controller: _manualKeyController, - focusNode: _manualKeyFocusNode, - style: STextStyles.field(context), - decoration: standardInputDecoration( - "Enter customer key", - _manualKeyFocusNode, + const SizedBox(height: 8), + Text( + "Enter a previously saved customer " + "key to restore access to your " + "ShopinBit conversations.", + style: STextStyles.itemSubtitle12( context, ), - onChanged: (_) => setState(() {}), ), - ), - const SizedBox(height: 12), - PrimaryButton( - label: "Set key", - enabled: - !_loading && - _manualKeyController.text - .trim() - .isNotEmpty, - onPressed: _setManualKey, - ), - ], + const SizedBox(height: 12), + AdaptiveTextField( + labelText: "Enter customer key", + controller: _manualKeyController, + onChangedComprehensive: (_) => + setState(() {}), + ), + const SizedBox(height: 12), + PrimaryButton( + label: "Set key", + enabled: + !_loading && + _manualKeyController.text + .trim() + .isNotEmpty, + onPressed: _setManualKey, + ), + ], + ), ), - ), - const SizedBox(height: 12), - RoundedWhiteContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Display Name", - style: STextStyles.titleBold12(context), - ), - const SizedBox(height: 8), - Text( - "The name ShopinBit staff will see " - "when communicating with you.", - style: STextStyles.itemSubtitle12(context), - ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, + const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Display Name", + style: STextStyles.titleBold12(context), ), - child: TextField( - controller: _displayNameController, - focusNode: _displayNameFocusNode, - style: STextStyles.field(context), - decoration: standardInputDecoration( - "Display name", - _displayNameFocusNode, + const SizedBox(height: 8), + Text( + "The name ShopinBit staff will see " + "when communicating with you.", + style: STextStyles.itemSubtitle12( context, ), - onChanged: (_) => setState(() {}), ), - ), - const SizedBox(height: 12), - PrimaryButton( - label: "Save", - enabled: - !_savingName && - _displayNameController.text - .trim() - .isNotEmpty, - onPressed: _saveDisplayName, - ), - ], + const SizedBox(height: 12), + AdaptiveTextField( + labelText: "Display name", + controller: _displayNameController, + onChangedComprehensive: (_) => + setState(() {}), + ), + const SizedBox(height: 12), + PrimaryButton( + label: "Save", + enabled: + !_savingName && + _displayNameController.text + .trim() + .isNotEmpty, + onPressed: _saveDisplayName, + ), + ], + ), ), - ), - const SizedBox(height: 12), - ], + const SizedBox(height: 12), + ], + ), ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), - ), - ); + ); + } } } diff --git a/lib/pages/shopinbit/shopinbit_setup_view.dart b/lib/pages/shopinbit/shopinbit_setup_view.dart index 5566d5320c..1ce525f258 100644 --- a/lib/pages/shopinbit/shopinbit_setup_view.dart +++ b/lib/pages/shopinbit/shopinbit_setup_view.dart @@ -1,20 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; import '../../notifications/show_flush_bar.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; +import '../../providers/db/drift_provider.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../themes/stack_colors.dart'; -import '../../utilities/constants.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/rounded_white_container.dart'; -import '../../widgets/stack_text_field.dart'; +import '../../widgets/textfields/adaptive_text_field.dart'; import 'shopinbit_step_2.dart'; -class ShopInBitSetupView extends StatefulWidget { +class ShopInBitSetupView extends ConsumerStatefulWidget { const ShopInBitSetupView({super.key, required this.model}); static const String routeName = "/shopInBitSetup"; @@ -22,44 +23,49 @@ class ShopInBitSetupView extends StatefulWidget { final ShopInBitOrderModel model; @override - State createState() => _ShopInBitSetupViewState(); + ConsumerState createState() => _ShopInBitSetupViewState(); } -class _ShopInBitSetupViewState extends State { +class _ShopInBitSetupViewState extends ConsumerState { late final Future _keyFuture; - late final TextEditingController _nameController; - late final FocusNode _nameFocusNode; + final TextEditingController _nameController = TextEditingController(); bool get _canContinue => _nameController.text.trim().isNotEmpty; @override void initState() { super.initState(); - _keyFuture = ShopInBitService.instance.ensureCustomerKey(); - final existingName = ShopInBitService.instance.loadDisplayName(); - _nameController = TextEditingController(text: existingName ?? ''); - _nameFocusNode = FocusNode(); + _keyFuture = ref.read(pShopinBitService).ensureCustomerKey(); - _nameFocusNode.addListener(() { - setState(() {}); - }); + // not the greatest solution but its the least invasive with the current + // ui code impl + () async { + final settings = await ref + .read(pSharedDrift) + .shopinBitSettingsDao + .getSettings(); + if (mounted) { + setState(() { + _nameController.text = settings.displayName ?? ""; + }); + } + }(); } @override void dispose() { _nameController.dispose(); - _nameFocusNode.dispose(); super.dispose(); } Future _completeSetup() async { final name = _nameController.text.trim(); widget.model.displayName = name; - await ShopInBitService.instance.setDisplayName(name); - await ShopInBitService.instance.setSetupComplete(true); + await ref.read(pSharedDrift).shopinBitSettingsDao.setDisplayName(name); + await ref.read(pSharedDrift).shopinBitSettingsDao.setSetupComplete(true); if (mounted) { - Navigator.of( + await Navigator.of( context, ).pushReplacementNamed(ShopInBitStep2.routeName, arguments: widget.model); } @@ -158,30 +164,12 @@ class _ShopInBitSetupViewState extends State { style: STextStyles.smallMed12(context), ), const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - controller: _nameController, - focusNode: _nameFocusNode, - autocorrect: false, - enableSuggestions: false, - onChanged: (_) => setState(() {}), - style: STextStyles.field(context), - decoration: - standardInputDecoration( - "Display name", - _nameFocusNode, - context, - ).copyWith( - filled: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), + AdaptiveTextField( + labelText: "Display name", + controller: _nameController, + autocorrect: false, + enableSuggestions: false, + onChangedComprehensive: (_) => setState(() {}), ), const Spacer(), PrimaryButton( diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart index 013b276da2..03ae923542 100644 --- a/lib/pages/shopinbit/shopinbit_shipping_view.dart +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -2,10 +2,11 @@ import 'dart:async'; import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/address.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; @@ -20,7 +21,7 @@ import '../../widgets/desktop/primary_button.dart'; import '../../widgets/stack_text_field.dart'; import 'shopinbit_payment_view.dart'; -class ShopInBitShippingView extends StatefulWidget { +class ShopInBitShippingView extends ConsumerStatefulWidget { const ShopInBitShippingView({super.key, required this.model}); static const String routeName = "/shopInBitShipping"; @@ -28,10 +29,11 @@ class ShopInBitShippingView extends StatefulWidget { final ShopInBitOrderModel model; @override - State createState() => _ShopInBitShippingViewState(); + ConsumerState createState() => + _ShopInBitShippingViewState(); } -class _ShopInBitShippingViewState extends State { +class _ShopInBitShippingViewState extends ConsumerState { late final TextEditingController _nameController; late final TextEditingController _streetController; late final TextEditingController _cityController; @@ -150,7 +152,7 @@ class _ShopInBitShippingViewState extends State { Future _fetchCountries() async { setState(() => _loadingCountries = true); try { - final resp = await ShopInBitService.instance.client.getCountries(); + final resp = await ref.read(pShopinBitService).client.getCountries(); if (resp.hasError || resp.value == null) return; _countries = resp.value!; if (_selectedCountryIso != null && @@ -205,18 +207,21 @@ class _ShopInBitShippingViewState extends State { ); } - final resp = await ShopInBitService.instance.client.submitAddress( - widget.model.apiTicketId, - shipping: Address( - firstName: firstName, - lastName: lastName, - street: street, - zip: postalCode, - city: city, - country: country, - ), - billing: billingAddress, - ); + final resp = await ref + .read(pShopinBitService) + .client + .submitAddress( + widget.model.apiTicketId, + shipping: Address( + firstName: firstName, + lastName: lastName, + street: street, + zip: postalCode, + city: city, + country: country, + ), + billing: billingAddress, + ); if (resp.hasError) { // Sandbox may fail here; continue anyway. 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..23403ce600 100644 --- a/lib/pages/shopinbit/shopinbit_step_2.dart +++ b/lib/pages/shopinbit/shopinbit_step_2.dart @@ -1,23 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; +import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; 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'; -class ShopInBitStep2 extends StatefulWidget { +class ShopInBitStep2 extends ConsumerStatefulWidget { const ShopInBitStep2({super.key, required this.model}); static const String routeName = "/shopInBitStep2"; @@ -25,12 +27,31 @@ class ShopInBitStep2 extends StatefulWidget { final ShopInBitOrderModel model; @override - State createState() => _ShopInBitStep2State(); + ConsumerState createState() => _ShopInBitStep2State(); } -class _ShopInBitStep2State extends State { +class _ShopInBitStep2State extends ConsumerState { ShopInBitCategory? _selected; + Future _continue() async { + widget.model.category = _selected; + final skipGuidelines = + (await ref.read(pSharedDrift).shopinBitSettingsDao.getSettings()) + .guidelinesAccepted; + if (!mounted) return; + + if (skipGuidelines) { + widget.model.guidelinesAccepted = true; + await Navigator.of( + context, + ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); + } else { + await Navigator.of( + context, + ).pushNamed(ShopInBitStep3.routeName, arguments: widget.model); + } + } + @override void initState() { super.initState(); @@ -39,257 +60,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..f84d487c2f 100644 --- a/lib/pages/shopinbit/shopinbit_step_3.dart +++ b/lib/pages/shopinbit/shopinbit_step_3.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; +import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; @@ -12,10 +13,9 @@ 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 { +class ShopInBitStep3 extends ConsumerStatefulWidget { const ShopInBitStep3({super.key, required this.model}); static const String routeName = "/shopInBitStep3"; @@ -23,10 +23,10 @@ class ShopInBitStep3 extends StatefulWidget { final ShopInBitOrderModel model; @override - State createState() => _ShopInBitStep3State(); + ConsumerState createState() => _ShopInBitStep3State(); } -class _ShopInBitStep3State extends State { +class _ShopInBitStep3State extends ConsumerState { bool _agreed = false; String _guidelinesText() { @@ -74,35 +74,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); - } + ref.read(pSharedDrift).shopinBitSettingsDao.setGuidelinesAccepted(true); + + Navigator.of( + context, + ).pushNamed(ShopInBitStep4.routeName, arguments: widget.model); } @override @@ -184,11 +163,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 +188,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..d8c8123d1d 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -2,11 +2,12 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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 '../../providers/global/shopin_bit_service_provider.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; import '../../utilities/util.dart'; @@ -15,10 +16,11 @@ 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'; -class ShopInBitTicketDetail extends StatefulWidget { +class ShopInBitTicketDetail extends ConsumerStatefulWidget { const ShopInBitTicketDetail({super.key, required this.model}); static const String routeName = "/shopInBitTicketDetail"; @@ -26,57 +28,13 @@ class ShopInBitTicketDetail extends StatefulWidget { final ShopInBitOrderModel model; @override - State createState() => _ShopInBitTicketDetailState(); + ConsumerState createState() => + _ShopInBitTicketDetailState(); } -class _ShopInBitTicketDetailState extends State { +class _ShopInBitTicketDetailState extends ConsumerState { 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; @@ -109,47 +67,42 @@ class _ShopInBitTicketDetailState extends State { Future _loadFromApi() async { setState(() => _loading = true); try { - final client = ShopInBitService.instance.client; + final client = ref.read(pShopinBitService).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 (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 (!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, + ); } } @@ -178,10 +131,10 @@ class _ShopInBitTicketDetailState extends State { try { if (widget.model.apiTicketId != 0) { - await ShopInBitService.instance.client.sendMessage( - widget.model.apiTicketId, - text, - ); + await ref + .read(pShopinBitService) + .client + .sendMessage(widget.model.apiTicketId, text); // Reload messages from API to get accurate state await _loadFromApi(); } @@ -201,18 +154,21 @@ class _ShopInBitTicketDetailState extends State { try { final model = widget.model; - final customerKey = await ShopInBitService.instance.ensureCustomerKey(); + final customerKey = await ref.read(pShopinBitService).ensureCustomerKey(); final comment = "${model.requestDescription}\n\n" "The Client paid the car research fee (#${model.feeTicketNumber})"; - final reqResp = await ShopInBitService.instance.client.createRequest( - customerPseudonym: model.displayName, - externalCustomerKey: customerKey, - serviceType: "car_research", - comment: comment, - deliveryCountry: model.deliveryCountry, - ); + final reqResp = await ref + .read(pShopinBitService) + .client + .createRequest( + customerPseudonym: model.displayName, + externalCustomerKey: customerKey, + serviceType: "car_research", + comment: comment, + deliveryCountry: model.deliveryCountry, + ); if (reqResp.hasError || reqResp.value == null) { if (mounted) { @@ -424,15 +380,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 +459,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..a7a14024ac 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -2,11 +2,12 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../db/isar/main_db.dart'; import '../../models/isar/models/shopinbit_ticket.dart'; import '../../models/shopinbit/shopinbit_order_model.dart'; -import '../../services/shopinbit/shopinbit_service.dart'; +import '../../providers/global/shopin_bit_service_provider.dart'; import '../../services/shopinbit/src/models/car_research.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; @@ -15,21 +16,23 @@ 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'; import 'shopinbit_ticket_detail.dart'; -class ShopInBitTicketsView extends StatefulWidget { +class ShopInBitTicketsView extends ConsumerStatefulWidget { const ShopInBitTicketsView({super.key}); static const String routeName = "/shopInBitTickets"; @override - State createState() => _ShopInBitTicketsViewState(); + ConsumerState createState() => + _ShopInBitTicketsViewState(); } -class _ShopInBitTicketsViewState extends State { +class _ShopInBitTicketsViewState extends ConsumerState { List _tickets = []; bool _syncing = false; ShopInBitTicket? _pendingTicket; @@ -111,7 +114,7 @@ class _ShopInBitTicketsViewState extends State { Future _syncFromApi() async { setState(() => _syncing = true); try { - final service = ShopInBitService.instance; + final service = ref.read(pShopinBitService); final customerKey = await service.ensureCustomerKey(); final resp = await service.client.getTicketsByCustomer(customerKey); @@ -122,7 +125,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 +174,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 +316,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 +335,10 @@ class _ShopInBitTicketsViewState extends State { context, )) .copyWith( - color: _statusColor( - context, - ticket.status, + color: ticket.status.getColor( + Theme.of( + context, + ).extension()!, ), ), ), @@ -446,14 +408,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..993a773d7d --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_concierge_form.dart @@ -0,0 +1,199 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../providers/global/shopin_bit_service_provider.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 ConsumerStatefulWidget { + const ShopInBitConciergeForm({super.key, required this.model}); + + final ShopInBitOrderModel model; + + @override + ConsumerState createState() => + _ShopInBitConciergeFormState(); +} + +class _ShopInBitConciergeFormState + extends ConsumerState { + 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, + ref.read(pShopinBitService), + ); + } 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..f0feb9db67 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_country_picker.dart @@ -0,0 +1,167 @@ +import "package:dropdown_button2/dropdown_button2.dart"; +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_svg/svg.dart"; + +import "../../../providers/global/shopin_bit_service_provider.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 ConsumerStatefulWidget { + const ShopInBitCountryPicker({ + super.key, + required this.selectedIso, + required this.onChanged, + this.hintText = "Delivery country", + }); + + final String? selectedIso; + final ValueChanged onChanged; + final String hintText; + + @override + ConsumerState createState() => + _ShopInBitCountryPickerState(); +} + +class _ShopInBitCountryPickerState + extends ConsumerState { + 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 ref.read(pShopinBitService).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..015e94d130 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_generic_form.dart @@ -0,0 +1,119 @@ +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../providers/global/shopin_bit_service_provider.dart"; +import "../../../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 ConsumerStatefulWidget { + const ShopInBitGenericForm({super.key, required this.model}); + + final ShopInBitOrderModel model; + + @override + ConsumerState createState() => + _ShopInBitGenericFormState(); +} + +class _ShopInBitGenericFormState extends ConsumerState { + late final TextEditingController _descriptionController; + final FocusNode _descriptionFocusNode = FocusNode(); + + String? _selectedCountryIso; + bool _privacyAccepted = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + _descriptionController = TextEditingController( + text: widget.model.requestDescription, + ); + _descriptionFocusNode.addListener(() => setState(() {})); + + if (widget.model.deliveryCountry.isNotEmpty) { + _selectedCountryIso = widget.model.deliveryCountry; + } + } + + @override + void dispose() { + _descriptionController.dispose(); + _descriptionFocusNode.dispose(); + super.dispose(); + } + + bool get _canContinue => + !_submitting && + _privacyAccepted && + _descriptionController.text.trim().isNotEmpty && + _selectedCountryIso != null; + + Future _submit() async { + setState(() => _submitting = true); + widget.model + ..requestDescription = _descriptionController.text.trim() + ..deliveryCountry = _selectedCountryIso!; + try { + await submitShopInBitRequest( + context, + widget.model, + ref.read(pShopinBitService), + ); + } 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..1f0e798b94 --- /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, + ShopInBitService service, +) async { + try { + final String customerKey = await service.ensureCustomerKey(); + + assert( + model.category != null, + "Step 4 reached with null category: Step 2 must set category before" + " reaching Step 4", + ); + + // API service_type: travel requests use "concierge" because the + // ShopinBit API routes both through the same concierge pipeline. + // Travel-specific details are captured in the structured comment field. + final String categoryStr = switch (model.category) { + ShopInBitCategory.concierge => "concierge", + ShopInBitCategory.travel => "concierge", + ShopInBitCategory.car => "car", + null => throw StateError("category must be non-null at Step 4 submit"), + }; + + final resp = await service.client.createRequest( + customerPseudonym: model.displayName, + externalCustomerKey: customerKey, + serviceType: categoryStr, + comment: model.requestDescription, + deliveryCountry: model.deliveryCountry, + ); + + 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..b84885a4e4 --- /dev/null +++ b/lib/pages/shopinbit/step_4_components/shopinbit_travel_form.dart @@ -0,0 +1,551 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; + +import "../../../models/shopinbit/shopinbit_order_model.dart"; +import "../../../providers/global/shopin_bit_service_provider.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 ConsumerStatefulWidget { + const ShopInBitTravelForm({super.key, required this.model}); + + final ShopInBitOrderModel model; + + @override + ConsumerState createState() => + _ShopInBitTravelFormState(); +} + +class _ShopInBitTravelFormState extends ConsumerState { + 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, + ref.read(pShopinBitService), + ); + } 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 79% 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..b42fb080ba 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,10 +10,10 @@ 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/db/drift_provider.dart'; import '../../../providers/desktop/current_desktop_menu_item.dart'; -import '../../../services/shopinbit/shopinbit_service.dart'; +import '../../../providers/global/shopin_bit_service_provider.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; import '../../../utilities/text_styles.dart'; @@ -21,11 +21,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,12 +91,16 @@ class _DesktopServicesViewState extends ConsumerState { return shouldContinue ?? false; } - void _showShopDialog(BuildContext context) async { - final service = ShopInBitService.instance; + Future _showShopDialog() async { + final dao = ref.read(pSharedDrift).shopinBitSettingsDao; + final settings = await dao.getSettings(); final model = ShopInBitOrderModel(); bool isFirstRun = false; - if (!service.loadSetupComplete()) { + if (!settings.setupComplete) { + // something went wrong + if (!mounted) return; + // First-time user: show setup. final completed = await showDialog( context: context, @@ -105,7 +111,7 @@ class _DesktopServicesViewState extends ConsumerState { isFirstRun = true; } else { // Returning user: restore display name. - final savedName = service.loadDisplayName(); + final savedName = settings.displayName; if (savedName != null && savedName.isNotEmpty) { model.displayName = savedName; } @@ -116,93 +122,12 @@ class _DesktopServicesViewState extends ConsumerState { 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 +135,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(() {}); } } @@ -314,7 +244,7 @@ class _DesktopServicesViewState extends ConsumerState { buttonHeight: ButtonHeight.m, enabled: true, label: "Shop with ShopinBit", - onPressed: () => _showShopDialog(context), + onPressed: _showShopDialog, ), const SizedBox(width: 16), Builder( @@ -371,29 +301,41 @@ class _DesktopServicesViewState extends ConsumerState { } } -class _ShopInBitDesktopSetupDialog extends StatefulWidget { +class _ShopInBitDesktopSetupDialog extends ConsumerStatefulWidget { const _ShopInBitDesktopSetupDialog({required this.model}); final ShopInBitOrderModel model; @override - State<_ShopInBitDesktopSetupDialog> createState() => + ConsumerState<_ShopInBitDesktopSetupDialog> createState() => _ShopInBitDesktopSetupDialogState(); } class _ShopInBitDesktopSetupDialogState - extends State<_ShopInBitDesktopSetupDialog> { + extends ConsumerState<_ShopInBitDesktopSetupDialog> { late final Future _keyFuture; - late final TextEditingController _nameController; + final TextEditingController _nameController = TextEditingController(); bool get _canContinue => _nameController.text.trim().isNotEmpty; @override void initState() { super.initState(); - _keyFuture = ShopInBitService.instance.ensureCustomerKey(); - final existingName = ShopInBitService.instance.loadDisplayName(); - _nameController = TextEditingController(text: existingName ?? ''); + _keyFuture = ref.read(pShopinBitService).ensureCustomerKey(); + + // not the greatest solution but its the least invasive with the current + // ui code impl + () async { + final settings = await ref + .read(pSharedDrift) + .shopinBitSettingsDao + .getSettings(); + if (mounted) { + setState(() { + _nameController.text = settings.displayName ?? ""; + }); + } + }(); } @override @@ -405,8 +347,9 @@ class _ShopInBitDesktopSetupDialogState Future _completeSetup() async { final name = _nameController.text.trim(); widget.model.displayName = name; - await ShopInBitService.instance.setDisplayName(name); - await ShopInBitService.instance.setSetupComplete(true); + final dao = ref.read(pSharedDrift).shopinBitSettingsDao; + await dao.setDisplayName(name); + await dao.setSetupComplete(true); if (mounted) { Navigator.of(context, rootNavigator: true).pop(true); } 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..b693f5d3fd --- /dev/null +++ b/lib/pages_desktop_specific/services/shopin_bit/sub_widgets/desktop_shopin_bit_first_run.dart @@ -0,0 +1,69 @@ +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: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("ShopinBit", style: STextStyles.desktopH2(context)), + const SizedBox(height: 24), + 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 SizedBox(height: 32), + 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).pushReplacementNamed( + ShopInBitStep1.routeName, + arguments: model, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages_desktop_specific/settings/desktop_settings_view.dart b/lib/pages_desktop_specific/settings/desktop_settings_view.dart index 4247186964..ee2c423b3d 100644 --- a/lib/pages_desktop_specific/settings/desktop_settings_view.dart +++ b/lib/pages_desktop_specific/settings/desktop_settings_view.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app_config.dart'; +import '../../pages/shopinbit/shopinbit_settings_view.dart'; import '../../providers/providers.dart'; import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; @@ -26,7 +27,6 @@ import 'settings_menu/currency_settings/currency_settings.dart'; import 'settings_menu/language_settings/language_settings.dart'; import 'settings_menu/nodes_settings.dart'; import 'settings_menu/security_settings.dart'; -import 'settings_menu/shopinbit_settings.dart'; import 'settings_menu/syncing_preferences_settings.dart'; import 'settings_menu/tor_settings/tor_settings.dart'; @@ -98,7 +98,7 @@ class _DesktopSettingsViewState extends ConsumerState { const Navigator( key: Key("settingsShopInBitDesktopKey"), onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: ShopInBitDesktopSettings.routeName, + initialRoute: ShopInBitSettingsView.routeName, ), //shopinbit ]; return DesktopScaffold( diff --git a/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart b/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart deleted file mode 100644 index 146243c96e..0000000000 --- a/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart +++ /dev/null @@ -1,550 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/svg.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 '../../../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'; - -class ShopInBitDesktopSettings extends ConsumerStatefulWidget { - const ShopInBitDesktopSettings({super.key}); - - static const String routeName = "/settingsMenuShopInBit"; - - @override - ConsumerState createState() => - _ShopInBitDesktopSettingsState(); -} - -class _ShopInBitDesktopSettingsState - extends ConsumerState { - final _manualKeyController = TextEditingController(); - final _manualKeyFocusNode = FocusNode(); - final _verifyKeyController = TextEditingController(); - final _verifyKeyFocusNode = FocusNode(); - late final TextEditingController _displayNameController; - late final FocusNode _displayNameFocusNode; - - String? _currentKey; - bool _loading = false; - bool _savingName = false; - - @override - void initState() { - super.initState(); - _currentKey = ShopInBitService.instance.loadCustomerKey(); - final savedName = ShopInBitService.instance.loadDisplayName(); - _displayNameController = TextEditingController(text: savedName ?? ''); - _displayNameFocusNode = FocusNode(); - } - - @override - void dispose() { - _manualKeyController.dispose(); - _manualKeyFocusNode.dispose(); - _verifyKeyController.dispose(); - _verifyKeyFocusNode.dispose(); - _displayNameController.dispose(); - _displayNameFocusNode.dispose(); - super.dispose(); - } - - Future _saveDisplayName() async { - final name = _displayNameController.text.trim(); - if (name.isEmpty) return; - setState(() => _savingName = true); - try { - await ShopInBitService.instance.setDisplayName(name); - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Display name updated", - context: context, - ), - ); - } - } finally { - if (mounted) setState(() => _savingName = false); - } - } - - Future _generate() async { - if (_currentKey != null) { - final proceed = await _showChangeWarning(); - if (proceed != true) return; - } - - setState(() => _loading = true); - try { - final String key; - if (_currentKey != null) { - final resp = await ShopInBitService.instance.client.generateKey(); - key = resp.valueOrThrow; - await ShopInBitService.instance.setCustomerKey(key); - } else { - key = await ShopInBitService.instance.ensureCustomerKey(); - } - setState(() => _currentKey = key); - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Customer key generated", - context: context, - ), - ); - } - } catch (e) { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to generate key: $e", - context: context, - ), - ); - } - } finally { - setState(() => _loading = false); - } - } - - Future _setManualKey() async { - final newKey = _manualKeyController.text.trim(); - if (newKey.isEmpty) return; - - if (_currentKey != null) { - final proceed = await _showChangeWarning(); - if (proceed != true) return; - } - - setState(() => _loading = true); - try { - await ShopInBitService.instance.setCustomerKey(newKey); - setState(() { - _currentKey = newKey; - _manualKeyController.clear(); - }); - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.success, - message: "Customer key set", - context: context, - ), - ); - } - } catch (e) { - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.warning, - message: "Failed to set key: $e", - context: context, - ), - ); - } - } finally { - setState(() => _loading = false); - } - } - - Future _showChangeWarning() async { - final result = await showDialog( - context: context, - barrierDismissible: true, - builder: (ctx) => DesktopDialog( - maxWidth: 550, - maxHeight: double.infinity, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.all(32), - child: Text( - "Save your current key", - style: STextStyles.desktopH3(ctx), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Padding( - padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Your current customer key is:", - style: STextStyles.desktopTextExtraExtraSmall(ctx), - ), - const SizedBox(height: 8), - RoundedWhiteContainer( - borderColor: Theme.of( - ctx, - ).extension()!.textSubtitle6, - child: SelectableText( - _currentKey!, - style: STextStyles.desktopTextSmall(ctx), - ), - ), - const SizedBox(height: 16), - Text( - "Changing your key will disconnect you from " - "existing ShopinBit requests. Make sure " - "you have saved your current key before " - "proceeding.", - style: STextStyles.desktopTextExtraExtraSmall(ctx), - ), - const SizedBox(height: 32), - Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - buttonHeight: ButtonHeight.l, - onPressed: () => - Navigator.of(ctx, rootNavigator: true).pop(false), - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - label: "I saved my key", - buttonHeight: ButtonHeight.l, - onPressed: () => - Navigator.of(ctx, rootNavigator: true).pop(null), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - - if (result == false || !mounted) return false; - - return _showVerifyDialog(); - } - - Future _showVerifyDialog() async { - _verifyKeyController.clear(); - return showDialog( - context: context, - barrierDismissible: true, - builder: (ctx) { - return StatefulBuilder( - builder: (ctx, setDialogState) { - final matches = _verifyKeyController.text.trim() == _currentKey; - return DesktopDialog( - maxWidth: 550, - maxHeight: double.infinity, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.all(32), - child: Text( - "Verify your key", - style: STextStyles.desktopH3(ctx), - ), - ), - const DesktopDialogCloseButton(), - ], - ), - Padding( - padding: const EdgeInsets.only( - left: 32, - right: 32, - bottom: 32, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Enter your current customer key to " - "confirm you have saved it.", - style: STextStyles.desktopTextExtraExtraSmall(ctx), - ), - const SizedBox(height: 16), - ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - controller: _verifyKeyController, - focusNode: _verifyKeyFocusNode, - style: STextStyles.field(ctx), - decoration: standardInputDecoration( - "Enter current key", - _verifyKeyFocusNode, - ctx, - ), - onChanged: (_) => setDialogState(() {}), - ), - ), - const SizedBox(height: 32), - Row( - children: [ - Expanded( - child: SecondaryButton( - label: "Cancel", - buttonHeight: ButtonHeight.l, - onPressed: () => Navigator.of( - ctx, - rootNavigator: true, - ).pop(false), - ), - ), - const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - label: "Confirm", - buttonHeight: ButtonHeight.l, - enabled: matches, - onPressed: () => Navigator.of( - ctx, - rootNavigator: true, - ).pop(true), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - }, - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(right: 30), - child: RoundedWhiteContainer( - radiusMultiplier: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: SvgPicture.asset( - Assets.svg.key, - width: 48, - height: 48, - ), - ), - Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Customer Key", - style: STextStyles.desktopTextSmall(context), - ), - const SizedBox(height: 16), - Text( - "Your customer key identifies you to ShopinBit. " - "Save it to restore access to your conversations " - "on another device. If you change it, you will " - "lose access to existing conversations.", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - const SizedBox(height: 20), - if (_currentKey != null) ...[ - Text( - "Current key", - style: - STextStyles.desktopTextExtraExtraSmall( - context, - ).copyWith( - color: Theme.of( - context, - ).extension()!.textDark3, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - SelectableText( - _currentKey!, - style: STextStyles.desktopTextSmall(context), - ), - const SizedBox(width: 12), - GestureDetector( - onTap: () async { - await Clipboard.setData( - ClipboardData(text: _currentKey!), - ); - if (mounted) { - unawaited( - showFloatingFlushBar( - type: FlushBarType.info, - message: "Key copied to clipboard", - context: context, - ), - ); - } - }, - child: SvgPicture.asset( - Assets.svg.copy, - width: 20, - height: 20, - color: Theme.of( - context, - ).extension()!.textDark3, - ), - ), - ], - ), - const SizedBox(height: 20), - ] else - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Text( - "No key set", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - ), - PrimaryButton( - width: 210, - buttonHeight: ButtonHeight.m, - enabled: !_loading, - label: _currentKey == null - ? "Generate key" - : "Generate new key", - onPressed: _generate, - ), - const SizedBox(height: 20), - Text( - "Restore key", - style: STextStyles.desktopTextSmall(context), - ), - const SizedBox(height: 8), - Text( - "Enter a previously saved customer key to " - "restore access to your ShopinBit " - "conversations.", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - const SizedBox(height: 16), - SizedBox( - width: 512, - child: ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - controller: _manualKeyController, - focusNode: _manualKeyFocusNode, - style: STextStyles.field(context), - decoration: standardInputDecoration( - "Enter customer key", - _manualKeyFocusNode, - context, - ), - onChanged: (_) => setState(() {}), - ), - ), - ), - const SizedBox(height: 16), - PrimaryButton( - width: 210, - buttonHeight: ButtonHeight.m, - enabled: - !_loading && - _manualKeyController.text.trim().isNotEmpty, - label: "Set key", - onPressed: _setManualKey, - ), - const SizedBox(height: 20), - Text( - "Display Name", - style: STextStyles.desktopTextSmall(context), - ), - const SizedBox(height: 8), - Text( - "The name ShopinBit staff will see " - "when communicating with you.", - style: STextStyles.desktopTextExtraExtraSmall( - context, - ), - ), - const SizedBox(height: 16), - SizedBox( - width: 512, - child: ClipRRect( - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), - child: TextField( - controller: _displayNameController, - focusNode: _displayNameFocusNode, - style: STextStyles.field(context), - decoration: standardInputDecoration( - "Display name", - _displayNameFocusNode, - context, - ), - onChanged: (_) => setState(() {}), - ), - ), - ), - const SizedBox(height: 16), - PrimaryButton( - width: 210, - buttonHeight: ButtonHeight.m, - enabled: - !_savingName && - _displayNameController.text.trim().isNotEmpty, - label: "Save", - onPressed: _saveDisplayName, - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/providers/db/drift_provider.dart b/lib/providers/db/drift_provider.dart index 658dd5bc7e..9f6ea4c35d 100644 --- a/lib/providers/db/drift_provider.dart +++ b/lib/providers/db/drift_provider.dart @@ -10,8 +10,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../db/drift/database.dart'; +import '../../db/drift/database.dart' show WalletDatabase, Drift; +import '../../db/drift/shared_database.dart' show SharedDrift; final pDrift = Provider.family( (ref, walletId) => Drift.get(walletId), ); + +final pSharedDrift = Provider((_) => SharedDrift.get()); diff --git a/lib/providers/global/shopin_bit_service_provider.dart b/lib/providers/global/shopin_bit_service_provider.dart new file mode 100644 index 0000000000..9f9c422e69 --- /dev/null +++ b/lib/providers/global/shopin_bit_service_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../services/shopinbit/shopinbit_service.dart'; +import 'secure_store_provider.dart'; + +final pShopinBitService = Provider( + (ref) => ShopInBitService()..ensureInitialized(ref.read(secureStoreProvider)), +); diff --git a/lib/route_generator.dart b/lib/route_generator.dart index 0f0fbbf58e..5aa23961d8 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'; @@ -254,7 +254,6 @@ import 'pages_desktop_specific/settings/settings_menu/desktop_support_view.dart' import 'pages_desktop_specific/settings/settings_menu/language_settings/language_settings.dart'; import 'pages_desktop_specific/settings/settings_menu/nodes_settings.dart'; import 'pages_desktop_specific/settings/settings_menu/security_settings.dart'; -import 'pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart'; import 'pages_desktop_specific/settings/settings_menu/syncing_preferences_settings.dart'; import 'pages_desktop_specific/settings/settings_menu/tor_settings/tor_settings.dart'; import 'pages_desktop_specific/spark_coins/spark_coins_view.dart'; @@ -2737,13 +2736,6 @@ class RouteGenerator { settings: RouteSettings(name: settings.name), ); - case ShopInBitDesktopSettings.routeName: - return getRoute( - shouldUseMaterialRoute: useMaterialPageRoute, - builder: (_) => const ShopInBitDesktopSettings(), - settings: RouteSettings(name: settings.name), - ); - case DesktopSupportView.routeName: return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, 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/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart index b0669433a4..d8d2cee319 100644 --- a/lib/services/shopinbit/shopinbit_service.dart +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -1,159 +1,63 @@ -import '../../db/hive/db.dart'; import '../../external_api_keys.dart'; +import '../../utilities/flutter_secure_storage_interface.dart'; import '../../utilities/logger.dart'; import 'src/client.dart'; -class ShopInBitService { - static final instance = ShopInBitService._(); - ShopInBitService._(); +const _kShopinBitCustomerKeyKeySecureStore = "shopinBitSecStoreCustomerKeyKey"; - ShopInBitClient? _client; - String? _customerKey; - bool? _guidelinesAccepted; - bool? _setupComplete; - String? _displayName; +class ShopInBitService { + SecureStorageInterface? _secureStorageInterface; - ShopInBitClient get client { - if (_client == null) { - _client = ShopInBitClient( - accessKey: kShopInBitAccessKey, - partnerSecret: kShopInBitPartnerSecret, - sandbox: true, - ); - // Pre-load customer key for ticket detail API calls. - loadCustomerKey(); + SecureStorageInterface get _secure { + if (_secureStorageInterface == null) { + throw Exception("Did you forget to call ShopInBitService.init()?"); } - return _client!; + return _secureStorageInterface!; } - String? get customerKey => _customerKey; + /// If secure storage was already set, this function will do nothing + void ensureInitialized(SecureStorageInterface secureStore) { + _secureStorageInterface ??= secureStore; + } - String? loadCustomerKey() { - if (_customerKey != null) return _customerKey; - _customerKey = - DB.instance.get( - boxName: DB.boxNamePrefs, - key: "shopInBitCustomerKey", - ) - as String?; - if (_customerKey != null) { - client.externalCustomerKey = _customerKey; - } - return _customerKey; + ShopInBitClient? _client; + ShopInBitClient get client { + _client ??= ShopInBitClient( + accessKey: kShopInBitAccessKey, + partnerSecret: kShopInBitPartnerSecret, + sandbox: true, + ); + return _client!; } + Future loadCustomerKey() => + _secure.read(key: _kShopinBitCustomerKeyKeySecureStore); + Future ensureCustomerKey() async { - if (_customerKey != null) return _customerKey!; - _customerKey = - DB.instance.get( - boxName: DB.boxNamePrefs, - key: "shopInBitCustomerKey", - ) - as String?; - if (_customerKey != null) { + final currentKey = await loadCustomerKey(); + + if (currentKey != null) { Logging.instance.t("ShopInBitService: loaded customer key from DB"); - client.externalCustomerKey = _customerKey; - return _customerKey!; + client.externalCustomerKey = currentKey; + return currentKey; } Logging.instance.i("ShopInBitService: generating new customer key"); final resp = await client.generateKey(); - _customerKey = resp.valueOrThrow; - client.externalCustomerKey = _customerKey; - await DB.instance.put( - boxName: DB.boxNamePrefs, - key: "shopInBitCustomerKey", - value: _customerKey, - ); + final customerKey = resp.valueOrThrow; + await setCustomerKey(customerKey); Logging.instance.i("ShopInBitService: customer key stored"); - return _customerKey!; + return customerKey; } Future setCustomerKey(String key) async { - _customerKey = key; + await _secure.write(key: _kShopinBitCustomerKeyKeySecureStore, value: key); client.externalCustomerKey = key; - await DB.instance.put( - boxName: DB.boxNamePrefs, - key: "shopInBitCustomerKey", - value: key, - ); - Logging.instance.i("ShopInBitService: customer key manually set"); + Logging.instance.i("ShopInBitService: customer key stored"); } Future clearCustomerKey() async { - _customerKey = null; client.externalCustomerKey = null; - await DB.instance.put( - boxName: DB.boxNamePrefs, - key: "shopInBitCustomerKey", - value: null, - ); + await _secure.delete(key: _kShopinBitCustomerKeyKeySecureStore); Logging.instance.i("ShopInBitService: customer key cleared"); } - - bool loadGuidelinesAccepted() { - if (_guidelinesAccepted != null) return _guidelinesAccepted!; - _guidelinesAccepted = - DB.instance.get( - boxName: DB.boxNamePrefs, - key: "shopInBitGuidelinesAccepted", - ) - as bool? ?? - false; - return _guidelinesAccepted!; - } - - Future setGuidelinesAccepted(bool accepted) async { - _guidelinesAccepted = accepted; - await DB.instance.put( - boxName: DB.boxNamePrefs, - key: "shopInBitGuidelinesAccepted", - value: accepted, - ); - Logging.instance.i( - "ShopInBitService: guidelines accepted set to $accepted", - ); - } - - bool loadSetupComplete() { - if (_setupComplete != null) return _setupComplete!; - _setupComplete = - DB.instance.get( - boxName: DB.boxNamePrefs, - key: "shopInBitSetupComplete", - ) - as bool? ?? - false; - return _setupComplete!; - } - - Future setSetupComplete(bool complete) async { - _setupComplete = complete; - await DB.instance.put( - boxName: DB.boxNamePrefs, - key: "shopInBitSetupComplete", - value: complete, - ); - Logging.instance.i("ShopInBitService: setup complete set to $complete"); - } - - String? loadDisplayName() { - if (_displayName != null) return _displayName; - _displayName = - DB.instance.get( - boxName: DB.boxNamePrefs, - key: "shopInBitDisplayName", - ) - as String?; - return _displayName; - } - - Future setDisplayName(String name) async { - _displayName = name; - await DB.instance.put( - boxName: DB.boxNamePrefs, - key: "shopInBitDisplayName", - value: name, - ); - Logging.instance.i("ShopInBitService: display name set"); - } } 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, diff --git a/test/services/paynym/paynym_is_api_test.mocks.dart b/test/services/paynym/paynym_is_api_test.mocks.dart index e3d6837fa8..c62d8cc0c1 100644 --- a/test/services/paynym/paynym_is_api_test.mocks.dart +++ b/test/services/paynym/paynym_is_api_test.mocks.dart @@ -96,4 +96,57 @@ class MockHTTP extends _i1.Mock implements _i2.HTTP { ), ) as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> patch({ + required Uri? url, + Map? headers, + Object? body, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#patch, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#patch, [], { + #url: url, + #headers: headers, + #body: body, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); + + @override + _i3.Future<_i2.Response> delete({ + required Uri? url, + Map? headers, + required ({_i4.InternetAddress host, int port})? proxyInfo, + }) => + (super.noSuchMethod( + Invocation.method(#delete, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + }), + returnValue: _i3.Future<_i2.Response>.value( + _FakeResponse_0( + this, + Invocation.method(#delete, [], { + #url: url, + #headers: headers, + #proxyInfo: proxyInfo, + }), + ), + ), + ) + as _i3.Future<_i2.Response>); }