diff --git a/crypto_plugins/flutter_libmwc b/crypto_plugins/flutter_libmwc index 5b43e0e91f..f5ad0a99a1 160000 --- a/crypto_plugins/flutter_libmwc +++ b/crypto_plugins/flutter_libmwc @@ -1 +1 @@ -Subproject commit 5b43e0e91f3d04bddfe88bba1d2f6178a18aadf9 +Subproject commit f5ad0a99a1781f600742095fee0e47057eafd9c0 diff --git a/docs/building.md b/docs/building.md index 924386b7e2..ad2e2550a7 100644 --- a/docs/building.md +++ b/docs/building.md @@ -43,7 +43,7 @@ sudo apt-get install libc6:i386 libncurses5:i386 libstdc++6:i386 lib32z1 libbz2- ### Build dependencies Install basic dependencies ``` -sudo apt-get install libssl-dev curl unzip automake build-essential file pkg-config git python3 libtool libtinfo6 cmake libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm g++ gcc gperf libopencv-dev python3-typogrify xsltproc valac gobject-introspection meson +sudo apt-get install libssl-dev curl unzip automake build-essential file pkg-config git python3 libtool libtinfo6 cmake libgit2-dev clang libncurses5-dev libncursesw5-dev zlib1g-dev llvm lld g++ gcc gperf libopencv-dev python3-typogrify xsltproc valac gobject-introspection meson ``` For Ubuntu 20.04, @@ -75,7 +75,7 @@ rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-andro Linux desktop specific dependencies: ``` -sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev meson python3-pip libgirepository1.0-dev valac xsltproc docbook-xsl +sudo apt-get install clang cmake lld ninja-build pkg-config libgtk-3-dev liblzma-dev meson python3-pip libgirepository1.0-dev valac xsltproc docbook-xsl pip3 install --upgrade meson==0.64.1 markdown==3.4.1 markupsafe==2.1.1 jinja2==3.1.2 pygments==2.13.0 toml==0.10.2 typogrify==2.0.7 tomli==2.0.1 ``` diff --git a/lib/db/isar/main_db.dart b/lib/db/isar/main_db.dart index 9e7e0da953..f3589d3210 100644 --- a/lib/db/isar/main_db.dart +++ b/lib/db/isar/main_db.dart @@ -73,6 +73,7 @@ class MainDB { TokenWalletInfoSchema, FrostWalletInfoSchema, WalletSolanaTokenInfoSchema, + ShopInBitTicketSchema, ], directory: (await StackFileSystem.applicationIsarDirectory()).path, // inspector: kDebugMode, @@ -645,4 +646,30 @@ class MainDB { isar.writeTxn(() async { await isar.solContracts.putAll(tokens); }); + + // ========== ShopInBit tickets =============================================== + + List getShopInBitTickets() { + return isar.shopInBitTickets.where().sortByCreatedAtDesc().findAllSync(); + } + + Future putShopInBitTicket(ShopInBitTicket ticket) async { + try { + return await isar.writeTxn(() async { + return await isar.shopInBitTickets.put(ticket); + }); + } catch (e) { + throw MainDBException("failed putShopInBitTicket", e); + } + } + + Future deleteShopInBitTicket(String ticketId) async { + try { + return await isar.writeTxn(() async { + return await isar.shopInBitTickets.deleteByTicketId(ticketId); + }); + } catch (e) { + throw MainDBException("failed deleteShopInBitTicket: $ticketId", e); + } + } } diff --git a/lib/dto/ordinals/inscription_data.dart b/lib/dto/ordinals/inscription_data.dart index 2f12bd670a..19d6ae9a92 100644 --- a/lib/dto/ordinals/inscription_data.dart +++ b/lib/dto/ordinals/inscription_data.dart @@ -51,6 +51,44 @@ class InscriptionData { ); } + /// Parse the response from an ord server's /inscription/{id} endpoint. + /// [contentUrl] should be pre-built as `$baseUrl/content/$inscriptionId`. + factory InscriptionData.fromOrdJson( + Map json, + String contentUrl, + ) { + final inscriptionId = json['inscription_id'] as String; + final satpoint = json['satpoint'] as String? ?? ''; + // satpoint format: "txid:vout:offset" + final satpointParts = satpoint.split(':'); + if (satpointParts.length < 2 || satpointParts[0].isEmpty) { + throw FormatException( + 'Invalid satpoint for inscription $inscriptionId: "$satpoint"', + ); + } + final output = '${satpointParts[0]}:${satpointParts[1]}'; + final offset = satpointParts.length >= 3 + ? int.tryParse(satpointParts[2]) ?? 0 + : 0; + + return InscriptionData( + inscriptionId: inscriptionId, + inscriptionNumber: json['inscription_number'] as int? ?? 0, + address: json['address'] as String? ?? '', + preview: contentUrl, + content: contentUrl, + contentLength: json['content_length'] as int? ?? 0, + contentType: json['content_type'] as String? ?? '', + contentBody: '', + timestamp: json['timestamp'] as int? ?? 0, + genesisTransaction: inscriptionId.split('i').first, + location: satpoint, + output: output, + outputValue: json['output_value'] as int? ?? 0, + offset: offset, + ); + } + @override String toString() { return 'InscriptionData {' diff --git a/lib/main.dart b/lib/main.dart index ea6880af6a..3b4083c457 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -41,6 +41,7 @@ import 'models/models.dart'; import 'models/node_model.dart'; import 'models/notification_model.dart'; import 'models/trade_wallet_lookup.dart'; +import 'pages/already_running_view.dart'; import 'pages/campfire_migrate_view.dart'; import 'pages/home_view/home_view.dart'; import 'pages/intro_view.dart'; @@ -178,8 +179,57 @@ void main(List args) async { (await StackFileSystem.applicationHiveDirectory()).path, ); - await DB.instance.hive.openBox(DB.boxNameDBInfo); - await DB.instance.hive.openBox(DB.boxNamePrefs); + try { + await DB.instance.hive.openBox(DB.boxNameDBInfo); + await DB.instance.hive.openBox(DB.boxNamePrefs); + } on FileSystemException catch (e) { + if (e.osError?.errorCode == 11 || e.message.contains('lock failed')) { + // Another instance already holds the Hive database lock. + // Try to bootstrap just enough of the theme system (Isar is independent + // of Hive) so the error screen looks like a real Stack Wallet screen. + Widget errorApp; + try { + await StackFileSystem.initThemesDir(); + await MainDB.instance.initMainDB(); + ThemeService.instance.init(MainDB.instance); + errorApp = const ProviderScope(child: AlreadyRunningApp()); + } catch (_) { + // Isar is also unavailable (e.g., another error). Fall back to a + // minimal but still Inter-font styled screen. + errorApp = MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(fontFamily: GoogleFonts.inter().fontFamily), + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'is already running.\n' + 'Close the other window and try again.', + textAlign: TextAlign.center, + style: GoogleFonts.inter(fontSize: 16), + ), + ], + ), + ), + ), + ); + } + runApp(errorApp); + return; + } + rethrow; + } await Prefs.instance.init(); await Logging.instance.initialize( diff --git a/lib/models/isar/models/isar_models.dart b/lib/models/isar/models/isar_models.dart index cf27091bf1..8206fc0f31 100644 --- a/lib/models/isar/models/isar_models.dart +++ b/lib/models/isar/models/isar_models.dart @@ -17,5 +17,6 @@ export 'blockchain_data/utxo.dart'; export 'ethereum/eth_contract.dart'; export 'log.dart'; export 'solana/sol_contract.dart'; +export 'shopinbit_ticket.dart'; export 'transaction_note.dart'; export '../../../wallets/isar/models/wallet_solana_token_info.dart'; diff --git a/lib/models/isar/models/shopinbit_ticket.dart b/lib/models/isar/models/shopinbit_ticket.dart new file mode 100644 index 0000000000..f3ffab4ba4 --- /dev/null +++ b/lib/models/isar/models/shopinbit_ticket.dart @@ -0,0 +1,41 @@ +import 'package:isar_community/isar.dart'; + +import '../../shopinbit/shopinbit_order_model.dart'; + +part 'shopinbit_ticket.g.dart'; + +@collection +class ShopInBitTicket { + Id id = Isar.autoIncrement; + + @Index(unique: true, replace: true) + late String ticketId; + + late String displayName; + @enumerated + late ShopInBitCategory category; + @enumerated + late ShopInBitOrderStatus status; + late String requestDescription; + late String deliveryCountry; + late String? offerProductName; + late String? offerPrice; + late String shippingName; + late String shippingStreet; + late String shippingCity; + late String shippingPostalCode; + late String shippingCountry; + late String? paymentMethod; + late List messages; + late DateTime createdAt; + late int apiTicketId; +} + +@embedded +class ShopInBitTicketMessage { + late String text; + late DateTime timestamp; + late bool isFromUser; + + ShopInBitTicketMessage(); +} diff --git a/lib/models/isar/models/shopinbit_ticket.g.dart b/lib/models/isar/models/shopinbit_ticket.g.dart new file mode 100644 index 0000000000..14afa3dfd1 --- /dev/null +++ b/lib/models/isar/models/shopinbit_ticket.g.dart @@ -0,0 +1,3738 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'shopinbit_ticket.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetShopInBitTicketCollection on Isar { + IsarCollection get shopInBitTickets => this.collection(); +} + +const ShopInBitTicketSchema = CollectionSchema( + name: r'ShopInBitTicket', + id: 1968691807160517649, + properties: { + r'apiTicketId': PropertySchema( + id: 0, + name: r'apiTicketId', + type: IsarType.long, + ), + r'category': PropertySchema( + id: 1, + name: r'category', + type: IsarType.byte, + enumMap: _ShopInBitTicketcategoryEnumValueMap, + ), + r'createdAt': PropertySchema( + id: 2, + name: r'createdAt', + type: IsarType.dateTime, + ), + r'deliveryCountry': PropertySchema( + id: 3, + name: r'deliveryCountry', + type: IsarType.string, + ), + r'displayName': PropertySchema( + id: 4, + name: r'displayName', + type: IsarType.string, + ), + r'messages': PropertySchema( + id: 5, + name: r'messages', + type: IsarType.objectList, + + target: r'ShopInBitTicketMessage', + ), + r'offerPrice': PropertySchema( + id: 6, + name: r'offerPrice', + type: IsarType.string, + ), + r'offerProductName': PropertySchema( + id: 7, + name: r'offerProductName', + type: IsarType.string, + ), + r'paymentMethod': PropertySchema( + id: 8, + name: r'paymentMethod', + type: IsarType.string, + ), + r'requestDescription': PropertySchema( + id: 9, + name: r'requestDescription', + type: IsarType.string, + ), + r'shippingCity': PropertySchema( + id: 10, + name: r'shippingCity', + type: IsarType.string, + ), + r'shippingCountry': PropertySchema( + id: 11, + name: r'shippingCountry', + type: IsarType.string, + ), + r'shippingName': PropertySchema( + id: 12, + name: r'shippingName', + type: IsarType.string, + ), + r'shippingPostalCode': PropertySchema( + id: 13, + name: r'shippingPostalCode', + type: IsarType.string, + ), + r'shippingStreet': PropertySchema( + id: 14, + name: r'shippingStreet', + type: IsarType.string, + ), + r'status': PropertySchema( + id: 15, + name: r'status', + type: IsarType.byte, + enumMap: _ShopInBitTicketstatusEnumValueMap, + ), + r'ticketId': PropertySchema( + id: 16, + name: r'ticketId', + type: IsarType.string, + ), + }, + + estimateSize: _shopInBitTicketEstimateSize, + serialize: _shopInBitTicketSerialize, + deserialize: _shopInBitTicketDeserialize, + deserializeProp: _shopInBitTicketDeserializeProp, + idName: r'id', + indexes: { + r'ticketId': IndexSchema( + id: -6483959237056329942, + name: r'ticketId', + unique: true, + replace: true, + properties: [ + IndexPropertySchema( + name: r'ticketId', + type: IndexType.hash, + caseSensitive: true, + ), + ], + ), + }, + links: {}, + embeddedSchemas: {r'ShopInBitTicketMessage': ShopInBitTicketMessageSchema}, + + getId: _shopInBitTicketGetId, + getLinks: _shopInBitTicketGetLinks, + attach: _shopInBitTicketAttach, + version: '3.3.0-dev.2', +); + +int _shopInBitTicketEstimateSize( + ShopInBitTicket object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.deliveryCountry.length * 3; + bytesCount += 3 + object.displayName.length * 3; + bytesCount += 3 + object.messages.length * 3; + { + final offsets = allOffsets[ShopInBitTicketMessage]!; + for (var i = 0; i < object.messages.length; i++) { + final value = object.messages[i]; + bytesCount += ShopInBitTicketMessageSchema.estimateSize( + value, + offsets, + allOffsets, + ); + } + } + { + final value = object.offerPrice; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.offerProductName; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.paymentMethod; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + bytesCount += 3 + object.requestDescription.length * 3; + bytesCount += 3 + object.shippingCity.length * 3; + bytesCount += 3 + object.shippingCountry.length * 3; + bytesCount += 3 + object.shippingName.length * 3; + bytesCount += 3 + object.shippingPostalCode.length * 3; + bytesCount += 3 + object.shippingStreet.length * 3; + bytesCount += 3 + object.ticketId.length * 3; + return bytesCount; +} + +void _shopInBitTicketSerialize( + ShopInBitTicket object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeLong(offsets[0], object.apiTicketId); + writer.writeByte(offsets[1], object.category.index); + writer.writeDateTime(offsets[2], object.createdAt); + writer.writeString(offsets[3], object.deliveryCountry); + writer.writeString(offsets[4], object.displayName); + writer.writeObjectList( + offsets[5], + allOffsets, + ShopInBitTicketMessageSchema.serialize, + object.messages, + ); + writer.writeString(offsets[6], object.offerPrice); + writer.writeString(offsets[7], object.offerProductName); + writer.writeString(offsets[8], object.paymentMethod); + writer.writeString(offsets[9], object.requestDescription); + writer.writeString(offsets[10], object.shippingCity); + writer.writeString(offsets[11], object.shippingCountry); + writer.writeString(offsets[12], object.shippingName); + writer.writeString(offsets[13], object.shippingPostalCode); + writer.writeString(offsets[14], object.shippingStreet); + writer.writeByte(offsets[15], object.status.index); + writer.writeString(offsets[16], object.ticketId); +} + +ShopInBitTicket _shopInBitTicketDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = ShopInBitTicket(); + object.apiTicketId = reader.readLong(offsets[0]); + object.category = + _ShopInBitTicketcategoryValueEnumMap[reader.readByteOrNull(offsets[1])] ?? + ShopInBitCategory.concierge; + object.createdAt = reader.readDateTime(offsets[2]); + object.deliveryCountry = reader.readString(offsets[3]); + object.displayName = reader.readString(offsets[4]); + object.id = id; + object.messages = + reader.readObjectList( + offsets[5], + ShopInBitTicketMessageSchema.deserialize, + allOffsets, + ShopInBitTicketMessage(), + ) ?? + []; + object.offerPrice = reader.readStringOrNull(offsets[6]); + object.offerProductName = reader.readStringOrNull(offsets[7]); + object.paymentMethod = reader.readStringOrNull(offsets[8]); + object.requestDescription = reader.readString(offsets[9]); + object.shippingCity = reader.readString(offsets[10]); + object.shippingCountry = reader.readString(offsets[11]); + object.shippingName = reader.readString(offsets[12]); + object.shippingPostalCode = reader.readString(offsets[13]); + object.shippingStreet = reader.readString(offsets[14]); + object.status = + _ShopInBitTicketstatusValueEnumMap[reader.readByteOrNull(offsets[15])] ?? + ShopInBitOrderStatus.pending; + object.ticketId = reader.readString(offsets[16]); + return object; +} + +P _shopInBitTicketDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readLong(offset)) as P; + case 1: + return (_ShopInBitTicketcategoryValueEnumMap[reader.readByteOrNull( + offset, + )] ?? + ShopInBitCategory.concierge) + as P; + case 2: + return (reader.readDateTime(offset)) as P; + case 3: + return (reader.readString(offset)) as P; + case 4: + return (reader.readString(offset)) as P; + case 5: + return (reader.readObjectList( + offset, + ShopInBitTicketMessageSchema.deserialize, + allOffsets, + ShopInBitTicketMessage(), + ) ?? + []) + as P; + case 6: + return (reader.readStringOrNull(offset)) as P; + case 7: + return (reader.readStringOrNull(offset)) as P; + case 8: + return (reader.readStringOrNull(offset)) as P; + case 9: + return (reader.readString(offset)) as P; + case 10: + return (reader.readString(offset)) as P; + case 11: + return (reader.readString(offset)) as P; + case 12: + return (reader.readString(offset)) as P; + case 13: + return (reader.readString(offset)) as P; + case 14: + return (reader.readString(offset)) as P; + case 15: + return (_ShopInBitTicketstatusValueEnumMap[reader.readByteOrNull( + offset, + )] ?? + ShopInBitOrderStatus.pending) + as P; + case 16: + return (reader.readString(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +const _ShopInBitTicketcategoryEnumValueMap = { + 'concierge': 0, + 'travel': 1, + 'car': 2, +}; +const _ShopInBitTicketcategoryValueEnumMap = { + 0: ShopInBitCategory.concierge, + 1: ShopInBitCategory.travel, + 2: ShopInBitCategory.car, +}; +const _ShopInBitTicketstatusEnumValueMap = { + 'pending': 0, + 'reviewing': 1, + 'offerAvailable': 2, + 'accepted': 3, + 'paymentPending': 4, + 'paid': 5, + 'shipping': 6, + 'delivered': 7, + 'closed': 8, + 'cancelled': 9, + 'refunded': 10, +}; +const _ShopInBitTicketstatusValueEnumMap = { + 0: ShopInBitOrderStatus.pending, + 1: ShopInBitOrderStatus.reviewing, + 2: ShopInBitOrderStatus.offerAvailable, + 3: ShopInBitOrderStatus.accepted, + 4: ShopInBitOrderStatus.paymentPending, + 5: ShopInBitOrderStatus.paid, + 6: ShopInBitOrderStatus.shipping, + 7: ShopInBitOrderStatus.delivered, + 8: ShopInBitOrderStatus.closed, + 9: ShopInBitOrderStatus.cancelled, + 10: ShopInBitOrderStatus.refunded, +}; + +Id _shopInBitTicketGetId(ShopInBitTicket object) { + return object.id; +} + +List> _shopInBitTicketGetLinks(ShopInBitTicket object) { + return []; +} + +void _shopInBitTicketAttach( + IsarCollection col, + Id id, + ShopInBitTicket object, +) { + object.id = id; +} + +extension ShopInBitTicketByIndex on IsarCollection { + Future getByTicketId(String ticketId) { + return getByIndex(r'ticketId', [ticketId]); + } + + ShopInBitTicket? getByTicketIdSync(String ticketId) { + return getByIndexSync(r'ticketId', [ticketId]); + } + + Future deleteByTicketId(String ticketId) { + return deleteByIndex(r'ticketId', [ticketId]); + } + + bool deleteByTicketIdSync(String ticketId) { + return deleteByIndexSync(r'ticketId', [ticketId]); + } + + Future> getAllByTicketId(List ticketIdValues) { + final values = ticketIdValues.map((e) => [e]).toList(); + return getAllByIndex(r'ticketId', values); + } + + List getAllByTicketIdSync(List ticketIdValues) { + final values = ticketIdValues.map((e) => [e]).toList(); + return getAllByIndexSync(r'ticketId', values); + } + + Future deleteAllByTicketId(List ticketIdValues) { + final values = ticketIdValues.map((e) => [e]).toList(); + return deleteAllByIndex(r'ticketId', values); + } + + int deleteAllByTicketIdSync(List ticketIdValues) { + final values = ticketIdValues.map((e) => [e]).toList(); + return deleteAllByIndexSync(r'ticketId', values); + } + + Future putByTicketId(ShopInBitTicket object) { + return putByIndex(r'ticketId', object); + } + + Id putByTicketIdSync(ShopInBitTicket object, {bool saveLinks = true}) { + return putByIndexSync(r'ticketId', object, saveLinks: saveLinks); + } + + Future> putAllByTicketId(List objects) { + return putAllByIndex(r'ticketId', objects); + } + + List putAllByTicketIdSync( + List objects, { + bool saveLinks = true, + }) { + return putAllByIndexSync(r'ticketId', objects, saveLinks: saveLinks); + } +} + +extension ShopInBitTicketQueryWhereSort + on QueryBuilder { + QueryBuilder anyId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension ShopInBitTicketQueryWhere + on QueryBuilder { + QueryBuilder idEqualTo( + Id id, + ) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between(lower: id, upper: id)); + }); + } + + QueryBuilder + idNotEqualTo(Id id) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: false), + ); + } + }); + } + + QueryBuilder + idGreaterThan(Id id, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: id, includeLower: include), + ); + }); + } + + QueryBuilder idLessThan( + Id id, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: id, includeUpper: include), + ); + }); + } + + QueryBuilder idBetween( + Id lowerId, + Id upperId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.between( + lower: lowerId, + includeLower: includeLower, + upper: upperId, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + ticketIdEqualTo(String ticketId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IndexWhereClause.equalTo(indexName: r'ticketId', value: [ticketId]), + ); + }); + } + + QueryBuilder + ticketIdNotEqualTo(String ticketId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'ticketId', + lower: [], + upper: [ticketId], + includeUpper: false, + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'ticketId', + lower: [ticketId], + includeLower: false, + upper: [], + ), + ); + } else { + return query + .addWhereClause( + IndexWhereClause.between( + indexName: r'ticketId', + lower: [ticketId], + includeLower: false, + upper: [], + ), + ) + .addWhereClause( + IndexWhereClause.between( + indexName: r'ticketId', + lower: [], + upper: [ticketId], + includeUpper: false, + ), + ); + } + }); + } +} + +extension ShopInBitTicketQueryFilter + on QueryBuilder { + QueryBuilder + apiTicketIdEqualTo(int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'apiTicketId', value: value), + ); + }); + } + + QueryBuilder + apiTicketIdGreaterThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'apiTicketId', + value: value, + ), + ); + }); + } + + QueryBuilder + apiTicketIdLessThan(int value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'apiTicketId', + value: value, + ), + ); + }); + } + + QueryBuilder + apiTicketIdBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'apiTicketId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + categoryEqualTo(ShopInBitCategory value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'category', value: value), + ); + }); + } + + QueryBuilder + categoryGreaterThan(ShopInBitCategory value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'category', + value: value, + ), + ); + }); + } + + QueryBuilder + categoryLessThan(ShopInBitCategory value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'category', + value: value, + ), + ); + }); + } + + QueryBuilder + categoryBetween( + ShopInBitCategory lower, + ShopInBitCategory upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'category', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + createdAtEqualTo(DateTime value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'createdAt', value: value), + ); + }); + } + + QueryBuilder + createdAtGreaterThan(DateTime value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'createdAt', + value: value, + ), + ); + }); + } + + QueryBuilder + createdAtLessThan(DateTime value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'createdAt', + value: value, + ), + ); + }); + } + + QueryBuilder + createdAtBetween( + DateTime lower, + DateTime upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'createdAt', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + deliveryCountryEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'deliveryCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + deliveryCountryGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'deliveryCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + deliveryCountryLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'deliveryCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + deliveryCountryBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'deliveryCountry', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + deliveryCountryStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'deliveryCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + deliveryCountryEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'deliveryCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + deliveryCountryContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'deliveryCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + deliveryCountryMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'deliveryCountry', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + deliveryCountryIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'deliveryCountry', value: ''), + ); + }); + } + + QueryBuilder + deliveryCountryIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'deliveryCountry', value: ''), + ); + }); + } + + QueryBuilder + displayNameEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'displayName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + displayNameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'displayName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + displayNameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'displayName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + displayNameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'displayName', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + displayNameStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'displayName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + displayNameEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'displayName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + displayNameContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'displayName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + displayNameMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'displayName', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + displayNameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'displayName', value: ''), + ); + }); + } + + QueryBuilder + displayNameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'displayName', value: ''), + ); + }); + } + + QueryBuilder + idEqualTo(Id value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'id', value: value), + ); + }); + } + + QueryBuilder + idGreaterThan(Id value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder + idLessThan(Id value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'id', + value: value, + ), + ); + }); + } + + QueryBuilder + idBetween( + Id lower, + Id upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'id', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + messagesLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength(r'messages', length, true, length, true); + }); + } + + QueryBuilder + messagesIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength(r'messages', 0, true, 0, true); + }); + } + + QueryBuilder + messagesIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength(r'messages', 0, false, 999999, true); + }); + } + + QueryBuilder + messagesLengthLessThan(int length, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.listLength(r'messages', 0, true, length, include); + }); + } + + QueryBuilder + messagesLengthGreaterThan(int length, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.listLength(r'messages', length, include, 999999, true); + }); + } + + QueryBuilder + messagesLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'messages', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } + + QueryBuilder + offerPriceIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'offerPrice'), + ); + }); + } + + QueryBuilder + offerPriceIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'offerPrice'), + ); + }); + } + + QueryBuilder + offerPriceEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'offerPrice', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerPriceGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'offerPrice', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerPriceLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'offerPrice', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerPriceBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'offerPrice', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerPriceStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'offerPrice', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerPriceEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'offerPrice', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerPriceContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'offerPrice', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerPriceMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'offerPrice', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerPriceIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'offerPrice', value: ''), + ); + }); + } + + QueryBuilder + offerPriceIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'offerPrice', value: ''), + ); + }); + } + + QueryBuilder + offerProductNameIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'offerProductName'), + ); + }); + } + + QueryBuilder + offerProductNameIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'offerProductName'), + ); + }); + } + + QueryBuilder + offerProductNameEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'offerProductName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerProductNameGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'offerProductName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerProductNameLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'offerProductName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerProductNameBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'offerProductName', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerProductNameStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'offerProductName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerProductNameEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'offerProductName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerProductNameContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'offerProductName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerProductNameMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'offerProductName', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + offerProductNameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'offerProductName', value: ''), + ); + }); + } + + QueryBuilder + offerProductNameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'offerProductName', value: ''), + ); + }); + } + + QueryBuilder + paymentMethodIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'paymentMethod'), + ); + }); + } + + QueryBuilder + paymentMethodIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'paymentMethod'), + ); + }); + } + + QueryBuilder + paymentMethodEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'paymentMethod', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + paymentMethodGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'paymentMethod', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + paymentMethodLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'paymentMethod', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + paymentMethodBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'paymentMethod', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + paymentMethodStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'paymentMethod', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + paymentMethodEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'paymentMethod', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + paymentMethodContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'paymentMethod', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + paymentMethodMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'paymentMethod', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + paymentMethodIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'paymentMethod', value: ''), + ); + }); + } + + QueryBuilder + paymentMethodIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'paymentMethod', value: ''), + ); + }); + } + + QueryBuilder + requestDescriptionEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'requestDescription', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + requestDescriptionGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'requestDescription', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + requestDescriptionLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'requestDescription', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + requestDescriptionBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'requestDescription', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + requestDescriptionStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'requestDescription', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + requestDescriptionEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'requestDescription', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + requestDescriptionContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'requestDescription', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + requestDescriptionMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'requestDescription', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + requestDescriptionIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'requestDescription', value: ''), + ); + }); + } + + QueryBuilder + requestDescriptionIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'requestDescription', value: ''), + ); + }); + } + + QueryBuilder + shippingCityEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'shippingCity', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCityGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'shippingCity', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCityLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'shippingCity', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCityBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'shippingCity', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCityStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'shippingCity', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCityEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'shippingCity', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCityContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'shippingCity', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCityMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'shippingCity', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCityIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'shippingCity', value: ''), + ); + }); + } + + QueryBuilder + shippingCityIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'shippingCity', value: ''), + ); + }); + } + + QueryBuilder + shippingCountryEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'shippingCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCountryGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'shippingCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCountryLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'shippingCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCountryBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'shippingCountry', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCountryStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'shippingCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCountryEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'shippingCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCountryContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'shippingCountry', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCountryMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'shippingCountry', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingCountryIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'shippingCountry', value: ''), + ); + }); + } + + QueryBuilder + shippingCountryIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'shippingCountry', value: ''), + ); + }); + } + + QueryBuilder + shippingNameEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'shippingName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingNameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'shippingName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingNameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'shippingName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingNameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'shippingName', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingNameStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'shippingName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingNameEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'shippingName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingNameContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'shippingName', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingNameMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'shippingName', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingNameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'shippingName', value: ''), + ); + }); + } + + QueryBuilder + shippingNameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'shippingName', value: ''), + ); + }); + } + + QueryBuilder + shippingPostalCodeEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'shippingPostalCode', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingPostalCodeGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'shippingPostalCode', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingPostalCodeLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'shippingPostalCode', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingPostalCodeBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'shippingPostalCode', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingPostalCodeStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'shippingPostalCode', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingPostalCodeEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'shippingPostalCode', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingPostalCodeContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'shippingPostalCode', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingPostalCodeMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'shippingPostalCode', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingPostalCodeIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'shippingPostalCode', value: ''), + ); + }); + } + + QueryBuilder + shippingPostalCodeIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'shippingPostalCode', value: ''), + ); + }); + } + + QueryBuilder + shippingStreetEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'shippingStreet', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingStreetGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'shippingStreet', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingStreetLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'shippingStreet', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingStreetBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'shippingStreet', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingStreetStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'shippingStreet', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingStreetEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'shippingStreet', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingStreetContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'shippingStreet', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingStreetMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'shippingStreet', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + shippingStreetIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'shippingStreet', value: ''), + ); + }); + } + + QueryBuilder + shippingStreetIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'shippingStreet', value: ''), + ); + }); + } + + QueryBuilder + statusEqualTo(ShopInBitOrderStatus value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'status', value: value), + ); + }); + } + + QueryBuilder + statusGreaterThan(ShopInBitOrderStatus value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'status', + value: value, + ), + ); + }); + } + + QueryBuilder + statusLessThan(ShopInBitOrderStatus value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'status', + value: value, + ), + ); + }); + } + + QueryBuilder + statusBetween( + ShopInBitOrderStatus lower, + ShopInBitOrderStatus upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'status', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } + + QueryBuilder + ticketIdEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'ticketId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + ticketIdGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'ticketId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + ticketIdLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'ticketId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + ticketIdBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'ticketId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + ticketIdStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'ticketId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + ticketIdEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'ticketId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + ticketIdContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'ticketId', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + ticketIdMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'ticketId', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + ticketIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'ticketId', value: ''), + ); + }); + } + + QueryBuilder + ticketIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'ticketId', value: ''), + ); + }); + } +} + +extension ShopInBitTicketQueryObject + on QueryBuilder { + QueryBuilder + messagesElement(FilterQuery q) { + return QueryBuilder.apply(this, (query) { + return query.object(q, r'messages'); + }); + } +} + +extension ShopInBitTicketQueryLinks + on QueryBuilder {} + +extension ShopInBitTicketQuerySortBy + on QueryBuilder { + QueryBuilder + sortByApiTicketId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'apiTicketId', Sort.asc); + }); + } + + QueryBuilder + sortByApiTicketIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'apiTicketId', Sort.desc); + }); + } + + QueryBuilder + sortByCategory() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'category', Sort.asc); + }); + } + + QueryBuilder + sortByCategoryDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'category', Sort.desc); + }); + } + + QueryBuilder + sortByCreatedAt() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'createdAt', Sort.asc); + }); + } + + QueryBuilder + sortByCreatedAtDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'createdAt', Sort.desc); + }); + } + + QueryBuilder + sortByDeliveryCountry() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'deliveryCountry', Sort.asc); + }); + } + + QueryBuilder + sortByDeliveryCountryDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'deliveryCountry', Sort.desc); + }); + } + + QueryBuilder + sortByDisplayName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'displayName', Sort.asc); + }); + } + + QueryBuilder + sortByDisplayNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'displayName', Sort.desc); + }); + } + + QueryBuilder + sortByOfferPrice() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'offerPrice', Sort.asc); + }); + } + + QueryBuilder + sortByOfferPriceDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'offerPrice', Sort.desc); + }); + } + + QueryBuilder + sortByOfferProductName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'offerProductName', Sort.asc); + }); + } + + QueryBuilder + sortByOfferProductNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'offerProductName', Sort.desc); + }); + } + + QueryBuilder + sortByPaymentMethod() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'paymentMethod', Sort.asc); + }); + } + + QueryBuilder + sortByPaymentMethodDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'paymentMethod', Sort.desc); + }); + } + + QueryBuilder + sortByRequestDescription() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'requestDescription', Sort.asc); + }); + } + + QueryBuilder + sortByRequestDescriptionDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'requestDescription', Sort.desc); + }); + } + + QueryBuilder + sortByShippingCity() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingCity', Sort.asc); + }); + } + + QueryBuilder + sortByShippingCityDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingCity', Sort.desc); + }); + } + + QueryBuilder + sortByShippingCountry() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingCountry', Sort.asc); + }); + } + + QueryBuilder + sortByShippingCountryDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingCountry', Sort.desc); + }); + } + + QueryBuilder + sortByShippingName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingName', Sort.asc); + }); + } + + QueryBuilder + sortByShippingNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingName', Sort.desc); + }); + } + + QueryBuilder + sortByShippingPostalCode() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingPostalCode', Sort.asc); + }); + } + + QueryBuilder + sortByShippingPostalCodeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingPostalCode', Sort.desc); + }); + } + + QueryBuilder + sortByShippingStreet() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingStreet', Sort.asc); + }); + } + + QueryBuilder + sortByShippingStreetDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingStreet', Sort.desc); + }); + } + + QueryBuilder sortByStatus() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'status', Sort.asc); + }); + } + + QueryBuilder + sortByStatusDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'status', Sort.desc); + }); + } + + QueryBuilder + sortByTicketId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'ticketId', Sort.asc); + }); + } + + QueryBuilder + sortByTicketIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'ticketId', Sort.desc); + }); + } +} + +extension ShopInBitTicketQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByApiTicketId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'apiTicketId', Sort.asc); + }); + } + + QueryBuilder + thenByApiTicketIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'apiTicketId', Sort.desc); + }); + } + + QueryBuilder + thenByCategory() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'category', Sort.asc); + }); + } + + QueryBuilder + thenByCategoryDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'category', Sort.desc); + }); + } + + QueryBuilder + thenByCreatedAt() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'createdAt', Sort.asc); + }); + } + + QueryBuilder + thenByCreatedAtDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'createdAt', Sort.desc); + }); + } + + QueryBuilder + thenByDeliveryCountry() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'deliveryCountry', Sort.asc); + }); + } + + QueryBuilder + thenByDeliveryCountryDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'deliveryCountry', Sort.desc); + }); + } + + QueryBuilder + thenByDisplayName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'displayName', Sort.asc); + }); + } + + QueryBuilder + thenByDisplayNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'displayName', Sort.desc); + }); + } + + QueryBuilder thenById() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.asc); + }); + } + + QueryBuilder thenByIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'id', Sort.desc); + }); + } + + QueryBuilder + thenByOfferPrice() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'offerPrice', Sort.asc); + }); + } + + QueryBuilder + thenByOfferPriceDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'offerPrice', Sort.desc); + }); + } + + QueryBuilder + thenByOfferProductName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'offerProductName', Sort.asc); + }); + } + + QueryBuilder + thenByOfferProductNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'offerProductName', Sort.desc); + }); + } + + QueryBuilder + thenByPaymentMethod() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'paymentMethod', Sort.asc); + }); + } + + QueryBuilder + thenByPaymentMethodDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'paymentMethod', Sort.desc); + }); + } + + QueryBuilder + thenByRequestDescription() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'requestDescription', Sort.asc); + }); + } + + QueryBuilder + thenByRequestDescriptionDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'requestDescription', Sort.desc); + }); + } + + QueryBuilder + thenByShippingCity() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingCity', Sort.asc); + }); + } + + QueryBuilder + thenByShippingCityDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingCity', Sort.desc); + }); + } + + QueryBuilder + thenByShippingCountry() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingCountry', Sort.asc); + }); + } + + QueryBuilder + thenByShippingCountryDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingCountry', Sort.desc); + }); + } + + QueryBuilder + thenByShippingName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingName', Sort.asc); + }); + } + + QueryBuilder + thenByShippingNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingName', Sort.desc); + }); + } + + QueryBuilder + thenByShippingPostalCode() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingPostalCode', Sort.asc); + }); + } + + QueryBuilder + thenByShippingPostalCodeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingPostalCode', Sort.desc); + }); + } + + QueryBuilder + thenByShippingStreet() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingStreet', Sort.asc); + }); + } + + QueryBuilder + thenByShippingStreetDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'shippingStreet', Sort.desc); + }); + } + + QueryBuilder thenByStatus() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'status', Sort.asc); + }); + } + + QueryBuilder + thenByStatusDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'status', Sort.desc); + }); + } + + QueryBuilder + thenByTicketId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'ticketId', Sort.asc); + }); + } + + QueryBuilder + thenByTicketIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'ticketId', Sort.desc); + }); + } +} + +extension ShopInBitTicketQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByApiTicketId() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'apiTicketId'); + }); + } + + QueryBuilder + distinctByCategory() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'category'); + }); + } + + QueryBuilder + distinctByCreatedAt() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'createdAt'); + }); + } + + QueryBuilder + distinctByDeliveryCountry({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'deliveryCountry', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByDisplayName({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'displayName', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByOfferPrice({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'offerPrice', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByOfferProductName({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'offerProductName', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByPaymentMethod({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'paymentMethod', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByRequestDescription({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'requestDescription', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByShippingCity({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'shippingCity', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByShippingCountry({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'shippingCountry', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByShippingName({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'shippingName', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByShippingPostalCode({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'shippingPostalCode', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder + distinctByShippingStreet({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy( + r'shippingStreet', + caseSensitive: caseSensitive, + ); + }); + } + + QueryBuilder distinctByStatus() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'status'); + }); + } + + QueryBuilder distinctByTicketId({ + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'ticketId', caseSensitive: caseSensitive); + }); + } +} + +extension ShopInBitTicketQueryProperty + on QueryBuilder { + QueryBuilder idProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'id'); + }); + } + + QueryBuilder apiTicketIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'apiTicketId'); + }); + } + + QueryBuilder + categoryProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'category'); + }); + } + + QueryBuilder + createdAtProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'createdAt'); + }); + } + + QueryBuilder + deliveryCountryProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'deliveryCountry'); + }); + } + + QueryBuilder + displayNameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'displayName'); + }); + } + + QueryBuilder, QQueryOperations> + messagesProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'messages'); + }); + } + + QueryBuilder + offerPriceProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'offerPrice'); + }); + } + + QueryBuilder + offerProductNameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'offerProductName'); + }); + } + + QueryBuilder + paymentMethodProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'paymentMethod'); + }); + } + + QueryBuilder + requestDescriptionProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'requestDescription'); + }); + } + + QueryBuilder + shippingCityProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'shippingCity'); + }); + } + + QueryBuilder + shippingCountryProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'shippingCountry'); + }); + } + + QueryBuilder + shippingNameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'shippingName'); + }); + } + + QueryBuilder + shippingPostalCodeProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'shippingPostalCode'); + }); + } + + QueryBuilder + shippingStreetProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'shippingStreet'); + }); + } + + QueryBuilder + statusProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'status'); + }); + } + + QueryBuilder ticketIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'ticketId'); + }); + } +} + +// ************************************************************************** +// IsarEmbeddedGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +const ShopInBitTicketMessageSchema = Schema( + name: r'ShopInBitTicketMessage', + id: -6797752334657665095, + properties: { + r'isFromUser': PropertySchema( + id: 0, + name: r'isFromUser', + type: IsarType.bool, + ), + r'text': PropertySchema(id: 1, name: r'text', type: IsarType.string), + r'timestamp': PropertySchema( + id: 2, + name: r'timestamp', + type: IsarType.dateTime, + ), + }, + + estimateSize: _shopInBitTicketMessageEstimateSize, + serialize: _shopInBitTicketMessageSerialize, + deserialize: _shopInBitTicketMessageDeserialize, + deserializeProp: _shopInBitTicketMessageDeserializeProp, +); + +int _shopInBitTicketMessageEstimateSize( + ShopInBitTicketMessage object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + bytesCount += 3 + object.text.length * 3; + return bytesCount; +} + +void _shopInBitTicketMessageSerialize( + ShopInBitTicketMessage object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeBool(offsets[0], object.isFromUser); + writer.writeString(offsets[1], object.text); + writer.writeDateTime(offsets[2], object.timestamp); +} + +ShopInBitTicketMessage _shopInBitTicketMessageDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = ShopInBitTicketMessage(); + object.isFromUser = reader.readBool(offsets[0]); + object.text = reader.readString(offsets[1]); + object.timestamp = reader.readDateTime(offsets[2]); + return object; +} + +P _shopInBitTicketMessageDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readBool(offset)) as P; + case 1: + return (reader.readString(offset)) as P; + case 2: + return (reader.readDateTime(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +extension ShopInBitTicketMessageQueryFilter + on + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QFilterCondition + > { + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + isFromUserEqualTo(bool value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'isFromUser', value: value), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textEqualTo(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'text', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'text', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'text', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'text', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'text', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'text', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'text', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'text', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'text', value: ''), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + textIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'text', value: ''), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + timestampEqualTo(DateTime value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'timestamp', value: value), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + timestampGreaterThan(DateTime value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'timestamp', + value: value, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + timestampLessThan(DateTime value, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'timestamp', + value: value, + ), + ); + }); + } + + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QAfterFilterCondition + > + timestampBetween( + DateTime lower, + DateTime upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'timestamp', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + ), + ); + }); + } +} + +extension ShopInBitTicketMessageQueryObject + on + QueryBuilder< + ShopInBitTicketMessage, + ShopInBitTicketMessage, + QFilterCondition + > {} diff --git a/lib/models/paynym/paynym_account.dart b/lib/models/paynym/paynym_account.dart index 4d44d43fc5..23a29b8043 100644 --- a/lib/models/paynym/paynym_account.dart +++ b/lib/models/paynym/paynym_account.dart @@ -80,7 +80,7 @@ class PaynymAccount { "segwit": segwit, "codes": codes.map((e) => e.toMap()), "followers": followers.map((e) => e.toMap()), - "following": followers.map((e) => e.toMap()), + "following": following.map((e) => e.toMap()), }; @override diff --git a/lib/models/shopinbit/shopinbit_order_model.dart b/lib/models/shopinbit/shopinbit_order_model.dart new file mode 100644 index 0000000000..89c17873c6 --- /dev/null +++ b/lib/models/shopinbit/shopinbit_order_model.dart @@ -0,0 +1,262 @@ +import 'package:flutter/foundation.dart'; + +import '../../services/shopinbit/src/models/ticket.dart'; +import '../isar/models/shopinbit_ticket.dart'; + +enum ShopInBitCategory { concierge, travel, car } + +enum ShopInBitOrderStatus { + pending, + reviewing, + offerAvailable, + accepted, + paymentPending, + paid, + shipping, + delivered, + closed, + cancelled, + refunded, +} + +class ShopInBitMessage { + final String text; + final DateTime timestamp; + final bool isFromUser; + + const ShopInBitMessage({ + required this.text, + required this.timestamp, + required this.isFromUser, + }); +} + +class ShopInBitOrderModel extends ChangeNotifier { + String _displayName = ""; + String get displayName => _displayName; + set displayName(String value) { + if (_displayName != value) { + _displayName = value; + notifyListeners(); + } + } + + bool _privacyAccepted = false; + bool get privacyAccepted => _privacyAccepted; + set privacyAccepted(bool value) { + if (_privacyAccepted != value) { + _privacyAccepted = value; + notifyListeners(); + } + } + + ShopInBitCategory? _category; + ShopInBitCategory? get category => _category; + set category(ShopInBitCategory? value) { + if (_category != value) { + _category = value; + notifyListeners(); + } + } + + bool _guidelinesAccepted = false; + bool get guidelinesAccepted => _guidelinesAccepted; + set guidelinesAccepted(bool value) { + if (_guidelinesAccepted != value) { + _guidelinesAccepted = value; + notifyListeners(); + } + } + + String _requestDescription = ""; + String get requestDescription => _requestDescription; + set requestDescription(String value) { + if (_requestDescription != value) { + _requestDescription = value; + notifyListeners(); + } + } + + String _deliveryCountry = ""; + String get deliveryCountry => _deliveryCountry; + set deliveryCountry(String value) { + if (_deliveryCountry != value) { + _deliveryCountry = value; + notifyListeners(); + } + } + + int _apiTicketId = 0; + int get apiTicketId => _apiTicketId; + set apiTicketId(int value) { + if (_apiTicketId != value) { + _apiTicketId = value; + notifyListeners(); + } + } + + String? _ticketId; + String? get ticketId => _ticketId; + set ticketId(String? value) { + if (_ticketId != value) { + _ticketId = value; + notifyListeners(); + } + } + + ShopInBitOrderStatus _status = ShopInBitOrderStatus.pending; + ShopInBitOrderStatus get status => _status; + set status(ShopInBitOrderStatus value) { + if (_status != value) { + _status = value; + notifyListeners(); + } + } + + String? _offerProductName; + String? get offerProductName => _offerProductName; + + String? _offerPrice; + String? get offerPrice => _offerPrice; + + void setOffer({required String productName, required String price}) { + _offerProductName = productName; + _offerPrice = price; + _status = ShopInBitOrderStatus.offerAvailable; + notifyListeners(); + } + + String _shippingName = ""; + String get shippingName => _shippingName; + + String _shippingStreet = ""; + String get shippingStreet => _shippingStreet; + + String _shippingCity = ""; + String get shippingCity => _shippingCity; + + String _shippingPostalCode = ""; + String get shippingPostalCode => _shippingPostalCode; + + String _shippingCountry = ""; + String get shippingCountry => _shippingCountry; + + void setShippingAddress({ + required String name, + required String street, + required String city, + required String postalCode, + required String country, + }) { + _shippingName = name; + _shippingStreet = street; + _shippingCity = city; + _shippingPostalCode = postalCode; + _shippingCountry = country; + notifyListeners(); + } + + String? _paymentMethod; + String? get paymentMethod => _paymentMethod; + set paymentMethod(String? value) { + if (_paymentMethod != value) { + _paymentMethod = value; + notifyListeners(); + } + } + + List _messages = []; + List get messages => List.unmodifiable(_messages); + void addMessage(ShopInBitMessage message) { + _messages.add(message); + notifyListeners(); + } + + void clearMessages() { + _messages.clear(); + } + + ShopInBitTicket toIsarTicket() { + return ShopInBitTicket() + ..ticketId = _ticketId ?? "" + ..displayName = _displayName + ..category = _category ?? ShopInBitCategory.concierge + ..status = _status + ..requestDescription = _requestDescription + ..deliveryCountry = _deliveryCountry + ..offerProductName = _offerProductName + ..offerPrice = _offerPrice + ..shippingName = _shippingName + ..shippingStreet = _shippingStreet + ..shippingCity = _shippingCity + ..shippingPostalCode = _shippingPostalCode + ..shippingCountry = _shippingCountry + ..paymentMethod = _paymentMethod + ..apiTicketId = _apiTicketId + ..messages = _messages + .map( + (m) => ShopInBitTicketMessage() + ..text = m.text + ..timestamp = m.timestamp + ..isFromUser = m.isFromUser, + ) + .toList() + ..createdAt = DateTime.now(); + } + + static ShopInBitOrderModel fromIsarTicket(ShopInBitTicket ticket) { + return ShopInBitOrderModel() + .._displayName = ticket.displayName + .._category = ticket.category + .._apiTicketId = ticket.apiTicketId + .._ticketId = ticket.ticketId + .._status = ticket.status + .._requestDescription = ticket.requestDescription + .._deliveryCountry = ticket.deliveryCountry + .._offerProductName = ticket.offerProductName + .._offerPrice = ticket.offerPrice + .._shippingName = ticket.shippingName + .._shippingStreet = ticket.shippingStreet + .._shippingCity = ticket.shippingCity + .._shippingPostalCode = ticket.shippingPostalCode + .._shippingCountry = ticket.shippingCountry + .._paymentMethod = ticket.paymentMethod + .._messages = ticket.messages + .map( + (m) => ShopInBitMessage( + text: m.text, + timestamp: m.timestamp, + isFromUser: m.isFromUser, + ), + ) + .toList(); + } + + static ShopInBitOrderStatus statusFromTicketState(TicketState state) { + switch (state) { + case TicketState.newTicket: + return ShopInBitOrderStatus.pending; + case TicketState.checking: + case TicketState.inProgress: + case TicketState.replyNeeded: + return ShopInBitOrderStatus.reviewing; + case TicketState.offerAvailable: + return ShopInBitOrderStatus.offerAvailable; + case TicketState.clearing: + return ShopInBitOrderStatus.accepted; + case TicketState.pendingClose: + return ShopInBitOrderStatus.paymentPending; + case TicketState.shipped: + return ShopInBitOrderStatus.shipping; + case TicketState.fulfilled: + return ShopInBitOrderStatus.delivered; + case TicketState.closed: + case TicketState.merged: + return ShopInBitOrderStatus.closed; + case TicketState.closedCancelled: + return ShopInBitOrderStatus.cancelled; + case TicketState.refunded: + return ShopInBitOrderStatus.refunded; + } + } +} diff --git a/lib/networking/http.dart b/lib/networking/http.dart index 4771a10acc..efa997e64a 100644 --- a/lib/networking/http.dart +++ b/lib/networking/http.dart @@ -87,6 +87,65 @@ class HTTP { } } + Future patch({ + required Uri url, + Map? headers, + Object? body, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final httpClient = HttpClient(); + try { + if (proxyInfo != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final HttpClientRequest request = await httpClient.patchUrl(url); + + if (headers != null) { + headers.forEach((key, value) => request.headers.add(key, value)); + } + + request.write(body); + + final response = await request.close(); + return Response(await _bodyBytes(response), response.statusCode); + } catch (e, s) { + Logging.instance.w("HTTP.patch() rethrew: ", error: e, stackTrace: s); + rethrow; + } finally { + httpClient.close(force: true); + } + } + + Future delete({ + required Uri url, + Map? headers, + required ({InternetAddress host, int port})? proxyInfo, + }) async { + final httpClient = HttpClient(); + try { + if (proxyInfo != null) { + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final HttpClientRequest request = await httpClient.deleteUrl(url); + + if (headers != null) { + headers.forEach((key, value) => request.headers.add(key, value)); + } + + final response = await request.close(); + return Response(await _bodyBytes(response), response.statusCode); + } catch (e, s) { + Logging.instance.w("HTTP.delete() rethrew: ", error: e, stackTrace: s); + rethrow; + } finally { + httpClient.close(force: true); + } + } + Future _bodyBytes(HttpClientResponse response) { final completer = Completer(); final List bytes = []; diff --git a/lib/notifications/show_flush_bar.dart b/lib/notifications/show_flush_bar.dart index b955a41ec2..8bbd88cc20 100644 --- a/lib/notifications/show_flush_bar.dart +++ b/lib/notifications/show_flush_bar.dart @@ -8,8 +8,9 @@ * */ +import 'dart:async'; + import 'package:another_flushbar/flushbar.dart'; -import 'package:another_flushbar/flushbar_route.dart' as flushRoute; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; @@ -26,6 +27,9 @@ Future showFloatingFlushBar({ required BuildContext context, Duration? duration = const Duration(milliseconds: 1500), FlushbarPosition flushbarPosition = FlushbarPosition.TOP, + @Deprecated( + 'onTap is non-functional -- toasts are fully passive with IgnorePointer', + ) VoidCallback? onTap, }) { Color bg; @@ -45,34 +49,126 @@ Future showFloatingFlushBar({ break; } final bar = Flushbar( - onTap: (_) { - onTap?.call(); - }, + onTap: null, + isDismissible: false, icon: iconAsset != null - ? SvgPicture.asset( - iconAsset, - height: 16, - width: 16, - color: fg, - ) + ? SvgPicture.asset(iconAsset, height: 16, width: 16, color: fg) : null, message: message, messageColor: fg, flushbarPosition: flushbarPosition, backgroundColor: bg, - duration: duration, + duration: null, flushbarStyle: FlushbarStyle.FLOATING, - borderRadius: BorderRadius.circular( - Constants.size.circularBorderRadius, - ), + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), margin: const EdgeInsets.all(20), maxWidth: 550, ); - final _route = flushRoute.showFlushbar( - context: context, - flushbar: bar, + final completer = Completer(); + final overlay = Overlay.of(context, rootOverlay: true); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => _OverlayFlushbar( + animationDuration: const Duration(seconds: 1), + displayDuration: duration, + forwardCurve: Curves.easeOutCirc, + reverseCurve: Curves.easeOutCirc, + initialAlignment: const Alignment(-1.0, -2.0), + endAlignment: const Alignment(-1.0, -1.0), + onDismiss: () { + entry.remove(); + if (!completer.isCompleted) { + completer.complete(); + } + }, + child: SafeArea( + child: Container(margin: const EdgeInsets.all(20), child: bar), + ), + ), ); + overlay.insert(entry); + return completer.future; +} + +class _OverlayFlushbar extends StatefulWidget { + const _OverlayFlushbar({ + required this.child, + required this.animationDuration, + required this.forwardCurve, + required this.reverseCurve, + required this.initialAlignment, + required this.endAlignment, + required this.onDismiss, + this.displayDuration, + }); + + final Widget child; + final Duration animationDuration; + final Duration? displayDuration; + final Curve forwardCurve; + final Curve reverseCurve; + final Alignment initialAlignment; + final Alignment endAlignment; + final VoidCallback onDismiss; + + @override + State<_OverlayFlushbar> createState() => _OverlayFlushbarState(); +} - return Navigator.of(context, rootNavigator: true).push(_route); +class _OverlayFlushbarState extends State<_OverlayFlushbar> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + Timer? _timer; + bool _dismissed = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + _animation = + AlignmentTween( + begin: widget.initialAlignment, + end: widget.endAlignment, + ).animate( + CurvedAnimation( + parent: _controller, + curve: widget.forwardCurve, + reverseCurve: widget.reverseCurve, + ), + ); + _controller.forward(); + if (widget.displayDuration != null) { + _timer = Timer(widget.displayDuration!, _dismiss); + } + } + + void _dismiss() { + if (_dismissed) return; + _dismissed = true; + _controller.reverse().then((_) { + if (mounted) { + widget.onDismiss(); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlignTransition( + alignment: _animation, + child: IgnorePointer(child: widget.child), + ); + } } diff --git a/lib/pages/already_running_view.dart b/lib/pages/already_running_view.dart new file mode 100644 index 0000000000..1678276e55 --- /dev/null +++ b/lib/pages/already_running_view.dart @@ -0,0 +1,191 @@ +/* + * This file is part of Stack Wallet. + * + * Copyright (c) 2023 Cypher Stack + * All Rights Reserved. + * The code is distributed under GPLv3 license, see LICENSE file for details. + * Generated by Cypher Stack on 2023-05-26 + * + */ + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../app_config.dart'; +import '../themes/stack_colors.dart'; +import '../themes/theme_providers.dart'; +import '../themes/theme_service.dart'; +import '../utilities/stack_file_system.dart'; +import '../utilities/text_styles.dart'; +import '../utilities/util.dart'; +import '../widgets/app_icon.dart'; +import '../widgets/background.dart'; + +/// Root app widget for the "already running" error path. +/// +/// Mirrors the theme bootstrap performed by [MaterialAppWithTheme] in main.dart +/// but without touching Hive. Requires Isar + ThemeService to already be +/// initialized before [runApp] is called. +class AlreadyRunningApp extends ConsumerStatefulWidget { + const AlreadyRunningApp({super.key}); + + @override + ConsumerState createState() => _AlreadyRunningAppState(); +} + +class _AlreadyRunningAppState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(applicationThemesDirectoryPathProvider.notifier).state = + StackFileSystem.themesDir!.path; + // The first instance already verified/installed the light theme, so + // getTheme cannot return null here. + ref.read(themeProvider.state).state = ref + .read(pThemeService) + .getTheme(themeId: "light")!; + }); + } + + @override + Widget build(BuildContext context) { + final colorScheme = ref.watch(colorProvider.state).state; + return MaterialApp( + debugShowCheckedModeBanner: false, + title: AppConfig.appName, + theme: ThemeData( + extensions: [colorScheme], + fontFamily: GoogleFonts.inter().fontFamily, + splashColor: Colors.transparent, + ), + home: const AlreadyRunningView(), + ); + } +} + +/// Error screen shown when this is a second instance of the app. +/// +/// Mirrors [IntroView]'s layout: themed background, logo, app name heading, +/// short description subtitle, then the error message (in label style, smaller +/// than the subtitle) in place of the action buttons. +class AlreadyRunningView extends ConsumerWidget { + const AlreadyRunningView({super.key}); + + static const _errorMessage = + "${AppConfig.appName} is already running. " + "Close the other window and try again."; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = Util.isDesktop; + final colors = Theme.of(context).extension()!; + final stack = ref.watch( + themeProvider.select((value) => value.assets.stack), + ); + + return Background( + child: Scaffold( + backgroundColor: colors.background, + body: SafeArea( + child: Center( + child: !isDesktop + ? Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(flex: 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: SizedBox( + width: 266, + height: 266, + child: stack.endsWith(".png") + ? Image.file(File(stack)) + : SvgPicture.file( + File(stack), + width: 266, + height: 266, + ), + ), + ), + ), + const Spacer(flex: 1), + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1(context), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: Text( + AppConfig.shortDescriptionText, + textAlign: TextAlign.center, + style: STextStyles.subtitle(context), + ), + ), + const Spacer(flex: 4), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + child: Text( + _errorMessage, + textAlign: TextAlign.center, + style: STextStyles.label(context), + ), + ), + ], + ) + : SizedBox( + width: 350, + height: 540, + child: Column( + children: [ + const Spacer(flex: 2), + const SizedBox( + width: 130, + height: 130, + child: AppIcon(), + ), + const Spacer(flex: 42), + Text( + AppConfig.appName, + textAlign: TextAlign.center, + style: STextStyles.pageTitleH1( + context, + ).copyWith(fontSize: 40), + ), + const Spacer(flex: 24), + Text( + AppConfig.shortDescriptionText, + textAlign: TextAlign.center, + style: STextStyles.subtitle( + context, + ).copyWith(fontSize: 24), + ), + const Spacer(flex: 42), + Text( + _errorMessage, + textAlign: TextAlign.center, + style: STextStyles.label( + context, + ).copyWith(fontSize: 18), + ), + const Spacer(flex: 65), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_card_detail_view.dart b/lib/pages/cakepay/cakepay_card_detail_view.dart new file mode 100644 index 0000000000..38dbc92b2c --- /dev/null +++ b/lib/pages/cakepay/cakepay_card_detail_view.dart @@ -0,0 +1,655 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +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'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/stack_text_field.dart'; +import 'cakepay_order_view.dart'; + +class CakePayCardDetailView extends StatefulWidget { + const CakePayCardDetailView({super.key, required this.card}); + + static const String routeName = "/cakePayCardDetail"; + + final CakePayCard card; + + @override + State createState() => _CakePayCardDetailViewState(); +} + +class _CakePayCardDetailViewState extends State { + late CakePayCard _card; + bool _purchasing = false; + double? _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(() {}); + }); + } + + @override + void dispose() { + _customAmountController.dispose(); + _customAmountFocusNode.dispose(); + _emailController.dispose(); + _emailFocusNode.dispose(); + super.dispose(); + } + + String get _priceString { + if (_card.isFixedDenomination && _selectedDenomination != null) { + return _selectedDenomination!.toStringAsFixed(2); + } + return _customAmountController.text.trim(); + } + + bool get _canPurchase { + 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; + if (_card.isRangeDenomination) { + if (_card.minValue != null && parsed < _card.minValue!) return false; + if (_card.maxValue != null && parsed > _card.maxValue!) return false; + } + return true; + } + + Future _showOpenBrowserWarning(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; + } + + Future _openTerms() async { + const url = "https://cakepay.com/terms/"; + if (await _showOpenBrowserWarning(url)) { + await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + } + } + + Future _purchase() async { + if (!_canPurchase) return; + setState(() => _purchasing = true); + + final resp = await CakePayService.instance.client.createOrder( + cardId: _card.id, + price: _priceString, + quantity: _quantity > 1 ? _quantity : null, + userEmail: _emailController.text.trim(), + confirmsNoVpn: true, + confirmsVoidedRefund: true, + confirmsTermsAgreed: true, + ); + + if (mounted) { + setState(() => _purchasing = false); + 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); + + 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 { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + 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(), + ), + ); + }, + ); + } + } + } + + @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, + 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, + ), + 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 SizedBox.shrink(); + + final quantityRow = Row( + children: [ + Text( + "Quantity", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 20), + onPressed: _quantity > 1 ? () => setState(() => _quantity--) : null, + ), + Text( + "$_quantity", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 20), + onPressed: () => setState(() => _quantity++), + ), + ], + ); + + final termsCheckbox = GestureDetector( + onTap: () => setState(() => _termsAccepted = !_termsAccepted), + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + height: 26, + child: IgnorePointer( + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _termsAccepted, + onChanged: (_) {}, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.w500_14(context), + children: [ + const TextSpan(text: "I agree to the "), + TextSpan( + text: "terms and conditions", + style: STextStyles.richLink(context) + .copyWith( + fontSize: isDesktop ? null : 14, + ), + recognizer: TapGestureRecognizer() + ..onTap = _openTerms, + ), + const TextSpan( + text: ", confirm I am not using a VPN, " + "and understand refunds are voided. " + "I understand that the gift card " + "will be delivered to the listed " + "email.", + ), + ], + ), + ), + ), + ], + ), + ), + ); + + 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_confirm_send_view.dart b/lib/pages/cakepay/cakepay_confirm_send_view.dart new file mode 100644 index 0000000000..41ea7a14bf --- /dev/null +++ b/lib/pages/cakepay/cakepay_confirm_send_view.dart @@ -0,0 +1,625 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../models/isar/models/isar_models.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../pinpad_views/lock_screen_view.dart'; +import '../send_view/sub_widgets/sending_transaction_dialog.dart'; +import '../wallet_view/wallet_view.dart'; + +class CakePayConfirmSendView extends ConsumerStatefulWidget { + const CakePayConfirmSendView({ + super.key, + required this.txData, + required this.walletId, + this.routeOnSuccessName = WalletView.routeName, + required this.orderId, + }); + + static const String routeName = "/cakePayConfirmSend"; + + final TxData txData; + final String walletId; + final String routeOnSuccessName; + final String orderId; + + @override + ConsumerState createState() => + _CakePayConfirmSendViewState(); +} + +class _CakePayConfirmSendViewState + extends ConsumerState { + late final String walletId; + late final String routeOnSuccessName; + + final isDesktop = Util.isDesktop; + + Future _attemptSend(BuildContext context) async { + final parentWallet = ref.read(pWallets).getWallet(walletId); + final coin = parentWallet.info.coin; + + final sendProgressController = ProgressAndSuccessController(); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return SendingTransactionDialog( + coin: coin, + controller: sendProgressController, + ); + }, + ), + ); + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + late String txid; + final String note = widget.txData.note ?? ""; + + try { + final txidFuture = parentWallet.confirmSend(txData: widget.txData); + + unawaited(parentWallet.refresh()); + + final results = await Future.wait([txidFuture, time]); + + sendProgressController.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); + + txid = (results.first as TxData).txid!; + + await ref + .read(mainDBProvider) + .putTransactionNote( + TransactionNote(walletId: walletId, txid: txid, value: note), + ); + + if (context.mounted) { + // pop sending dialog (pushed via showDialog which uses root navigator) + Navigator.of(context, rootNavigator: true).pop(); + + if (Util.isDesktop) { + // pop the confirm send desktop dialog + Navigator.of(context, rootNavigator: true).pop(); + } + + Navigator.of(context).popUntil(ModalRoute.withName(routeOnSuccessName)); + + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Payment sent! Check order status for updates.", + context: context, + ), + ); + } + } + } catch (e, s) { + Logging.instance.e( + "Broadcast transaction failed: ", + error: e, + stackTrace: s, + ); + + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Broadcast transaction failed", + message: e.toString(), + 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(); + }, + ), + ); + }, + ); + } + } + } + + Future _confirmSend() async { + final dynamic unlocked; + + final coin = ref.read(pWalletCoin(walletId)); + + if (Util.isDesktop) { + unlocked = await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: DesktopAuthSend(coin: coin), + ), + ], + ), + ), + ); + } else { + unlocked = await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), + settings: const RouteSettings(name: "/confirmsendlockscreen"), + ), + ); + } + + if (unlocked is bool && mounted) { + if (unlocked) { + await _attemptSend(context); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid passphrase", + context: context, + ), + ); + } + } + } + + @override + void initState() { + walletId = widget.walletId; + routeOnSuccessName = widget.routeOnSuccessName; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final coin = ref.watch(pWalletCoin(walletId)); + + return ConditionalParent( + condition: !isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, 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: child, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + }, + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + maxWidth: 580, + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: 6), + const AppBarBackButton(isCompact: true, iconSize: 23), + const SizedBox(width: 12), + Text( + "Confirm ${coin.ticker} transaction", + style: STextStyles.desktopH3(context), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + borderColor: Theme.of( + context, + ).extension()!.background, + child: child, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + "Transaction fee", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ], + ), + const SizedBox(height: 10), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + ref + .watch(pAmountFormatter(coin)) + .format(widget.txData.fee!), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + final total = amount + fee; + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConditionalParent( + condition: isDesktop, + builder: (child) => Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.background, + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.size.circularBorderRadius), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row(children: [child]), + ), + ), + child: Text( + "Send ${coin.ticker}", + style: isDesktop + ? STextStyles.desktopTextMedium(context) + : STextStyles.pageTitleH1(context), + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Send from", style: STextStyles.smallMed12(context)), + const SizedBox(height: 4), + Text( + ref.watch(pWalletName(walletId)), + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "CakePay address", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + widget.txData.recipients!.first.address, + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Amount", style: STextStyles.smallMed12(context)), + Text( + ref + .watch(pAmountFormatter(coin)) + .format(widget.txData.amountWithoutChange!), + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + ), + Text( + ref + .watch(pAmountFormatter(coin)) + .format(widget.txData.fee!), + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Note", style: STextStyles.smallMed12(context)), + const SizedBox(height: 4), + Text( + widget.txData.note ?? "", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Order ID", style: STextStyles.smallMed12(context)), + Text( + widget.orderId.length > 8 + ? "${widget.orderId.substring(0, 8)}..." + : widget.orderId, + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + if (!isDesktop) const SizedBox(height: 12), + if (!isDesktop) + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + final total = amount + fee; + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + if (!isDesktop) const SizedBox(height: 16), + if (!isDesktop) const Spacer(), + if (!isDesktop) + PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_order_view.dart b/lib/pages/cakepay/cakepay_order_view.dart new file mode 100644 index 0000000000..9f3b9fbfdd --- /dev/null +++ b/lib/pages/cakepay/cakepay_order_view.dart @@ -0,0 +1,1052 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app_config.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../services/cakepay/cakepay_service.dart'; +import '../../services/cakepay/src/models/order.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +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/qr.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'cakepay_send_from_view.dart'; + +class CakePayOrderView extends ConsumerStatefulWidget { + const CakePayOrderView({super.key, required this.orderId}); + + static const String routeName = "/cakePayOrder"; + + final String orderId; + + @override + ConsumerState createState() => _CakePayOrderViewState(); +} + +class _CakePayOrderViewState extends ConsumerState { + CakePayOrder? _order; + bool _loading = true; + Timer? _pollTimer; + Timer? _countdownTimer; + Duration _timeRemaining = Duration.zero; + int _selectedPaymentMethod = 0; + + @override + void initState() { + super.initState(); + _loadOrder(); + _pollTimer = Timer.periodic( + const Duration(seconds: 15), + (_) => _loadOrder(), + ); + } + + @override + void dispose() { + _pollTimer?.cancel(); + _countdownTimer?.cancel(); + super.dispose(); + } + + void _startCountdown() { + _countdownTimer?.cancel(); + _updateTimeRemaining(); + _countdownTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _updateTimeRemaining(), + ); + } + + void _updateTimeRemaining() { + if (_order?.expirationTime == null) return; + final expiresAt = DateTime.fromMillisecondsSinceEpoch( + _order!.expirationTime!, + ); + final remaining = expiresAt.difference(DateTime.now()); + if (mounted) { + setState(() { + _timeRemaining = remaining.isNegative ? Duration.zero : remaining; + }); + } + if (remaining.isNegative) { + _countdownTimer?.cancel(); + } + } + + String _formatDuration(Duration d) { + if (d.isNegative || d == Duration.zero) return "Expired"; + final minutes = d.inMinutes; + final seconds = d.inSeconds % 60; + if (d.inHours > 0) { + return "${d.inHours}h ${minutes % 60}m ${seconds}s"; + } + return "${minutes}m ${seconds}s"; + } + + void _navigateToSendFrom({ + required CryptoCurrency coin, + required Amount? amount, + required String address, + required String orderId, + }) { + final isDesktop = Util.isDesktop; + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + builder: (_) => CakePaySendFromView( + coin: coin, + amount: amount, + address: address, + orderId: orderId, + shouldPopRoot: true, + ), + ); + } else { + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => CakePaySendFromView( + coin: coin, + amount: amount, + address: address, + orderId: orderId, + ), + settings: const RouteSettings(name: CakePaySendFromView.routeName), + ), + ); + } + } + + /// Resolve an API ticker (e.g. "LTC_MWEB") to a Stack Wallet coin, + /// falling back to the base ticker before "_" if the full one isn't + /// recognised. + CryptoCurrency? _resolveCoin(String apiTicker) { + final ticker = apiTicker.toUpperCase(); + var coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin == null && + ticker.contains('_') && + !ticker.endsWith('_LN')) { + coin = AppConfig.getCryptoCurrencyForTicker( + ticker.split('_').first, + ); + } + return coin; + } + + /// Pretty-print an API ticker for display. + String _tickerLabel(String apiTicker) { + switch (apiTicker.toUpperCase()) { + case 'BTC_LN': + return 'BTC (LN)'; + case 'LTC_MWEB': + return 'LTC (MWEB)'; + default: + return apiTicker.toUpperCase(); + } + } + + void _payWithOption(CakePayPaymentOption option, String orderId) { + final label = _tickerLabel(option.ticker); + final coin = _resolveCoin(option.ticker); + + if (option.address.trim().isEmpty) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "No payment address available for $label", + context: context, + ); + return; + } + + if (coin == null) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "No wallet support for $label", + context: context, + ); + return; + } + + final hasWallet = ref + .read(pWallets) + .wallets + .any((w) => w.info.coin == coin); + + if (!hasWallet) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: "No $label wallet found. Create one first.", + context: context, + ); + return; + } + + Amount? amount; + try { + amount = Amount.fromDecimal( + Decimal.parse(option.amountFrom.toString()), + fractionDigits: coin.fractionDigits, + ); + } catch (_) {} + + _navigateToSendFrom( + coin: coin, + amount: amount, + address: option.address, + orderId: orderId, + ); + } + + Future _loadOrder() async { + final resp = await CakePayService.instance.client.getOrder(widget.orderId); + if (mounted) { + 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; + if (_isTerminal(_order!.status)) { + _pollTimer?.cancel(); + _countdownTimer?.cancel(); + } else if (_order!.expirationTime != null) { + _startCountdown(); + } + } + }); + } + } + + bool _isTerminal(CakePayOrderStatus status) { + return status == CakePayOrderStatus.complete || + status == CakePayOrderStatus.expired || + status == CakePayOrderStatus.failed || + status == CakePayOrderStatus.refunded; + } + + /// Whether the order has received payment and is being processed or + /// is already complete. Payment UI should be hidden for these. + bool _isPaidOrBeyond(CakePayOrderStatus status) { + return const { + CakePayOrderStatus.paid, + CakePayOrderStatus.pendingPurchase, + CakePayOrderStatus.purchaseProcessing, + CakePayOrderStatus.purchased, + CakePayOrderStatus.pendingEmail, + CakePayOrderStatus.complete, + }.contains(status); + } + + /// Whether payment UI (tabs, QR, address, pay button) should be shown. + bool _showPaymentUI(CakePayOrderStatus status) { + return !_isPaidOrBeyond(status) && + status != CakePayOrderStatus.expired && + status != CakePayOrderStatus.failed && + status != CakePayOrderStatus.pendingRefund && + status != CakePayOrderStatus.refunded; + } + + /// Copyable order ID and created-at timestamp for terminal state banners. + List _orderInfoWidgets(CakePayOrder order, bool isDesktop) { + final subtitleStyle = isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context); + + return [ + // Copyable order ID. + RoundedWhiteContainer( + child: GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: order.orderId)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Order ID copied", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Order ID", style: subtitleStyle), + const SizedBox(height: 4), + Text( + order.orderId, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + Icon( + Icons.copy, + size: 14, + color: Theme.of(context) + .extension()! + .accentColorBlue, + ), + ], + ), + ), + ), + // Created-at timestamp. + if (order.createdAt != null) ...[ + SizedBox(height: isDesktop ? 8 : 6), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Created", style: subtitleStyle), + Text(order.createdAt!, style: subtitleStyle), + ], + ), + ), + ], + ]; + } + + 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; + + if (_loading) { + return _scaffold( + isDesktop: isDesktop, + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + if (_order == null) { + return _scaffold( + isDesktop: isDesktop, + child: Center( + child: Text( + "Failed to load order", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ), + ); + } + + 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, + ], + ), + SizedBox(height: isDesktop ? 8 : 6), + RoundedWhiteContainer( + child: GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: order.orderId)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Order ID copied", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Order ID", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + order.orderId, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(width: 6), + Icon( + Icons.copy, + size: 14, + color: Theme.of(context) + .extension()! + .accentColorBlue, + ), + ], + ), + ], + ), + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + ]; + + if (order.amountUsd != null) { + details.add( + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + "\$${order.amountUsd} USD", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 16 : 12)); + } + + if (order.cards != null && order.cards!.isNotEmpty) { + for (final item in order.cards!) { + details.add( + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name ?? "Gift Card", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + if (item.priceValue != null) ...[ + const SizedBox(height: 4), + Text( + "${item.priceValue} ${item.currencyCode ?? ''}".trim(), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + if (item.priceUsd != null) ...[ + const SizedBox(height: 2), + Text( + item.priceUsd!, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ], + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + } + + // Commission / markup info. + if (order.commission != null || order.markupPercent != null) { + details.add( + RoundedWhiteContainer( + child: Column( + children: [ + if (order.commission != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Commission", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + order.commission!, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + if (order.commission != null && order.markupPercent != null) + const SizedBox(height: 4), + if (order.markupPercent != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Markup", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + "${order.markupPercent!.toStringAsFixed(2)}%", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + // Expiration countdown. + if (order.expirationTime != null) { + final isExpired = _timeRemaining == Duration.zero; + details.add( + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Time remaining", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + _formatDuration(_timeRemaining), + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: isExpired + ? Theme.of( + context, + ).extension()!.accentColorRed + : _timeRemaining.inMinutes < 5 + ? Theme.of( + context, + ).extension()!.accentColorOrange + : null, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + // --- Status-dependent payment section --- + final status = order.status; + + // Banner for paid / processing states. + if (_isPaidOrBeyond(status)) { + details.add(SizedBox(height: isDesktop ? 16 : 12)); + details.add( + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.check_circle, + size: 20, + color: Theme.of(context) + .extension()! + .accentColorGreen, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + status == CakePayOrderStatus.complete + ? "Order complete." + : "Payment received.", + style: (isDesktop + ? STextStyles + .desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + )) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorGreen, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + "Your gift card details will be sent to " + "the email address provided when creating " + "the order.", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + details.addAll(_orderInfoWidgets(order, isDesktop)); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + details.add( + const PrimaryButton( + label: "ORDER PAID", + enabled: false, + onPressed: null, + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + // Banner for expired / failed / refund states. + if (status == CakePayOrderStatus.expired || + status == CakePayOrderStatus.failed || + status == CakePayOrderStatus.pendingRefund || + status == CakePayOrderStatus.refunded) { + details.add(SizedBox(height: isDesktop ? 16 : 12)); + details.add( + RoundedWhiteContainer( + child: Row( + children: [ + Icon( + Icons.cancel, + size: 20, + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _statusLabel(status), + style: (isDesktop + ? STextStyles + .desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + )) + .copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + details.addAll(_orderInfoWidgets(order, isDesktop)); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + // Payment UI: tabs + QR + address + pay button. + // Only shown for states that still accept payment. + if (_showPaymentUI(status) && + paymentOptions != null && + paymentOptions.isNotEmpty) { + // Sort so BTC_LN always appears last. + final options = paymentOptions.values.toList() + ..sort((a, b) { + final aLn = a.ticker.toUpperCase() == 'BTC_LN'; + final bLn = b.ticker.toUpperCase() == 'BTC_LN'; + if (aLn && !bLn) return 1; + if (!aLn && bLn) return -1; + return 0; + }); + if (_selectedPaymentMethod >= options.length) { + _selectedPaymentMethod = 0; + } + final selected = options[_selectedPaymentMethod]; + final label = _tickerLabel(selected.ticker); + final coin = _resolveCoin(selected.ticker); + final bool hasWallet = + coin != null && + ref.watch(pWallets).wallets.any( + (w) => w.info.coin == coin, + ); + + details.add(SizedBox(height: isDesktop ? 8 : 4)); + details.add( + Text( + "Pay with", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + + // Tab selector. + details.add( + Row( + children: List.generate(options.length, (index) { + final isSelected = + _selectedPaymentMethod == index; + return Expanded( + child: GestureDetector( + onTap: () => setState( + () => _selectedPaymentMethod = index, + ), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 10, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected + ? Theme.of(context) + .extension()! + .accentColorBlue + : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + _tickerLabel(options[index].ticker), + textAlign: TextAlign.center, + style: (isDesktop + ? STextStyles + .desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + )) + .copyWith( + color: isSelected + ? Theme.of(context) + .extension()! + .accentColorBlue + : null, + fontWeight: isSelected + ? FontWeight.w600 + : null, + ), + ), + ), + ), + ); + }), + ), + ); + + details.add(SizedBox(height: isDesktop ? 16 : 12)); + + // QR code for the selected payment address. + if (selected.address.isNotEmpty) { + details.add( + Center( + child: QR( + data: selected.address, + size: isDesktop ? 200 : 180, + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 16 : 12)); + } + + // Selected method details. + details.add( + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + "Amount", + style: isDesktop + ? STextStyles + .desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + ), + ), + Text( + "${selected.amountFrom} $label", + style: isDesktop + ? STextStyles.desktopTextSmall( + context, + ) + : STextStyles.titleBold12(context), + ), + ], + ), + const SizedBox(height: 8), + GestureDetector( + onTap: () { + Clipboard.setData( + ClipboardData(text: selected.address), + ); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + "$label address", + style: isDesktop + ? STextStyles + .desktopTextExtraExtraSmall( + context, + ) + : STextStyles + .itemSubtitle12( + context, + ), + ), + const Spacer(), + Icon( + Icons.copy, + size: 14, + color: Theme.of(context) + .extension()! + .accentColorBlue, + ), + const SizedBox(width: 4), + Text( + "Copy", + style: + STextStyles.link2(context), + ), + ], + ), + const SizedBox(height: 4), + Text( + selected.address, + style: isDesktop + ? STextStyles + .desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + PrimaryButton( + label: hasWallet + ? "Pay with $label" + : "$label (no wallet)", + enabled: hasWallet, + onPressed: hasWallet + ? () => _payWithOption( + selected, + order.orderId, + ) + : null, + ), + ], + ), + ), + ); + details.add(SizedBox(height: isDesktop ? 8 : 6)); + } + + final content = SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: details, + ), + ); + + return _scaffold(isDesktop: isDesktop, child: content); + } + + 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, + ), + 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("Order", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + child: child, + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_orders_view.dart b/lib/pages/cakepay/cakepay_orders_view.dart new file mode 100644 index 0000000000..77a31ae212 --- /dev/null +++ b/lib/pages/cakepay/cakepay_orders_view.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; + +import '../../services/cakepay/cakepay_service.dart'; +import '../../services/cakepay/src/models/order.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 '../../widgets/rounded_white_container.dart'; +import 'cakepay_order_view.dart'; + +class CakePayOrdersView extends StatefulWidget { + const CakePayOrdersView({super.key}); + + static const String routeName = "/cakePayOrders"; + + @override + State createState() => _CakePayOrdersViewState(); +} + +class _CakePayOrdersViewState extends State { + List _orders = []; + bool _syncing = false; + + @override + void initState() { + super.initState(); + _syncFromApi(); + } + + /// Fetch each locally-tracked order ID individually via getOrder() + /// (which works with the seller API key, unlike getMyOrders()). + /// Mirrors ShopInBit's _syncFromApi() pattern. + Future _syncFromApi() async { + setState(() => _syncing = true); + try { + final orderIds = 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); + } + } + + if (mounted) { + setState(() { + _orders = results; + }); + } + } catch (_) { + // Fall back to empty list — no local cache to fall back on + } finally { + if (mounted) { + setState(() => _syncing = false); + } + } + } + + 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; + + final list = _orders.isEmpty + ? Center( + child: Text( + _syncing ? "Loading orders..." : "No orders yet", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ) + : ListView.separated( + shrinkWrap: isDesktop, + primary: isDesktop ? false : null, + itemCount: _orders.length, + separatorBuilder: (_, __) => SizedBox(height: isDesktop ? 16 : 12), + itemBuilder: (context, index) { + final order = _orders[index]; + return GestureDetector( + onTap: () { + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + builder: (_) => CakePayOrderView(orderId: order.orderId), + ); + } else { + Navigator.of(context).pushNamed( + CakePayOrderView.routeName, + arguments: order.orderId, + ); + } + }, + child: RoundedWhiteContainer( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + order.orderId.length > 8 + ? "${order.orderId.substring(0, 8)}..." + : order.orderId, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: _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, + ), + ), + ), + ), + ], + ), + if (order.amountUsd != null) ...[ + const SizedBox(height: 4), + Text( + "\$${order.amountUsd} USD", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + ], + ], + ), + ), + SizedBox(width: isDesktop ? 16 : 8), + Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ], + ), + ), + ); + }, + ); + + final content = Stack( + children: [ + list, + if (_syncing) + const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ); + + return ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 580, + maxHeight: 550, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "My Orders", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + 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("My Orders", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + child: content, + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_send_from_view.dart b/lib/pages/cakepay/cakepay_send_from_view.dart new file mode 100644 index 0000000000..4213625d36 --- /dev/null +++ b/lib/pages/cakepay/cakepay_send_from_view.dart @@ -0,0 +1,408 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../app_config.dart'; +import '../../models/isar/models/blockchain_data/address.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../themes/coin_icon_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../themes/theme_providers.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/intermediate/external_wallet.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/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 'cakepay_confirm_send_view.dart'; + +class CakePaySendFromView extends ConsumerStatefulWidget { + const CakePaySendFromView({ + super.key, + this.coin, + this.amount, + required this.address, + required this.orderId, + this.shouldPopRoot = false, + }); + + static const String routeName = "/cakePaySendFrom"; + + final CryptoCurrency? coin; + final Amount? amount; + final String address; + final String orderId; + final bool shouldPopRoot; + + @override + ConsumerState createState() => + _CakePaySendFromViewState(); +} + +class _CakePaySendFromViewState extends ConsumerState { + @override + Widget build(BuildContext context) { + final List walletIds; + if (widget.coin != null) { + walletIds = ref + .watch(pWallets) + .wallets + .where((e) => e.info.coin == widget.coin) + .map((e) => e.walletId) + .toList(); + } else { + walletIds = ref.watch(pWallets).wallets.map((e) => e.walletId).toList(); + } + + final isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: !isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("Send from", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ); + }, + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Send from ${AppConfig.prefix}", + style: STextStyles.desktopH3(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: widget.shouldPopRoot, + ).pop, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.amount != null && widget.coin != null + ? "You need to send ${ref.watch(pAmountFormatter(widget.coin!)).format(widget.amount!)}" + : "Select a wallet to pay", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + const SizedBox(height: 16), + ConditionalParent( + condition: !isDesktop, + builder: (child) => Expanded(child: child), + child: ListView.builder( + primary: isDesktop ? false : null, + shrinkWrap: isDesktop, + itemCount: walletIds.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: _CakePaySendFromCard( + walletId: walletIds[index], + amount: widget.amount, + address: widget.address, + orderId: widget.orderId, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _CakePaySendFromCard extends ConsumerStatefulWidget { + const _CakePaySendFromCard({ + required this.walletId, + this.amount, + required this.address, + required this.orderId, + }); + + final String walletId; + final Amount? amount; + final String address; + final String orderId; + + @override + ConsumerState<_CakePaySendFromCard> createState() => + _CakePaySendFromCardState(); +} + +class _CakePaySendFromCardState extends ConsumerState<_CakePaySendFromCard> { + Future _send() async { + final coin = ref.read(pWalletCoin(widget.walletId)); + final Amount? sendAmount = widget.amount; + + if (sendAmount == null) { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: "Payment amount not available yet", + 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(), + ), + ); + }, + ); + return; + } + + bool wasCancelled = false; + + try { + final wallet = ref.read(pWallets).getWallet(widget.walletId); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), + child: BuildingTransactionDialog( + coin: coin, + isSpark: false, + onCancel: () { + wasCancelled = true; + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + + if (wallet is ExternalWallet) { + await wallet.init(); + await wallet.open(); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + final addressType = + wallet.cryptoCurrency.getAddressType(widget.address) ?? + AddressType.unknown; + + final recipient = TxRecipient( + address: widget.address, + amount: sendAmount, + isChange: false, + addressType: addressType, + ); + + final txDataFuture = wallet.prepareSend( + txData: TxData( + recipients: [recipient], + feeRateType: FeeRateType.average, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + final txData = (results.first as TxData).copyWith( + note: "CakePay payment", + ); + + if (!wasCancelled) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + if (mounted) { + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => CakePayConfirmSendView( + txData: txData, + walletId: widget.walletId, + routeOnSuccessName: Util.isDesktop + ? DesktopHomeView.routeName + : HomeView.routeName, + orderId: widget.orderId, + ), + settings: const RouteSettings( + name: CakePayConfirmSendView.routeName, + ), + ), + ); + } + } + } catch (e, s) { + Logging.instance.e("$e\n$s", error: e, stackTrace: s); + if (mounted && !wasCancelled) { + Navigator.of(context, rootNavigator: true).pop(); + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: e.toString(), + 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(), + ), + ); + }, + ); + } + } + } + + @override + Widget build(BuildContext context) { + final coin = ref.watch(pWalletCoin(widget.walletId)); + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: MaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + key: Key("cakePayWalletKey_${widget.walletId}"), + padding: const EdgeInsets.all(8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () async { + if (mounted) unawaited(_send()); + }, + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: ref.watch(pCoinColor(coin)).withValues(alpha: 0.5), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ref.watch(pWalletName(widget.walletId)), + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + Text( + ref + .watch(pAmountFormatter(coin)) + .format( + ref.watch(pWalletBalance(widget.walletId)).spendable, + ), + style: STextStyles.itemSubtitle(context), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/cakepay/cakepay_vendors_view.dart b/lib/pages/cakepay/cakepay_vendors_view.dart new file mode 100644 index 0000000000..01d07ed044 --- /dev/null +++ b/lib/pages/cakepay/cakepay_vendors_view.dart @@ -0,0 +1,443 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../services/cakepay/cakepay_service.dart'; +import '../../services/cakepay/src/models/card.dart'; +import '../../services/cakepay/src/models/vendor.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/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/rounded_white_container.dart'; +import '../../widgets/stack_text_field.dart'; +import '../../utilities/assets.dart'; +import 'cakepay_card_detail_view.dart'; + +class CakePayVendorsView extends StatefulWidget { + const CakePayVendorsView({super.key}); + + static const String routeName = "/cakePayVendors"; + + @override + State createState() => _CakePayVendorsViewState(); +} + +class _CakePayVendorsViewState extends State { + List _vendors = []; + List _countryNames = []; + String? _selectedCountry; + bool _loading = true; + String? _error; + final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); + final _countrySearchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadVendors(); + } + + @override + void dispose() { + _searchController.dispose(); + _searchFocusNode.dispose(); + _countrySearchController.dispose(); + super.dispose(); + } + + /// Derive a country list from the loaded vendors so we don't need the + /// broken /marketplace/countries/ endpoint. + void _deriveCountries() { + final seen = {}; + final countries = []; + for (final v in _vendors) { + final c = v.country; + if (c != null && c.isNotEmpty && seen.add(c)) { + countries.add(c); + } + } + countries.sort(); + _countryNames = countries; + } + + Future _loadVendors() async { + setState(() { + _loading = true; + _error = null; + }); + final resp = await CakePayService.instance.client.getVendors( + country: _selectedCountry, + search: _searchController.text.trim().isNotEmpty + ? _searchController.text.trim() + : null, + ); + if (mounted) { + setState(() { + _loading = false; + if (!resp.hasError && resp.value != null) { + _vendors = resp.value!; + _deriveCountries(); + } else { + _error = resp.exception?.message ?? "Failed to load gift cards"; + } + }); + } + } + + List get _allCards { + final cards = []; + for (final vendor in _vendors) { + cards.addAll(vendor.cards.where((c) => c.available)); + } + return cards; + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final cards = _allCards; + + final searchField = ClipRRect( + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + "Search gift cards", + _searchFocusNode, + context, + ).copyWith( + prefixIcon: const Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 12), + child: Icon(Icons.search, size: 20), + ), + ), + onSubmitted: (_) => _loadVendors(), + ), + ); + + final countryDropdown = _countryNames.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(top: 12), + child: ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton2( + value: _selectedCountry, + isExpanded: true, + hint: Text( + "All countries", + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textFieldDefaultSearchIconLeft, + ) + : STextStyles.fieldLabel(context), + ), + items: [ + DropdownMenuItem( + value: null, + child: Text( + "All countries", + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + ) + : STextStyles.w500_14(context), + ), + ), + ..._countryNames.map( + (name) => DropdownMenuItem( + value: name, + child: Text( + name, + style: isDesktop + ? STextStyles.desktopTextExtraSmall( + context, + ).copyWith( + color: Theme.of(context) + .extension()! + .textFieldActiveText, + ) + : STextStyles.w500_14(context), + ), + ), + ), + ], + onMenuStateChange: (isOpen) { + if (!isOpen) { + _countrySearchController.clear(); + } + }, + onChanged: (value) { + setState(() => _selectedCountry = value); + _loadVendors(); + }, + 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, + colorFilter: ColorFilter.mode( + Theme.of(context) + .extension()! + .textFieldActiveSearchIconRight, + BlendMode.srcIn, + ), + ), + ), + ), + dropdownStyleData: DropdownStyleData( + offset: const Offset(0, -10), + 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) { + if (item.value == null) { + return "all countries".contains( + searchValue.toLowerCase(), + ); + } + return item.value!.toLowerCase().contains( + searchValue.toLowerCase(), + ); + }, + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ), + ), + ); + + final cardsList = _loading + ? const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : cards.isEmpty + ? Center( + child: Text( + _error ?? "No gift cards found", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ) + : ListView.separated( + shrinkWrap: isDesktop, + primary: isDesktop ? false : null, + itemCount: cards.length, + separatorBuilder: (_, __) => SizedBox(height: isDesktop ? 16 : 12), + itemBuilder: (context, index) { + final card = cards[index]; + return GestureDetector( + onTap: () { + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + builder: (_) => CakePayCardDetailView(card: card), + ); + } else { + Navigator.of(context).pushNamed( + CakePayCardDetailView.routeName, + arguments: card, + ); + } + }, + child: RoundedWhiteContainer( + child: Row( + children: [ + if (card.cardImageUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + card.cardImageUrl!, + width: isDesktop ? 60 : 48, + height: isDesktop ? 40 : 32, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Icon( + Icons.card_giftcard, + size: isDesktop ? 40 : 32, + ), + ), + ) + else + Icon(Icons.card_giftcard, size: isDesktop ? 40 : 32), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.name, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + card.denominationRange.isNotEmpty + ? "${card.denominationRange} ${card.currencyCode ?? ''}" + : card.currencyCode ?? '', + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ], + ), + ), + ); + }, + ); + + final body = Column( + children: [ + searchField, + countryDropdown, + SizedBox(height: isDesktop ? 16 : 12), + Expanded(child: cardsList), + ], + ); + + 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( + "Gift Cards", + 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 Cards", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ), + child: body, + ), + ); + } +} diff --git a/lib/pages/more_view/gift_cards_view.dart b/lib/pages/more_view/gift_cards_view.dart new file mode 100644 index 0000000000..9fcf82bbca --- /dev/null +++ b/lib/pages/more_view/gift_cards_view.dart @@ -0,0 +1,153 @@ +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/rounded_white_container.dart'; +import '../../widgets/tor_subscription.dart'; +import '../cakepay/cakepay_orders_view.dart'; +import '../cakepay/cakepay_vendors_view.dart'; + +class GiftCardsView extends ConsumerStatefulWidget { + const GiftCardsView({super.key}); + + static const String routeName = "/giftCardsView"; + + @override + ConsumerState createState() => _GiftCardsViewState(); +} + +class _GiftCardsViewState extends ConsumerState { + late bool _torEnabled; + + @override + void initState() { + _torEnabled = AppConfig.hasFeature(AppFeature.tor) + ? ref.read(pTorService).status != TorConnectionStatus.disconnected + : false; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return TorSubscription( + onTorStatusChanged: (status) { + setState(() { + _torEnabled = status != TorConnectionStatus.disconnected; + }); + }, + child: Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text("Gift cards", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SvgPicture.asset( + Assets.svg.creditCard, + width: 32, + height: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "CakePay", + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + Text( + "Purchase gift cards with cryptocurrency", + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textSubtitle1, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + if (_torEnabled) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + "CakePay is not available while Tor is enabled", + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + Row( + children: [ + Expanded( + child: PrimaryButton( + label: "Browse", + enabled: !_torEnabled, + onPressed: () { + Navigator.of( + context, + ).pushNamed(CakePayVendorsView.routeName); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: SecondaryButton( + label: "My Orders", + onPressed: () { + Navigator.of( + context, + ).pushNamed(CakePayOrdersView.routeName); + }, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/more_view/services_view.dart b/lib/pages/more_view/services_view.dart new file mode 100644 index 0000000000..fa4131f8a4 --- /dev/null +++ b/lib/pages/more_view/services_view.dart @@ -0,0 +1,337 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.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 '../../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/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 { + const ServicesView({super.key}); + + static const String routeName = "/servicesView"; + + @override + State createState() => _ServicesViewState(); +} + +class _ServicesViewState extends State { + Future _showOpenBrowserWarning(BuildContext context, String url) async { + final uri = Uri.parse(url); + final shouldContinue = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => 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; + } + + void _showShopDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: true, + builder: (dialogContext) => StackDialogBase( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("ShopinBit", style: STextStyles.pageTitleH2(dialogContext)), + const SizedBox(height: 8), + RichText( + text: TextSpan( + style: STextStyles.smallMed14(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" + "\n\nBy continuing, you agree to the ShopinBit ", + ), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + dialogContext, + ).copyWith(fontSize: 16), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/privacy.html"; + final shouldOpen = await _showOpenBrowserWarning( + dialogContext, + url, + ); + if (shouldOpen) { + await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + } + }, + ), + const TextSpan(text: "."), + ], + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + child: Text( + "Cancel", + style: STextStyles.button(dialogContext).copyWith( + color: Theme.of( + dialogContext, + ).extension()!.accentColorDark, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextButton( + style: Theme.of(dialogContext) + .extension()! + .getPrimaryEnabledButtonStyle(dialogContext), + onPressed: () async { + Navigator.of(dialogContext).pop(); + final model = ShopInBitOrderModel(); + final service = ShopInBitService.instance; + + if (service.loadSetupComplete()) { + // Returning user: pre-load display name, + // skip Step 1, go to Step 2 + final savedName = service.loadDisplayName(); + if (savedName != null && savedName.isNotEmpty) { + model.displayName = savedName; + } + await Navigator.of(context).pushNamed( + ShopInBitStep2.routeName, + arguments: model, + ); + } else { + // First-time user: show setup flow + await Navigator.of(context).pushNamed( + ShopInBitSetupView.routeName, + arguments: model, + ); + } + if (mounted) setState(() {}); + }, + child: Text( + "Continue", + style: STextStyles.button(dialogContext), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + @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("Services", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SvgPicture.asset( + Assets.svg.circleSliders, + width: 32, + height: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + "ShopinBit", + style: STextStyles.titleBold12(context), + ), + ), + GestureDetector( + onTap: () { + Navigator.of( + context, + ).pushNamed(ShopInBitSettingsView.routeName); + }, + child: SvgPicture.asset( + Assets.svg.gear, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.textDark3, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + "Turn your crypto into Electronics, Flights, Hotel, " + "Cars or any other legal product or service... " + "ShopinBit is a concierge shopping service that helps " + "you 'live the good life with crypto'...", + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + const SizedBox(height: 12), + RichText( + text: TextSpan( + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + children: [ + const TextSpan( + text: + "Minimum order value of 1,000 EUR. " + "A 10% service fee applies to all orders.\n\n" + "By using ShopinBit, you agree to their ", + ), + TextSpan( + text: "Terms & Conditions", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 14), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/terms.html"; + final shouldOpen = + await _showOpenBrowserWarning(context, url); + if (shouldOpen) { + await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + } + }, + ), + const TextSpan(text: " and "), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 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: "."), + ], + ), + ), + const SizedBox(height: 16), + PrimaryButton( + label: "Shop with ShopinBit", + enabled: true, + onPressed: () => _showShopDialog(context), + ), + const SizedBox(height: 12), + Builder( + builder: (context) { + final count = MainDB.instance + .getShopInBitTickets() + .length; + return SecondaryButton( + label: count > 0 + ? "My requests ($count)" + : "My requests", + onPressed: () async { + await Navigator.of( + context, + ).pushNamed(ShopInBitTicketsView.routeName); + if (mounted) setState(() {}); + }, + ); + }, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/ordinals/ordinal_details_view.dart b/lib/pages/ordinals/ordinal_details_view.dart index 7ea7c2d342..958aa8f37d 100644 --- a/lib/pages/ordinals/ordinal_details_view.dart +++ b/lib/pages/ordinals/ordinal_details_view.dart @@ -15,8 +15,11 @@ import '../../models/isar/models/blockchain_data/utxo.dart'; import '../../models/isar/ordinal.dart'; import '../../networking/http.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../pages/send_view/confirm_transaction_view.dart'; import '../../providers/db/main_db_provider.dart'; import '../../providers/global/prefs_provider.dart'; +import '../../providers/global/wallets_provider.dart'; +import '../../route_generator.dart'; import '../../services/tor_service.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; @@ -27,10 +30,14 @@ import '../../utilities/fs.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.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/ordinal_image.dart'; import '../../widgets/rounded_white_container.dart'; +import 'widgets/dialogs.dart'; class OrdinalDetailsView extends ConsumerStatefulWidget { const OrdinalDetailsView({ @@ -298,12 +305,7 @@ class _OrdinalImageGroup extends ConsumerWidget { aspectRatio: 1, child: Container( color: Colors.transparent, - child: Image.network( - ordinal.content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: ordinal.content), ), ), ), @@ -354,33 +356,129 @@ class _OrdinalImageGroup extends ConsumerWidget { }, ), ), - // const SizedBox( - // width: _spacing, - // ), - // Expanded( - // child: PrimaryButton( - // label: "Send", - // icon: SvgPicture.asset( - // Assets.svg.send, - // width: 10, - // height: 10, - // color: Theme.of(context) - // .extension()! - // .buttonTextPrimary, - // ), - // buttonHeight: ButtonHeight.l, - // iconSpacing: 4, - // onPressed: () async { - // final response = await showDialog( - // context: context, - // builder: (_) => const SendOrdinalUnfreezeDialog(), - // ); - // if (response == "unfreeze") { - // // TODO: unfreeze and go to send ord screen - // } - // }, - // ), - // ), + const SizedBox(width: _spacing), + Expanded( + child: PrimaryButton( + label: "Send", + icon: SvgPicture.asset( + Assets.svg.send, + width: 10, + height: 10, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + buttonHeight: ButtonHeight.l, + iconSpacing: 4, + onPressed: () async { + final utxo = ordinal.getUTXO(ref.read(mainDBProvider)); + if (utxo == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not find ordinal UTXO", + context: context, + ), + ); + return; + } + + // Step 1: Confirm unfreeze + if (utxo.isBlocked) { + final unfreezeResponse = await showDialog( + context: context, + builder: (_) => const SendOrdinalUnfreezeDialog(), + ); + if (unfreezeResponse != "unfreeze") return; + } + + if (!context.mounted) return; + + // Step 2: Get recipient address + final address = await showDialog( + context: context, + builder: (_) => OrdinalRecipientAddressDialog( + inscriptionNumber: ordinal.inscriptionNumber, + ), + ); + if (address == null || address.isEmpty) return; + + // Validate address + final wallet = ref.read(pWallets).getWallet(walletId); + if (!wallet.cryptoCurrency.validateAddress(address)) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid address", + context: context, + ), + ); + } + return; + } + + if (!context.mounted) return; + + // Step 3: Prepare the transaction + final OrdinalsInterface? ordinalsWallet = + wallet is OrdinalsInterface ? wallet : null; + if (ordinalsWallet == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Wallet does not support ordinals", + context: context, + ), + ); + return; + } + + bool didError = false; + final txData = await showLoading( + whileFuture: ordinalsWallet.prepareOrdinalSend( + ordinalUtxo: utxo, + recipientAddress: address, + ), + context: context, + rootNavigator: true, + message: "Preparing transaction...", + onException: (e) { + didError = true; + String msg = e.toString(); + while (msg.isNotEmpty && msg.startsWith("Exception:")) { + msg = msg.substring(10).trim(); + } + if (context.mounted) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: msg, + context: context, + ); + } + }, + ); + + if (didError || txData == null || !context.mounted) return; + + // Step 4: Navigate to confirm transaction view + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: + RouteGenerator.useMaterialPageRoute, + builder: (_) => ConfirmTransactionView( + walletId: walletId, + txData: txData, + onSuccess: () {}, + ), + settings: const RouteSettings( + name: ConfirmTransactionView.routeName, + ), + ), + ); + }, + ), + ), ], ), ], diff --git a/lib/pages/ordinals/widgets/dialogs.dart b/lib/pages/ordinals/widgets/dialogs.dart index fca607961d..cb51fca1a8 100644 --- a/lib/pages/ordinals/widgets/dialogs.dart +++ b/lib/pages/ordinals/widgets/dialogs.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; + import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.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'; @@ -11,6 +16,61 @@ class SendOrdinalUnfreezeDialog extends StatelessWidget { @override Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 450, + maxHeight: 220, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "This ordinal is frozen", + style: STextStyles.desktopH3(context), + ), + SvgPicture.asset( + Assets.svg.coinControl.blocked, + width: 24, + height: 24, + color: Theme.of(context).extension()!.textDark, + ), + ], + ), + const SizedBox(height: 12), + Text( + "To send this ordinal, you must unfreeze it first.", + style: STextStyles.desktopTextMedium(context), + ), + const Spacer(), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Unfreeze", + onPressed: () { + Navigator.of(context).pop("unfreeze"); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + return StackDialog( title: "This ordinal is frozen", icon: SvgPicture.asset( @@ -39,6 +99,56 @@ class UnfreezeOrdinalDialog extends StatelessWidget { @override Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 450, + maxHeight: 200, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Unfreeze ordinal?", + style: STextStyles.desktopH3(context), + ), + SvgPicture.asset( + Assets.svg.coinControl.blocked, + width: 24, + height: 24, + color: Theme.of(context).extension()!.textDark, + ), + ], + ), + const Spacer(), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Unfreeze", + onPressed: () { + Navigator.of(context).pop("unfreeze"); + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + return StackDialog( title: "Are you sure you want to unfreeze this ordinal?", icon: SvgPicture.asset( @@ -60,3 +170,158 @@ class UnfreezeOrdinalDialog extends StatelessWidget { ); } } + +class OrdinalRecipientAddressDialog extends StatefulWidget { + const OrdinalRecipientAddressDialog({ + super.key, + required this.inscriptionNumber, + }); + + final int inscriptionNumber; + + @override + State createState() => + _OrdinalRecipientAddressDialogState(); +} + +class _OrdinalRecipientAddressDialogState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + _controller = TextEditingController(); + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _buildTextField(BuildContext context) { + return TextField( + controller: _controller, + decoration: InputDecoration( + hintText: "Paste address", + hintStyle: STextStyles.fieldLabel(context), + suffixIcon: IconButton( + icon: SvgPicture.asset( + Assets.svg.clipboard, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.textFieldDefaultSearchIconLeft, + ), + onPressed: () async { + final data = await Clipboard.getData("text/plain"); + if (data?.text != null) { + _controller.text = data!.text!; + setState(() {}); + } + }, + ), + ), + style: STextStyles.field(context), + autofocus: true, + ); + } + + @override + Widget build(BuildContext context) { + if (Util.isDesktop) { + return DesktopDialog( + maxWidth: 500, + maxHeight: 300, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Send ordinal #${widget.inscriptionNumber}", + style: STextStyles.desktopH3(context), + ), + const SizedBox(height: 12), + Text( + "Enter the recipient address", + style: STextStyles.desktopTextMedium(context), + ), + const SizedBox(height: 8), + _buildTextField(context), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Continue", + onPressed: () { + final address = _controller.text.trim(); + if (address.isNotEmpty) { + Navigator.of(context).pop(address); + } + }, + ), + ), + ], + ), + ], + ), + ), + ); + } + + return StackDialogBase( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Send ordinal #${widget.inscriptionNumber}", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 12), + Text( + "Enter the recipient address", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 8), + _buildTextField(context), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Continue", + onPressed: () { + final address = _controller.text.trim(); + if (address.isNotEmpty) { + Navigator.of(context).pop(address); + } + }, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/pages/ordinals/widgets/ordinal_card.dart b/lib/pages/ordinals/widgets/ordinal_card.dart index 31eeb57337..8662e7dddf 100644 --- a/lib/pages/ordinals/widgets/ordinal_card.dart +++ b/lib/pages/ordinals/widgets/ordinal_card.dart @@ -5,14 +5,11 @@ import '../../../pages_desktop_specific/ordinals/desktop_ordinal_details_view.da import '../../../utilities/constants.dart'; import '../../../utilities/text_styles.dart'; import '../../../utilities/util.dart'; +import '../../../widgets/ordinal_image.dart'; import '../../../widgets/rounded_white_container.dart'; class OrdinalCard extends StatelessWidget { - const OrdinalCard({ - super.key, - required this.walletId, - required this.ordinal, - }); + const OrdinalCard({super.key, required this.walletId, required this.ordinal}); final String walletId; final Ordinal ordinal; @@ -38,12 +35,7 @@ class OrdinalCard extends StatelessWidget { borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), - child: Image.network( - ordinal.content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: ordinal.content), ), ), const Spacer(), diff --git a/lib/pages/send_view/confirm_transaction_view.dart b/lib/pages/send_view/confirm_transaction_view.dart index dfd6c98bd0..84af619745 100644 --- a/lib/pages/send_view/confirm_transaction_view.dart +++ b/lib/pages/send_view/confirm_transaction_view.dart @@ -14,10 +14,13 @@ import 'dart:io'; import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; +import 'package:isar_community/isar.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; +import '../../models/input.dart'; import '../../models/isar/models/transaction_note.dart'; +import '../../models/isar/ordinal.dart'; import '../../notifications/show_flush_bar.dart'; import '../../pages_desktop_specific/coin_control/desktop_coin_control_use_dialog.dart'; import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; @@ -1418,6 +1421,71 @@ class _ConfirmTransactionViewState ), ), ), + // Ordinal UTXO spend warning + Builder( + builder: (context) { + final usedUtxos = widget.txData.usedUTXOs; + if (usedUtxos == null || usedUtxos.isEmpty) { + return const SizedBox.shrink(); + } + + final db = ref.read(mainDBProvider); + bool hasOrdinal = false; + for (final input in usedUtxos) { + if (input is StandardInput) { + final ordinal = db.isar.ordinals + .where() + .filter() + .walletIdEqualTo(walletId) + .and() + .utxoTXIDEqualTo(input.utxo.txid) + .and() + .utxoVOUTEqualTo(input.utxo.vout) + .findFirstSync(); + if (ordinal != null) { + hasOrdinal = true; + break; + } + } + } + + if (!hasOrdinal) return const SizedBox.shrink(); + + return Padding( + padding: isDesktop + ? const EdgeInsets.symmetric(horizontal: 32, vertical: 8) + : const EdgeInsets.symmetric(vertical: 8), + child: RoundedContainer( + color: Theme.of( + context, + ).extension()!.warningBackground, + child: Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: Theme.of( + context, + ).extension()!.warningForeground, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "This transaction spends a UTXO containing " + "an ordinal inscription.", + style: STextStyles.smallMed12(context).copyWith( + color: Theme.of( + context, + ).extension()!.warningForeground, + ), + ), + ), + ], + ), + ), + ); + }, + ), SizedBox(height: isDesktop ? 28 : 16), Padding( padding: isDesktop @@ -1446,7 +1514,10 @@ class _ConfirmTransactionViewState right: 32, bottom: 32, ), - child: DesktopAuthSend(coin: coin), + child: DesktopAuthSend( + coin: coin, + tokenTicker: widget.isTokenTx ? unit : null, + ), ), ], ), diff --git a/lib/pages/settings_views/global_settings_view/global_settings_view.dart b/lib/pages/settings_views/global_settings_view/global_settings_view.dart index 5dc6d4101f..2232d198d4 100644 --- a/lib/pages/settings_views/global_settings_view/global_settings_view.dart +++ b/lib/pages/settings_views/global_settings_view/global_settings_view.dart @@ -11,8 +11,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app_config.dart'; +import '../../../providers/providers.dart'; import '../../../route_generator.dart'; import '../../../themes/stack_colors.dart'; import '../../../utilities/assets.dart'; @@ -22,6 +24,7 @@ import '../../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../../widgets/rounded_white_container.dart'; import '../../address_book_views/address_book_view.dart'; import '../../pinpad_views/lock_screen_view.dart'; +import '../../shopinbit/shopinbit_settings_view.dart'; import '../sub_widgets/settings_list_button.dart'; import 'about_view.dart'; import 'advanced_views/advanced_settings_view.dart'; @@ -96,21 +99,19 @@ class GlobalSettingsView extends StatelessWidget { Navigator.push( context, RouteGenerator.getRoute( - shouldUseMaterialRoute: - RouteGenerator - .useMaterialPageRoute, - builder: - (_) => const LockscreenView( - showBackButton: true, - routeOnSuccess: - StackBackupView.routeName, - biometricsCancelButtonString: - "CANCEL", - biometricsLocalizedReason: - "Authenticate to access ${AppConfig.prefix} backup & restore settings", - biometricsAuthenticationTitle: - "${AppConfig.prefix} backup", - ), + shouldUseMaterialRoute: RouteGenerator + .useMaterialPageRoute, + builder: (_) => const LockscreenView( + showBackButton: true, + routeOnSuccess: + StackBackupView.routeName, + biometricsCancelButtonString: + "CANCEL", + biometricsLocalizedReason: + "Authenticate to access ${AppConfig.prefix} backup & restore settings", + biometricsAuthenticationTitle: + "${AppConfig.prefix} backup", + ), settings: const RouteSettings( name: "/swblockscreen", ), @@ -247,6 +248,34 @@ class GlobalSettingsView extends StatelessWidget { }, ), const SizedBox(height: 8), + Consumer( + builder: (_, ref, __) { + final familiarity = ref.watch( + prefsChangeNotifierProvider.select( + (v) => v.familiarity, + ), + ); + if (familiarity < 6) { + return const SizedBox.shrink(); + } + return Column( + children: [ + const SizedBox(height: 8), + SettingsListButton( + iconAssetName: Assets.svg.key, + iconSize: 16, + title: "ShopinBit", + onPressed: () { + Navigator.of(context).pushNamed( + ShopInBitSettingsView.routeName, + ); + }, + ), + ], + ); + }, + ), + const SizedBox(height: 8), SettingsListButton( iconAssetName: Assets.svg.questionMessage, iconSize: 16, 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 52b78cbcf0..3eca3392fb 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -14,8 +14,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; 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'; @@ -41,19 +44,17 @@ class HiddenSettings extends StatelessWidget { padding: const EdgeInsets.all(8.0), child: AppBarIconButton( size: 32, - color: - Theme.of( - context, - ).extension()!.textFieldDefaultBG, + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, shadows: const [], icon: SvgPicture.asset( Assets.svg.arrowLeft, width: 18, height: 18, - color: - Theme.of( - context, - ).extension()!.topNavIconPrimary, + color: Theme.of( + context, + ).extension()!.topNavIconPrimary, ), onPressed: Navigator.of(context).pop, ), @@ -81,8 +82,8 @@ class HiddenSettings extends StatelessWidget { ref .read(prefsChangeNotifierProvider) .advancedFiroFeatures = !ref - .read(prefsChangeNotifierProvider) - .advancedFiroFeatures; + .read(prefsChangeNotifierProvider) + .advancedFiroFeatures; }, child: RoundedWhiteContainer( child: Text( @@ -94,10 +95,9 @@ class HiddenSettings extends StatelessWidget { ? "Hide advanced Firo features" : "Show advanced Firo features", style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), @@ -109,10 +109,9 @@ class HiddenSettings extends StatelessWidget { builder: (_, ref, __) { return GestureDetector( onTap: () async { - final notifs = - ref - .read(notificationsProvider) - .notifications; + final notifs = ref + .read(notificationsProvider) + .notifications; for (final n in notifs) { await ref @@ -137,10 +136,9 @@ class HiddenSettings extends StatelessWidget { child: Text( "Delete notifications", style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), @@ -153,17 +151,17 @@ class HiddenSettings extends StatelessWidget { return GestureDetector( onTap: () async { ref - .read(prefsChangeNotifierProvider) - .logsPath = null; + .read(prefsChangeNotifierProvider) + .logsPath = + null; }, child: RoundedWhiteContainer( child: Text( "Reset log location", style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), @@ -285,14 +283,14 @@ class HiddenSettings extends StatelessWidget { 6) { return GestureDetector( onTap: () async { - final familiarity = - ref - .read(prefsChangeNotifierProvider) - .familiarity; + final familiarity = ref + .read(prefsChangeNotifierProvider) + .familiarity; if (familiarity < 6) { ref - .read(prefsChangeNotifierProvider) - .familiarity = 6; + .read(prefsChangeNotifierProvider) + .familiarity = + 6; Constants.exchangeForExperiencedUsers(6); } @@ -300,14 +298,12 @@ class HiddenSettings extends StatelessWidget { child: RoundedWhiteContainer( child: Text( "Enable exchange", - style: STextStyles.button( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.button(context) + .copyWith( + color: Theme.of(context) .extension()! .accentColorDark, - ), + ), ), ), ); @@ -317,34 +313,82 @@ class HiddenSettings extends StatelessWidget { }, ), const SizedBox(height: 12), + GestureDetector( + onTap: () async { + final tickets = MainDB.instance + .getShopInBitTickets(); + for (final t in tickets) { + await MainDB.instance.deleteShopInBitTicket( + t.ticketId, + ); + } + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: + "Deleted ${tickets.length} ShopinBit request(s)", + context: context, + ), + ); + } + }, + child: RoundedWhiteContainer( + child: Text( + "Delete all ShopinBit requests", + style: STextStyles.button(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorDark, + ), + ), + ), + ), + const SizedBox(height: 12), Consumer( builder: (_, ref, __) { return GestureDetector( onTap: () async { await showDialog( context: context, - builder: - (_) => TorWarningDialog( - coin: Stellar( - CryptoCurrencyNetwork.main, - ), - ), + builder: (_) => TorWarningDialog( + coin: Stellar(CryptoCurrencyNetwork.main), + ), ); }, child: RoundedWhiteContainer( child: Text( "Show Tor warning popup", style: STextStyles.button(context).copyWith( - color: - Theme.of(context) - .extension()! - .accentColorDark, + color: Theme.of(context) + .extension()! + .accentColorDark, ), ), ), ); }, ), + 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, // ), @@ -385,3 +429,127 @@ 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 new file mode 100644 index 0000000000..8db1d9ad99 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -0,0 +1,777 @@ +import 'dart:async'; + +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../../services/shopinbit/src/models/address.dart'; +import '../../services/shopinbit/src/models/car_research.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/assets.dart'; +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'; +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 'shopinbit_car_research_payment_view.dart'; +import 'shopinbit_step_2.dart'; + +class ShopInBitCarFeeView extends StatefulWidget { + const ShopInBitCarFeeView({super.key, required this.model}); + + static const String routeName = "/shopInBitCarFee"; + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitCarFeeViewState(); +} + +class _ShopInBitCarFeeViewState extends State { + late final TextEditingController _nameController; + late final TextEditingController _streetController; + late final TextEditingController _cityController; + late final TextEditingController _postalCodeController; + late final FocusNode _nameFocusNode; + late final FocusNode _streetFocusNode; + late final FocusNode _cityFocusNode; + late final FocusNode _postalCodeFocusNode; + + List> _countries = []; + String? _selectedCountryIso; + bool _loadingCountries = false; + final TextEditingController _countrySearchController = + TextEditingController(); + + // Billing address (optional, separate from delivery) + bool _differentBilling = false; + late final TextEditingController _billingNameController; + late final TextEditingController _billingStreetController; + late final TextEditingController _billingCityController; + late final TextEditingController _billingPostalCodeController; + late final FocusNode _billingNameFocusNode; + late final FocusNode _billingStreetFocusNode; + late final FocusNode _billingCityFocusNode; + late final FocusNode _billingPostalCodeFocusNode; + String? _selectedBillingCountryIso; + final TextEditingController _billingCountrySearchController = + TextEditingController(); + + String _displayedFee = "223.00 EUR"; + bool _submitting = false; + + bool get _canContinue { + if (_nameController.text.trim().isEmpty || + _streetController.text.trim().isEmpty || + _cityController.text.trim().isEmpty || + _postalCodeController.text.trim().isEmpty || + _selectedCountryIso == null) { + return false; + } + if (_differentBilling) { + if (_billingNameController.text.trim().isEmpty || + _billingStreetController.text.trim().isEmpty || + _billingCityController.text.trim().isEmpty || + _billingPostalCodeController.text.trim().isEmpty || + _selectedBillingCountryIso == null) { + return false; + } + } + return true; + } + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _streetController = TextEditingController(); + _cityController = TextEditingController(); + _postalCodeController = TextEditingController(); + _nameFocusNode = FocusNode(); + _streetFocusNode = FocusNode(); + _cityFocusNode = FocusNode(); + _postalCodeFocusNode = FocusNode(); + _billingNameController = TextEditingController(); + _billingStreetController = TextEditingController(); + _billingCityController = TextEditingController(); + _billingPostalCodeController = TextEditingController(); + _billingNameFocusNode = FocusNode(); + _billingStreetFocusNode = FocusNode(); + _billingCityFocusNode = FocusNode(); + _billingPostalCodeFocusNode = FocusNode(); + + for (final node in [ + _nameFocusNode, + _streetFocusNode, + _cityFocusNode, + _postalCodeFocusNode, + _billingNameFocusNode, + _billingStreetFocusNode, + _billingCityFocusNode, + _billingPostalCodeFocusNode, + ]) { + node.addListener(() => setState(() {})); + } + + _fetchCountries(); + } + + @override + void dispose() { + _nameController.dispose(); + _streetController.dispose(); + _cityController.dispose(); + _postalCodeController.dispose(); + _nameFocusNode.dispose(); + _streetFocusNode.dispose(); + _cityFocusNode.dispose(); + _postalCodeFocusNode.dispose(); + _billingNameController.dispose(); + _billingStreetController.dispose(); + _billingCityController.dispose(); + _billingPostalCodeController.dispose(); + _billingNameFocusNode.dispose(); + _billingStreetFocusNode.dispose(); + _billingCityFocusNode.dispose(); + _billingPostalCodeFocusNode.dispose(); + _billingCountrySearchController.dispose(); + _countrySearchController.dispose(); + super.dispose(); + } + + void _popToStep2() { + Navigator.of(context).popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitStep2.routeName) { + return true; + } + if (name == ServicesView.routeName) { + return true; + } + if (route.isFirst) { + return true; + } + return false; + }); + } + + 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); + } + } + + ({String first, String last}) _splitFullName(String raw) { + final trimmed = raw.trim(); + final idx = trimmed.lastIndexOf(' '); + if (idx >= 0) { + return ( + first: trimmed.substring(0, idx).trim(), + last: trimmed.substring(idx + 1).trim(), + ); + } + return (first: trimmed, last: ""); + } + + Future _createInvoice() async { + if (_submitting) return; + setState(() => _submitting = true); + try { + await ShopInBitService.instance.ensureCustomerKey(); + + // Delivery address (always provided) + final deliveryName = _splitFullName(_nameController.text); + widget.model.setShippingAddress( + name: _nameController.text.trim(), + street: _streetController.text.trim(), + city: _cityController.text.trim(), + postalCode: _postalCodeController.text.trim(), + country: _selectedCountryIso!, + ); + + // Billing address: use separate billing fields if different, else use delivery + final Address billing; + if (_differentBilling) { + final billingName = _splitFullName(_billingNameController.text); + billing = Address( + firstName: billingName.first, + lastName: billingName.last, + street: _billingStreetController.text.trim(), + zip: _billingPostalCodeController.text.trim(), + city: _billingCityController.text.trim(), + country: _selectedBillingCountryIso!, + ); + } else { + billing = Address( + firstName: deliveryName.first, + lastName: deliveryName.last, + street: _streetController.text.trim(), + zip: _postalCodeController.text.trim(), + city: _cityController.text.trim(), + country: _selectedCountryIso!, + ); + } + + final resp = await ShopInBitService.instance.client + .createCarResearchInvoice(billing: billing); + + if (resp.hasError || resp.value == null) { + if (mounted) { + setState(() => _submitting = false); + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + resp.exception?.message ?? "Failed to create invoice", + context: context, + ), + ); + } + return; + } + + final invoice = resp.value!; + + // Best-effort fee fetch; do not block navigation on fee parse failure. + await _loadFee(invoice); + + if (!mounted) return; + + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + unawaited( + showDialog( + context: context, + builder: (_) => ShopInBitCarResearchPaymentView( + model: widget.model, + invoice: invoice, + ), + ), + ); + } else { + unawaited( + Navigator.of(context).pushNamed( + ShopInBitCarResearchPaymentView.routeName, + arguments: (widget.model, invoice), + ), + ); + } + } catch (e) { + if (mounted) { + setState(() => _submitting = false); + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: e.toString(), + context: context, + ), + ); + } + } + } + + String? _parseBip21Amount(String uri) { + try { + // Parse amount from payment URI query params. + final qIdx = uri.indexOf('?'); + if (qIdx < 0) return null; + final query = uri.substring(qIdx + 1); + final params = Uri.splitQueryString(query); + return params['amount'] ?? params['tx_amount']; + } catch (_) { + return null; + } + } + + Future _loadFee(CarResearchInvoice invoice) async { + // Keep status call for visibility into any future API changes surfacing + // 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 + .getCarResearchInvoiceStatus(invoice.btcpayInvoice); + if (resp.hasError || resp.value == null) { + Logging.instance.i( + "CarResearch status response (car_fee_view): error " + "${resp.exception?.message}", + ); + } else { + Logging.instance.i( + "CarResearch status response (car_fee_view): ${resp.value}", + ); + } + } catch (e) { + Logging.instance.i( + "CarResearch status response (car_fee_view): threw $e", + ); + } + + // Primary fee source: parse BIP21 `amount` query param from paymentLinks. + Logging.instance.i( + "CarResearch paymentLinks (car_fee_view): ${invoice.paymentLinks}", + ); + try { + for (final entry in invoice.paymentLinks.entries) { + final parsed = _parseBip21Amount(entry.value); + if (parsed != null && parsed.isNotEmpty) { + if (mounted) { + setState(() => _displayedFee = "$parsed ${entry.key.toUpperCase()}"); + } + return; + } + } + } catch (_) { + // Leave placeholder in place. + } + // No parse succeeded — leave the existing "223.00 EUR" business-rule + // placeholder in place rather than showing "—". + } + + Widget _buildField({ + required TextEditingController controller, + required FocusNode focusNode, + required String label, + required bool isDesktop, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), + child: TextField( + controller: controller, + focusNode: focusNode, + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + label, + focusNode, + context, + desktopMed: isDesktop, + ).copyWith( + filled: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ); + } + + Widget _buildCountryDropdown({ + required String? value, + required ValueChanged onChanged, + required String hint, + required TextEditingController searchController, + required bool isDesktop, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton2( + value: value, + 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) { + searchController.clear(); + } + }, + onChanged: _loadingCountries ? null : onChanged, + hint: Text( + _loadingCountries ? "Loading countries..." : 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, + maxHeight: 300, + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.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 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), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final spacing = SizedBox(height: isDesktop ? 16 : 12); + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Car research fee", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Research fee", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + Text( + _displayedFee, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Delivery address", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + SizedBox(height: isDesktop ? 16 : 12), + _buildField( + controller: _nameController, + focusNode: _nameFocusNode, + label: "Full name", + isDesktop: isDesktop, + ), + spacing, + _buildField( + controller: _streetController, + focusNode: _streetFocusNode, + label: "Street address", + isDesktop: isDesktop, + ), + spacing, + Row( + children: [ + Expanded( + child: _buildField( + controller: _cityController, + focusNode: _cityFocusNode, + label: "City", + isDesktop: isDesktop, + ), + ), + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: _buildField( + controller: _postalCodeController, + focusNode: _postalCodeFocusNode, + label: "Postal code", + isDesktop: isDesktop, + ), + ), + ], + ), + spacing, + _buildCountryDropdown( + value: _selectedCountryIso, + onChanged: (v) => setState(() => _selectedCountryIso = v), + hint: "Country", + searchController: _countrySearchController, + isDesktop: isDesktop, + ), + spacing, + GestureDetector( + onTap: () { + setState(() { + _differentBilling = !_differentBilling; + if (!_differentBilling) { + _billingNameController.clear(); + _billingStreetController.clear(); + _billingCityController.clear(); + _billingPostalCodeController.clear(); + _selectedBillingCountryIso = null; + } + }); + }, + child: Container( + color: Colors.transparent, + child: Row( + children: [ + SizedBox( + width: 20, + height: 20, + child: IgnorePointer( + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _differentBilling, + onChanged: (_) {}, + ), + ), + ), + const SizedBox(width: 12), + Text( + "Different billing address?", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context), + ), + ], + ), + ), + ), + if (_differentBilling) ...[ + spacing, + Text( + "Billing address", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + SizedBox(height: isDesktop ? 16 : 12), + _buildField( + controller: _billingNameController, + focusNode: _billingNameFocusNode, + label: "Full name", + isDesktop: isDesktop, + ), + spacing, + _buildField( + controller: _billingStreetController, + focusNode: _billingStreetFocusNode, + label: "Street address", + isDesktop: isDesktop, + ), + spacing, + Row( + children: [ + Expanded( + child: _buildField( + controller: _billingCityController, + focusNode: _billingCityFocusNode, + label: "City", + isDesktop: isDesktop, + ), + ), + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: _buildField( + controller: _billingPostalCodeController, + focusNode: _billingPostalCodeFocusNode, + label: "Postal code", + isDesktop: isDesktop, + ), + ), + ], + ), + spacing, + _buildCountryDropdown( + value: _selectedBillingCountryIso, + onChanged: (v) => setState(() => _selectedBillingCountryIso = v), + hint: "Billing country", + searchController: _billingCountrySearchController, + isDesktop: isDesktop, + ), + ], + if (!isDesktop) const Spacer(), + if (isDesktop) const SizedBox(height: 24), + PrimaryButton( + label: "Pay research fee", + enabled: _canContinue && !_submitting, + onPressed: (_canContinue && !_submitting) + ? () => unawaited(_createInvoice()) + : null, + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 750, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + 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) { + _popToStep2(); + } + }, + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: _popToStep2, + ), + 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_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart new file mode 100644 index 0000000000..0392e668eb --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart @@ -0,0 +1,742 @@ +import 'dart:async'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app_config.dart'; +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/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'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/assets.dart'; +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/qr.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'shopinbit_order_created.dart'; +import 'shopinbit_send_from_view.dart'; +import 'shopinbit_tickets_view.dart'; + +class ShopInBitCarResearchPaymentView extends ConsumerStatefulWidget { + const ShopInBitCarResearchPaymentView({ + super.key, + required this.model, + required this.invoice, + }); + + static const String routeName = "/shopInBitCarResearchPayment"; + + final ShopInBitOrderModel model; + final CarResearchInvoice invoice; + + @override + ConsumerState createState() => + _ShopInBitCarResearchPaymentViewState(); +} + +class _ShopInBitCarResearchPaymentViewState + extends ConsumerState { + static const Set _terminalStates = { + // concierge heritage + "paid", + "paid_over", + "paid_late", + "payment_processing", + // BTCPay / car research likely + "settled", + "confirmed", + "complete", + "completed", + "finalized", + }; + + Timer? _pollTimer; + Map? _status; + bool _logging = false; + bool _checking = false; + String _statusString = "ready_to_pay"; + List _methods = []; + List _addresses = []; + int _selectedMethod = 0; + + String get _currentAddress => + _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : ""; + + bool get _isTerminal { + final s = _statusString.toLowerCase().trim(); + return _terminalStates.contains(s); + } + + bool get _payNowEnabled => !_isTerminal && !_logging && !_checking; + + void _confirmPayment() { + _pollTimer?.cancel(); + final method = _methods[_selectedMethod]; + final ticker = method.toUpperCase(); + + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + + String address = ""; + Amount? amount; + EthContract? tokenContract; + + if (_currentAddress.isNotEmpty) { + final parsed = AddressUtils.parsePaymentUri(_currentAddress); + + if (parsed?.address != null && parsed!.address.isNotEmpty) { + address = parsed.address; + } else { + final raw = _currentAddress; + final colonIdx = raw.indexOf(':'); + if (colonIdx != -1) { + final afterScheme = raw.substring(colonIdx + 1); + final qIdx = afterScheme.indexOf('?'); + address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; + } else { + address = raw; + } + } + + String? amountStr = parsed?.amount; + if (amountStr == null || amountStr.isEmpty) { + final uri = Uri.tryParse(_currentAddress); + if (uri != null) { + amountStr = uri.queryParameters['amount']; + } + } + // Car research flow has no concierge PaymentInfo.due fallback. + + final int fractionDigits; + if (coin != null) { + fractionDigits = coin.fractionDigits; + } else if (ticker == "USDT") { + fractionDigits = 6; + } else { + fractionDigits = 8; + } + + if (amountStr != null && amountStr.isNotEmpty) { + try { + amount = Amount.fromDecimal( + Decimal.parse(amountStr), + fractionDigits: fractionDigits, + ); + } catch (_) {} + } + } + + if (coin != null && address.isNotEmpty) { + _navigateToSendFrom(coin: coin, amount: amount, address: address); + return; + } + + if (ticker == "USDT" && address.isNotEmpty) { + const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; + tokenContract = ref.read(mainDBProvider).getEthContractSync(usdtAddress); + if (tokenContract != null) { + final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); + if (ethCoin != null) { + _navigateToSendFrom( + coin: ethCoin, + amount: amount, + address: address, + tokenContract: tokenContract, + ); + return; + } + } + } + + // No compatible wallet coin found — surface an info flushbar and keep + // the user on this screen so they can pay externally and then use the + // "CHECK FOR PAYMENT" button. + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: + "No compatible wallet for $method. " + "Pay externally, then tap CHECK FOR PAYMENT.", + context: context, + ), + ); + } + + void _navigateToSendFrom({ + required CryptoCurrency coin, + required Amount? amount, + required String address, + EthContract? tokenContract, + }) { + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + unawaited( + showDialog( + context: context, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: widget.model, + shouldPopRoot: true, + tokenContract: tokenContract, + ), + ), + ); + } else { + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: widget.model, + tokenContract: tokenContract, + ), + settings: const RouteSettings(name: ShopInBitSendFromView.routeName), + ), + ); + } + } + + Future _checkForPayment() async { + if (_checking || _logging) return; + setState(() => _checking = true); + try { + await _pollStatus(); + if (!mounted) return; + if (!_isTerminal && !_logging) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: + "Payment not yet confirmed. Please wait a moment and try again.", + context: context, + ), + ); + } + } finally { + if (mounted) setState(() => _checking = false); + } + } + + String? _parseBip21Amount(String uri) { + try { + // Parse amount from payment URI query params. + final qIdx = uri.indexOf('?'); + if (qIdx < 0) return null; + final query = uri.substring(qIdx + 1); + final params = Uri.splitQueryString(query); + return params['amount'] ?? params['tx_amount']; + } catch (_) { + return null; + } + } + + String get _displayedFee { + // API status endpoint does not expose a fee field (confirmed: returns + // only {status, additional}). Parse the amount from the BIP21 payment + // URI for the currently-selected method, fall back to the 223.00 EUR + // business-rule value if no parse succeeds. + final links = widget.invoice.paymentLinks; + if (_selectedMethod < _methods.length) { + final methodKey = _methods[_selectedMethod]; + // _methods holds upper-cased keys; links map may be case-sensitive. + String? uri = links[methodKey]; + if (uri == null) { + for (final entry in links.entries) { + if (entry.key.toUpperCase() == methodKey) { + uri = entry.value; + break; + } + } + } + if (uri != null) { + final parsed = _parseBip21Amount(uri); + if (parsed != null && parsed.isNotEmpty) { + return "$parsed $methodKey"; + } + } + } + return "223.00 EUR"; + } + + String get _statusLabel { + switch (_statusString) { + case "payment_processing": + return "Confirming..."; + case "paid": + case "paid_over": + case "paid_late": + return "Paid ✓"; + case "ready_to_pay": + default: + return "Waiting for payment"; + } + } + + @override + void initState() { + super.initState(); + final links = widget.invoice.paymentLinks; + _methods = links.keys.map((k) => k.toUpperCase()).toList(); + _addresses = links.values.toList(); + // Kick off an immediate poll then start periodic polling. + unawaited(_pollStatus()); + _pollTimer = Timer.periodic( + const Duration(seconds: 15), + (_) => unawaited(_pollStatus()), + ); + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + void _popToTickets() { + Navigator.of(context).popUntil((route) { + final name = route.settings.name; + if (name == ShopInBitTicketsView.routeName) { + return true; + } + if (name == ServicesView.routeName) { + return true; + } + if (route.isFirst) { + return true; + } + return false; + }); + } + + Future _pollStatus() async { + try { + final resp = await ShopInBitService.instance.client + .getCarResearchInvoiceStatus(widget.invoice.btcpayInvoice); + if (resp.hasError || resp.value == null) { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + resp.exception?.message ?? "Failed to fetch invoice status", + context: context, + ), + ); + } + return; + } + if (!mounted) return; + Logging.instance.i( + "CarResearch status response (payment_view): ${resp.value}", + ); + Logging.instance.i( + "CarResearch paymentLinks (payment_view): " + "${widget.invoice.paymentLinks}", + ); + setState(() { + _status = resp.value!; + _statusString = _status!["status"]?.toString() ?? _statusString; + }); + if (_isTerminal) { + _pollTimer?.cancel(); + await _logPayment(); + } + } catch (e) { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: e.toString(), + context: context, + ), + ); + } + } + } + + Future _logPayment() async { + if (_logging) return; + setState(() => _logging = true); + try { + final resp = await ShopInBitService.instance.client + .logCarResearchPayment(widget.invoice.btcpayInvoice); + if (resp.hasError || resp.value == null) { + if (mounted) { + setState(() => _logging = false); + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: resp.exception?.message ?? "Failed to log payment", + context: context, + ), + ); + } + return; + } + + final result = resp.value!; + widget.model.apiTicketId = result.ticketId; + widget.model.ticketId = result.ticketNumber; + 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) { + setState(() => _logging = false); + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: e.toString(), + context: context, + ), + ); + } + } + } + + void _copyAddress(BuildContext context) { + final addr = _currentAddress; + if (addr.isEmpty) return; + Clipboard.setData(ClipboardData(text: addr)); + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final ticker = _selectedMethod < _methods.length + ? _methods[_selectedMethod].toUpperCase() + : ""; + + bool hasWallets = false; + if (ticker == "USDT") { + const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; + hasWallets = ref + .watch(pWallets) + .wallets + .any( + (w) => + w.info.coin is Ethereum && + w.info.tokenContractAddresses.contains(usdtAddress), + ); + } else { + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin != null) { + hasWallets = ref + .watch(pWallets) + .wallets + .any((e) => e.info.coin == coin); + } + } + + final methodSelector = _methods.length <= 1 + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Text( + _methods.isEmpty ? "—" : _methods.first, + textAlign: TextAlign.center, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ) + : Row( + children: List.generate(_methods.length, (index) { + final isSelected = _selectedMethod == index; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selectedMethod = index), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected + ? Theme.of(context) + .extension()! + .accentColorBlue + : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + _methods[index], + textAlign: TextAlign.center, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: isSelected + ? Theme.of(context) + .extension()! + .accentColorBlue + : null, + fontWeight: isSelected + ? FontWeight.w600 + : null, + ), + ), + ), + ), + ); + }), + ); + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Car research payment", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Research fee", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + Text( + _displayedFee, + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + children: [ + Text( + "Status:", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(width: 8), + Text( + _statusLabel, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: _isTerminal + ? Theme.of(context) + .extension()! + .accentColorGreen + : null, + fontWeight: _isTerminal ? FontWeight.w600 : null, + ), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 24 : 16), + methodSelector, + SizedBox(height: isDesktop ? 24 : 16), + if (_currentAddress.isNotEmpty) + Center( + child: QR(data: _currentAddress, size: isDesktop ? 200 : 180), + ) + else + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + "No payment address available", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + if (_currentAddress.isNotEmpty) + GestureDetector( + onTap: () => _copyAddress(context), + child: RoundedWhiteContainer( + child: Column( + children: [ + Row( + children: [ + Text( + "${_methods[_selectedMethod]} address", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const Spacer(), + Icon( + Icons.copy, + size: 14, + color: Theme.of( + context, + ).extension()!.accentColorBlue, + ), + const SizedBox(width: 4), + Text("Copy", style: STextStyles.link2(context)), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + _currentAddress, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ), + ], + ), + ], + ), + ), + ), + const Spacer(), + PrimaryButton( + label: _checking + ? "Checking..." + : (hasWallets ? "PAY NOW" : "CHECK FOR PAYMENT"), + enabled: _payNowEnabled, + onPressed: _payNowEnabled + ? (hasWallets + ? _confirmPayment + : () => unawaited(_checkForPayment())) + : null, + ), +], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 750, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + 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) { + _popToTickets(); + } + }, + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: _popToTickets, + ), + 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_confirm_send_view.dart b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart new file mode 100644 index 0000000000..de7bc1770e --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart @@ -0,0 +1,750 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../db/isar/main_db.dart'; +import '../../models/isar/models/isar_models.dart'; +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart'; +import '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/isar/providers/wallet_info_provider.dart'; +import '../../wallets/models/tx_data.dart'; +import '../../wallets/wallet/impl/ethereum_wallet.dart'; +import '../../wallets/wallet/wallet.dart'; +import '../../widgets/background.dart'; +import '../../widgets/conditional_parent.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../pinpad_views/lock_screen_view.dart'; +import '../send_view/sub_widgets/sending_transaction_dialog.dart'; +import '../wallet_view/wallet_view.dart'; + +class ShopInBitConfirmSendView extends ConsumerStatefulWidget { + const ShopInBitConfirmSendView({ + super.key, + required this.txData, + required this.walletId, + this.routeOnSuccessName = WalletView.routeName, + required this.model, + this.tokenContract, + }); + + static const String routeName = "/shopInBitConfirmSend"; + + final TxData txData; + final String walletId; + final String routeOnSuccessName; + final ShopInBitOrderModel model; + final EthContract? tokenContract; + + @override + ConsumerState createState() => + _ShopInBitConfirmSendViewState(); +} + +class _ShopInBitConfirmSendViewState + extends ConsumerState { + late final String walletId; + late final String routeOnSuccessName; + late final ShopInBitOrderModel model; + + final isDesktop = Util.isDesktop; + + Future _attemptSend(BuildContext context) async { + final parentWallet = ref.read(pWallets).getWallet(walletId); + final coin = parentWallet.info.coin; + + final sendProgressController = ProgressAndSuccessController(); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return SendingTransactionDialog( + coin: coin, + controller: sendProgressController, + ); + }, + ), + ); + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + late String txid; + Future txidFuture; + + final String note = widget.txData.note ?? ""; + + try { + final wallet = widget.tokenContract != null + ? Wallet.loadTokenWallet( + ethWallet: parentWallet as EthereumWallet, + contract: widget.tokenContract!, + ) + : parentWallet; + + txidFuture = wallet.confirmSend(txData: widget.txData); + + unawaited(wallet.refresh()); + + final results = await Future.wait([txidFuture, time]); + + sendProgressController.triggerSuccess?.call(); + await Future.delayed(const Duration(seconds: 5)); + + txid = (results.first as TxData).txid!; + + // save note + await ref + .read(mainDBProvider) + .putTransactionNote( + TransactionNote(walletId: walletId, txid: txid, value: note), + ); + + // Update model status after successful broadcast + model.status = ShopInBitOrderStatus.paymentPending; + model.paymentMethod = widget.tokenContract != null + ? widget.tokenContract!.symbol.toUpperCase() + : coin.ticker.toUpperCase(); + + unawaited(MainDB.instance.putShopInBitTicket(model.toIsarTicket())); + + // pop back to wallet + if (context.mounted) { + // pop sending dialog (pushed via showDialog which uses root navigator) + Navigator.of(context, rootNavigator: true).pop(); + + if (Util.isDesktop) { + // pop the confirm send desktop dialog + Navigator.of(context, rootNavigator: true).pop(); + } + + Navigator.of(context).popUntil(ModalRoute.withName(routeOnSuccessName)); + } + } catch (e, s) { + Logging.instance.e( + "Broadcast transaction failed: ", + error: e, + stackTrace: s, + ); + + if (context.mounted) { + // pop sending dialog (pushed via showDialog which uses root navigator) + Navigator.of(context, rootNavigator: true).pop(); + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Broadcast transaction failed", + message: e.toString(), + 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(); + }, + ), + ); + }, + ); + } + } + } + + Future _confirmSend() async { + final dynamic unlocked; + + final coin = ref.read(pWalletCoin(walletId)); + + if (Util.isDesktop) { + unlocked = await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxWidth: 580, + maxHeight: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [DesktopDialogCloseButton()], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: DesktopAuthSend( + coin: coin, + tokenTicker: widget.tokenContract?.symbol, + ), + ), + ], + ), + ), + ); + } else { + unlocked = await Navigator.push( + context, + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => const LockscreenView( + showBackButton: true, + popOnSuccess: true, + routeOnSuccessArguments: true, + routeOnSuccess: "", + biometricsCancelButtonString: "CANCEL", + biometricsLocalizedReason: "Authenticate to send transaction", + biometricsAuthenticationTitle: "Confirm Transaction", + ), + settings: const RouteSettings(name: "/confirmsendlockscreen"), + ), + ); + } + + if (unlocked is bool && mounted) { + if (unlocked) { + await _attemptSend(context); + } else { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid passphrase", + context: context, + ), + ); + } + } + } + + @override + void initState() { + walletId = widget.walletId; + routeOnSuccessName = widget.routeOnSuccessName; + model = widget.model; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return ConditionalParent( + condition: !isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + backgroundColor: Theme.of( + context, + ).extension()!.backgroundAppBar, + leading: AppBarBackButton( + onPressed: () async { + Navigator.of(context).pop(); + }, + ), + title: Text( + "Confirm transaction", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (builderContext, 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: child, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + }, + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + maxWidth: 580, + child: Column( + children: [ + Row( + children: [ + const SizedBox(width: 6), + const AppBarBackButton(isCompact: true, iconSize: 23), + const SizedBox(width: 12), + Text( + "Confirm ${widget.tokenContract?.symbol ?? ref.watch(pWalletCoin(walletId)).ticker} transaction", + style: STextStyles.desktopH3(context), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: Column( + children: [ + RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + borderColor: Theme.of( + context, + ).extension()!.background, + child: child, + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + "Transaction fee", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ], + ), + const SizedBox(height: 10), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.textFieldDefaultBG, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + ref + .watch( + pAmountFormatter( + ref.watch(pWalletCoin(walletId)), + ), + ) + .format(widget.txData.fee!), + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final coin = ref.read(pWalletCoin(walletId)); + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + + if (widget.tokenContract != null) { + final amountStr = + "${amount.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}"; + final feeStr = ref + .watch(pAmountFormatter(coin)) + .format(fee); + return Text( + "$amountStr + $feeStr", + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + } + + final total = amount + fee; + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: SecondaryButton( + label: "Cancel", + buttonHeight: ButtonHeight.l, + onPressed: Navigator.of(context).pop, + ), + ), + const SizedBox(width: 16), + Expanded( + child: PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConditionalParent( + condition: isDesktop, + builder: (child) => Container( + decoration: BoxDecoration( + color: Theme.of(context).extension()!.background, + borderRadius: BorderRadius.vertical( + top: Radius.circular(Constants.size.circularBorderRadius), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row(children: [child]), + ), + ), + child: Text( + "Send ${widget.tokenContract?.symbol ?? ref.watch(pWalletCoin(walletId)).ticker}", + style: isDesktop + ? STextStyles.desktopTextMedium(context) + : STextStyles.pageTitleH1(context), + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Send from", style: STextStyles.smallMed12(context)), + const SizedBox(height: 4), + Text( + widget.tokenContract != null + ? "${ref.watch(pWalletName(walletId))} (${widget.tokenContract!.symbol})" + : ref.watch(pWalletName(walletId)), + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "ShopinBit address", + style: STextStyles.smallMed12(context), + ), + const SizedBox(height: 4), + Text( + widget.txData.recipients!.first.address, + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Amount", style: STextStyles.smallMed12(context)), + ConditionalParent( + condition: isDesktop, + builder: (child) => Row( + children: [ + child, + if (widget.tokenContract == null) + Builder( + builder: (context) { + final coin = ref.watch(pWalletCoin(walletId)); + final price = ref.watch( + priceAnd24hChangeNotifierProvider.select( + (value) => value.getPrice(coin), + ), + ); + final String extra; + if (price == null) { + extra = ""; + } else { + final amountWithoutChange = + widget.txData.amountWithoutChange!; + final value = + (price.value * amountWithoutChange.decimal) + .toAmount(fractionDigits: 2); + final currency = ref.watch( + prefsChangeNotifierProvider.select( + (value) => value.currency, + ), + ); + final locale = ref.watch( + localeServiceChangeNotifierProvider.select( + (value) => value.locale, + ), + ); + + extra = + " | ${value.fiatString(locale: locale)} $currency"; + } + + return Text( + extra, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle2, + ), + ); + }, + ), + ], + ), + child: Text( + widget.tokenContract != null + ? "${widget.txData.amountWithoutChange!.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}" + : ref + .watch( + pAmountFormatter( + ref.watch(pWalletCoin(walletId)), + ), + ) + .format(widget.txData.amountWithoutChange!), + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Transaction fee", + style: STextStyles.smallMed12(context), + ), + Text( + ref + .watch( + pAmountFormatter(ref.read(pWalletCoin(walletId))), + ) + .format(widget.txData.fee!), + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text("Note", style: STextStyles.smallMed12(context)), + const SizedBox(height: 4), + Text( + widget.txData.note ?? "", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + isDesktop + ? Container( + color: Theme.of( + context, + ).extension()!.background, + height: 1, + ) + : const SizedBox(height: 12), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Request ID", style: STextStyles.smallMed12(context)), + Text( + model.ticketId ?? "", + style: STextStyles.itemSubtitle12(context), + textAlign: TextAlign.right, + ), + ], + ), + ), + if (!isDesktop) const SizedBox(height: 12), + if (!isDesktop) + RoundedContainer( + color: Theme.of( + context, + ).extension()!.snackBarBackSuccess, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total amount", + style: STextStyles.titleBold12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, + ), + ), + Builder( + builder: (context) { + final coin = ref.watch(pWalletCoin(walletId)); + final fee = widget.txData.fee!; + final amount = widget.txData.amountWithoutChange!; + + if (widget.tokenContract != null) { + final amountStr = + "${amount.decimal.toStringAsFixed(widget.tokenContract!.decimals)} ${widget.tokenContract!.symbol}"; + final feeStr = ref + .watch(pAmountFormatter(coin)) + .format(fee); + return Text( + "$amountStr + $feeStr", + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of(context) + .extension()! + .textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + } + + final total = amount + fee; + return Text( + ref.watch(pAmountFormatter(coin)).format(total), + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textConfirmTotalAmount, + ), + textAlign: TextAlign.right, + ); + }, + ), + ], + ), + ), + if (!isDesktop) const SizedBox(height: 16), + if (!isDesktop) const Spacer(), + if (!isDesktop) + PrimaryButton( + label: "Send", + buttonHeight: isDesktop ? ButtonHeight.l : null, + onPressed: _confirmSend, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_offer_view.dart b/lib/pages/shopinbit/shopinbit_offer_view.dart new file mode 100644 index 0000000000..ace2f3d37d --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; + +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../../themes/stack_colors.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_white_container.dart'; +import 'shopinbit_shipping_view.dart'; + +class ShopInBitOfferView extends StatefulWidget { + const ShopInBitOfferView({super.key, required this.model}); + + static const String routeName = "/shopInBitOffer"; + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitOfferViewState(); +} + +class _ShopInBitOfferViewState extends State { + bool _loading = false; + + @override + void initState() { + super.initState(); + if (widget.model.apiTicketId != 0) { + _loadOffer(); + } + } + + Future _loadOffer() async { + setState(() => _loading = true); + try { + final resp = await ShopInBitService.instance.client.getTicketFull( + widget.model.apiTicketId, + ); + if (!resp.hasError && resp.value != null) { + final t = resp.value!; + widget.model.setOffer( + productName: t.productName, + price: t.customerPrice, + ); + } + } catch (_) { + // Fall back to local data + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final model = widget.model; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Review offer", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "ShopinBit has found a match for your request.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + SizedBox(height: isDesktop ? 16 : 12), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Product", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 4), + Text( + model.offerProductName ?? (_loading ? "Loading..." : "N/A"), + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 12 : 8), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Price (incl. service fee)", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 4), + Text( + _loading && model.offerPrice == null + ? "Loading..." + : "${model.offerPrice ?? '0'} EUR", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + const Spacer(), + PrimaryButton( + label: "Accept offer", + enabled: !_loading, + onPressed: () { + model.status = ShopInBitOrderStatus.accepted; + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + builder: (_) => ShopInBitShippingView(model: model), + ); + } else { + Navigator.of( + context, + ).pushNamed(ShopInBitShippingView.routeName, arguments: model); + } + }, + ), + SizedBox(height: isDesktop ? 16 : 12), + SecondaryButton( + label: "Decline", + onPressed: () { + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + } else { + Navigator.of(context).pop(); + } + }, + ), + ], + ); + + const loadingOverlay = Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 600, + child: Column( + 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: Stack(children: [content, if (_loading) loadingOverlay]), + ), + ), + ], + ), + ); + } + + 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 Stack( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ), + if (_loading) loadingOverlay, + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_order_created.dart b/lib/pages/shopinbit/shopinbit_order_created.dart new file mode 100644 index 0000000000..d07752c1a3 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_order_created.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../models/shopinbit/shopinbit_order_model.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/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 '../more_view/services_view.dart'; +import 'shopinbit_ticket_detail.dart'; + +class ShopInBitOrderCreated extends StatelessWidget { + const ShopInBitOrderCreated({super.key, required this.model}); + + static const String routeName = "/shopInBitOrderCreated"; + + final ShopInBitOrderModel model; + + static void _popToServices(BuildContext context) { + Navigator.of(context).popUntil((route) { + if (route.settings.name == ServicesView.routeName) { + return true; + } + if (route.isFirst) { + return true; + } + return false; + }); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final content = Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(), + SvgPicture.asset( + Assets.svg.checkCircle, + width: isDesktop ? 64 : 48, + height: isDesktop ? 64 : 48, + color: Theme.of(context).extension()!.accentColorGreen, + ), + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Request created!", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Your request has been submitted.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + textAlign: TextAlign.center, + ), + SizedBox(height: isDesktop ? 32 : 24), + RoundedWhiteContainer( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Request ID", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + model.ticketId ?? "N/A", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + SizedBox(height: isDesktop ? 12 : 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Status", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + Text( + "Pending review", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ], + ), + ), + const Spacer(), + PrimaryButton( + label: "View request", + onPressed: () { + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + + builder: (_) => ShopInBitTicketDetail(model: model), + ); + } else { + Navigator.of( + context, + ).pushNamed(ShopInBitTicketDetail.routeName, arguments: model); + } + }, + ), + SizedBox(height: isDesktop ? 16 : 12), + SecondaryButton( + label: "Back to services", + onPressed: () { + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + } else { + _popToServices(context); + } + }, + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 550, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: content, + ), + ), + ], + ), + ); + } + + return Background( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (!didPop) { + _popToServices(context); + } + }, + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => _popToServices(context), + ), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart new file mode 100644 index 0000000000..1a51cbeafa --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -0,0 +1,858 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:decimal/decimal.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:url_launcher/url_launcher.dart'; + +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/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'; +import '../../utilities/address_utils.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/assets.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../wallets/crypto_currency/crypto_currency.dart'; +import '../../widgets/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; +import '../../widgets/desktop/desktop_dialog_close_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/rounded_white_container.dart'; +import 'shopinbit_send_from_view.dart'; +import 'shopinbit_tickets_view.dart'; + +class ShopInBitPaymentView extends ConsumerStatefulWidget { + const ShopInBitPaymentView({super.key, required this.model}); + + static const String routeName = "/shopInBitPayment"; + + final ShopInBitOrderModel model; + + @override + ConsumerState createState() => + _ShopInBitPaymentViewState(); +} + +class _ShopInBitPaymentViewState extends ConsumerState { + bool _termsAccepted = false; + bool _loading = false; + int _selectedMethod = 0; + Timer? _pollTimer; + + PaymentInfo? _paymentInfo; + + // Derived from API payment_links keys, fallback to defaults + List _methods = ["BTC", "XMR", "USDT"]; + List _addresses = ["", "", ""]; + + String get _currentAddress => + _selectedMethod < _addresses.length ? _addresses[_selectedMethod] : ""; + + String get _totalPrice => + _paymentInfo?.customerPrice ?? widget.model.offerPrice ?? "0"; + + String get _status => _paymentInfo?.status ?? 'ready_to_pay'; + + bool get _isExpiredOrInvalid => _status == 'expired' || _status == 'invalid'; + + bool get _isTerminal => const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + }.contains(_status); + + bool get _payNowEnabled => + _termsAccepted && !_isExpiredOrInvalid && !_isTerminal; + + @override + void initState() { + super.initState(); + if (widget.model.apiTicketId != 0) { + _loadPayment(); + } + } + + @override + void dispose() { + _pollTimer?.cancel(); + super.dispose(); + } + + void _applyPaymentInfo(PaymentInfo info) { + _paymentInfo = info; + final links = info.paymentLinks; + if (links.isNotEmpty) { + _methods = links.keys.map((k) => k.toUpperCase()).toList(); + _addresses = links.values.toList(); + } + } + + void _startPolling() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic( + const Duration(seconds: 15), + (_) => _pollPayment(), + ); + } + + Future _pollPayment() async { + try { + final resp = await ShopInBitService.instance.client.getPayment( + widget.model.apiTicketId, + ); + if (!resp.hasError && resp.value != null && mounted) { + setState(() => _applyPaymentInfo(resp.value!)); + if (_isTerminal) { + _pollTimer?.cancel(); + } + } + } catch (_) {} + } + + Future _loadPayment() async { + setState(() => _loading = true); + try { + final resp = await ShopInBitService.instance.client.getPayment( + widget.model.apiTicketId, + ); + if (!resp.hasError && resp.value != null) { + _applyPaymentInfo(resp.value!); + } + } catch (_) { + // Fall back to local/dummy data + } finally { + if (mounted) { + setState(() => _loading = false); + _startPolling(); + } + } + } + + Future _refreshInvoice() async { + setState(() => _loading = true); + try { + final resp = await ShopInBitService.instance.client.getPayment( + widget.model.apiTicketId, + retry: true, + ); + if (!resp.hasError && resp.value != null) { + _applyPaymentInfo(resp.value!); + } + } catch (_) {} + if (mounted) { + setState(() => _loading = false); + _startPolling(); + } + } + + Future _openTerms() async { + const url = "https://api.shopinbit.com/static/policy/terms.html"; + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + + Future _checkForPayment() async { + _pollTimer?.cancel(); + setState(() => _loading = true); + try { + final resp = await ShopInBitService.instance.client.getPayment( + widget.model.apiTicketId, + ); + if (!resp.hasError && resp.value != null && mounted) { + setState(() => _applyPaymentInfo(resp.value!)); + final status = resp.value!.status; + if (const { + 'paid', + 'paid_over', + 'paid_late', + 'payment_processing', + }.contains(status)) { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.success, + message: "Payment received!", + context: context, + ), + ); + } + } else if (status == 'underpaid') { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Underpaid. Remaining: ${resp.value!.due ?? '?'} EUR.", + context: context, + ), + ); + } + } else { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.info, + message: "No payment detected yet.", + context: context, + ), + ); + } + } + } else if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: resp.exception?.message ?? "Failed to check payment.", + context: context, + ), + ); + } + } catch (e) { + if (mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: e.toString(), + context: context, + ), + ); + } + } finally { + if (mounted) { + setState(() => _loading = false); + if (!_isTerminal) { + _startPolling(); + } + } + } + } + + void _confirmPayment() { + _pollTimer?.cancel(); + final method = _methods[_selectedMethod]; + final ticker = method.toUpperCase(); + + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + + String address = ""; + Amount? amount; + EthContract? tokenContract; + + if (_currentAddress.isNotEmpty) { + final parsed = AddressUtils.parsePaymentUri(_currentAddress); + + if (parsed?.address != null && parsed!.address.isNotEmpty) { + address = parsed.address; + } else { + final raw = _currentAddress; + final colonIdx = raw.indexOf(':'); + if (colonIdx != -1) { + final afterScheme = raw.substring(colonIdx + 1); + final qIdx = afterScheme.indexOf('?'); + address = qIdx != -1 ? afterScheme.substring(0, qIdx) : afterScheme; + } else { + address = raw; + } + } + + String? amountStr = parsed?.amount; + if (amountStr == null || amountStr.isEmpty) { + final uri = Uri.tryParse(_currentAddress); + if (uri != null) { + amountStr = uri.queryParameters['amount']; + } + } + if (amountStr == null || amountStr.isEmpty) { + amountStr = _paymentInfo?.due; + } + + final int fractionDigits; + if (coin != null) { + fractionDigits = coin.fractionDigits; + } else if (ticker == "USDT") { + fractionDigits = 6; + } else { + fractionDigits = 8; + } + + if (amountStr != null && amountStr.isNotEmpty) { + try { + amount = Amount.fromDecimal( + Decimal.parse(amountStr), + fractionDigits: fractionDigits, + ); + } catch (_) {} + } + } + + if (coin != null && address.isNotEmpty) { + _navigateToSendFrom(coin: coin, amount: amount, address: address); + return; + } + + if (ticker == "USDT" && address.isNotEmpty) { + const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; + tokenContract = ref.read(mainDBProvider).getEthContractSync(usdtAddress); + if (tokenContract != null) { + final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH"); + if (ethCoin != null) { + _navigateToSendFrom( + coin: ethCoin, + amount: amount, + address: address, + tokenContract: tokenContract, + ); + return; + } + } + } + + widget.model.status = ShopInBitOrderStatus.paymentPending; + widget.model.paymentMethod = method; + + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + } else { + Navigator.of(context).popUntil((route) => route.isFirst); + } + } + + void _popToTickets() { + Navigator.of(context).pop(); + } + + void _navigateToSendFrom({ + required CryptoCurrency coin, + required Amount? amount, + required String address, + EthContract? tokenContract, + }) { + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + unawaited( + showDialog( + context: context, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: widget.model, + shouldPopRoot: true, + tokenContract: tokenContract, + ), + ), + ); + } else { + Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ShopInBitSendFromView( + coin: coin, + amount: amount, + address: address, + model: widget.model, + tokenContract: tokenContract, + ), + settings: const RouteSettings(name: ShopInBitSendFromView.routeName), + ), + ); + } + } + + bool _hasWalletForTicker(String ticker) { + if (ticker == "USDT") { + const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"; + return ref + .read(pWallets) + .wallets + .any( + (w) => + w.info.coin is Ethereum && + w.info.tokenContractAddresses.contains(usdtAddress), + ); + } else { + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + if (coin != null) { + return ref + .read(pWallets) + .wallets + .any((e) => e.info.coin == coin); + } + } + return false; + } + + String? _parseBip21Amount(String bip21Uri) { + final parsed = AddressUtils.parsePaymentUri(bip21Uri); + String? amountStr = parsed?.amount; + if (amountStr == null || amountStr.isEmpty) { + final uri = Uri.tryParse(bip21Uri); + if (uri != null) { + amountStr = uri.queryParameters['amount']; + } + } + return (amountStr != null && amountStr.isNotEmpty) ? amountStr : null; + } + + void _onOwnedCoinTap(int methodIndex) { + if (!_payNowEnabled) return; + _selectedMethod = methodIndex; + _confirmPayment(); + } + + void _onUnownedCoinTap(int methodIndex) { + if (_isExpiredOrInvalid || _isTerminal) return; + final ticker = _methods[methodIndex].toUpperCase(); + final address = _addresses[methodIndex]; + + showModalBottomSheet( + context: context, + builder: (ctx) => Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "$ticker Payment", + style: STextStyles.pageTitleH2(context), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: address)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + }, + child: RoundedWhiteContainer( + child: Row( + children: [ + Expanded( + child: Text( + address, + style: STextStyles.itemSubtitle12(context), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.copy, + size: 14, + color: Theme.of( + context, + ).extension()!.accentColorBlue, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + PrimaryButton( + label: "CHECK FOR PAYMENT", + onPressed: () { + Navigator.of(ctx).pop(); + _checkForPayment(); + }, + ), + ], + ), + ), + ); + } + + void _copyAddress(BuildContext context) { + Clipboard.setData(ClipboardData(text: _currentAddress)); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard", + iconAsset: Assets.svg.copy, + context: context, + ); + } + + @override + 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++) { + final ticker = _methods[i].toUpperCase(); + final coin = AppConfig.getCryptoCurrencyForTicker(ticker); + final hasWallet = _hasWalletForTicker(ticker); + final amountStr = _addresses[i].isNotEmpty + ? _parseBip21Amount(_addresses[i]) + : null; + + if (i > 0) { + coinRows.add(const SizedBox(height: 8)); + } + + coinRows.add( + RoundedWhiteContainer( + child: Opacity( + opacity: hasWallet ? 1.0 : 0.5, + child: InkWell( + onTap: hasWallet + ? () => _onOwnedCoinTap(i) + : () => _onUnownedCoinTap(i), + child: Row( + children: [ + if (coin != null) + SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, + ) + else + SizedBox( + width: 24, + height: 24, + child: Center( + child: Text( + ticker.substring(0, ticker.length > 2 ? 2 : ticker.length), + style: STextStyles.itemSubtitle12(context), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + ticker, + style: STextStyles.titleBold12(context), + ), + if (amountStr != null) + Text( + "$amountStr $ticker", + style: STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + if (hasWallet) + Text( + "PAY NOW", + style: STextStyles.link2(context), + ) + else + Icon( + Icons.info_outline, + size: 18, + color: Theme.of( + context, + ).extension()!.textSubtitle2, + ), + ], + ), + ), + ), + ), + ); + } + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Payment", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + Text( + "$_totalPrice EUR", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + ], + ), + ), + // Status banner + if (_status == 'underpaid') ...[ + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.alertCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorOrange, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Payment underpaid. Remaining: " + "${_paymentInfo?.due ?? '?'} EUR. " + "Please send the remaining amount.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorOrange, + ), + ), + ), + ], + ), + ), + ], + if (_isExpiredOrInvalid) ...[ + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Column( + children: [ + Row( + children: [ + SvgPicture.asset( + Assets.svg.alertCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorRed, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Invoice expired.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorRed, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + SecondaryButton( + label: "Refresh Invoice", + onPressed: _refreshInvoice, + ), + ], + ), + ), + ], + if (_isTerminal) ...[ + SizedBox(height: isDesktop ? 16 : 8), + RoundedWhiteContainer( + child: Row( + children: [ + SvgPicture.asset( + Assets.svg.checkCircle, + width: 20, + height: 20, + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "Payment received.", + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + ), + ), + ], + ), + ), + ], + SizedBox(height: isDesktop ? 24 : 16), + // Coin list (replaces tab selector + QR + address + global button) + if (!_isExpiredOrInvalid) ...coinRows, + SizedBox(height: isDesktop ? 16 : 12), + GestureDetector( + onTap: () { + setState(() { + _termsAccepted = !_termsAccepted; + }); + }, + child: Container( + color: Colors.transparent, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 20, + height: 20, + child: IgnorePointer( + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + value: _termsAccepted, + onChanged: (_) {}, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: RichText( + text: TextSpan( + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.w500_14(context), + children: [ + const TextSpan(text: "I accept the "), + TextSpan( + text: "Terms & Conditions", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: isDesktop ? null : 14), + recognizer: TapGestureRecognizer() + ..onTap = _openTerms, + ), + const TextSpan(text: "."), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 750, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 8, + ), + child: Stack( + children: [ + SingleChildScrollView(child: content), + if (_loading) loadingOverlay, + ], + ), + ), + ), + ], + ), + ); + } + + return Background( + child: PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, dynamic result) { + if (!didPop) { + _popToTickets(); + } + }, + child: Scaffold( + backgroundColor: + Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: _popToTickets, + ), + title: Text("ShopinBit", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight(child: content), + ), + ), + ), + if (_loading) loadingOverlay, + ], + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_send_from_view.dart b/lib/pages/shopinbit/shopinbit_send_from_view.dart new file mode 100644 index 0000000000..39b731afef --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_send_from_view.dart @@ -0,0 +1,515 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +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 '../../providers/providers.dart'; +import '../../route_generator.dart'; +import '../../themes/coin_icon_provider.dart'; +import '../../themes/stack_colors.dart'; +import '../../themes/theme_providers.dart'; +import '../../utilities/amount/amount.dart'; +import '../../utilities/amount/amount_formatter.dart'; +import '../../utilities/constants.dart'; +import '../../utilities/enums/fee_rate_type_enum.dart'; +import '../../utilities/logger.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +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'; +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/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'; + +class ShopInBitSendFromView extends ConsumerStatefulWidget { + const ShopInBitSendFromView({ + super.key, + required this.coin, + required this.model, + this.amount, + required this.address, + this.shouldPopRoot = false, + this.tokenContract, + }); + + static const String routeName = "/shopInBitSendFrom"; + + final CryptoCurrency coin; + final Amount? amount; + final String address; + final ShopInBitOrderModel model; + final bool shouldPopRoot; + final EthContract? tokenContract; + + @override + ConsumerState createState() => + _ShopInBitSendFromViewState(); +} + +class _ShopInBitSendFromViewState extends ConsumerState { + late final CryptoCurrency coin; + late final Amount? amount; + late final String address; + late final ShopInBitOrderModel model; + late final EthContract? tokenContract; + + @override + void initState() { + coin = widget.coin; + address = widget.address; + amount = widget.amount; + model = widget.model; + tokenContract = widget.tokenContract; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final List walletIds; + if (tokenContract != null) { + walletIds = ref + .watch(pWallets) + .wallets + .where( + (w) => + w.info.coin == coin && + w.info.tokenContractAddresses.contains(tokenContract!.address), + ) + .map((e) => e.walletId) + .toList(); + } else { + walletIds = ref + .watch(pWallets) + .wallets + .where((e) => e.info.coin == coin) + .map((e) => e.walletId) + .toList(); + } + + final isDesktop = Util.isDesktop; + + return ConditionalParent( + condition: !isDesktop, + builder: (child) { + return Background( + child: Scaffold( + backgroundColor: Theme.of( + context, + ).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () { + Navigator.of(context).pop(); + }, + ), + title: Text("Send from", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ), + ); + }, + child: ConditionalParent( + condition: isDesktop, + builder: (child) => DesktopDialog( + maxHeight: double.infinity, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "Send from ${AppConfig.prefix}", + style: STextStyles.desktopH3(context), + ), + ), + DesktopDialogCloseButton( + onPressedOverride: Navigator.of( + context, + rootNavigator: widget.shouldPopRoot, + ).pop, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 32, right: 32, bottom: 32), + child: child, + ), + ], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Row( + children: [ + Text( + amount != null + ? tokenContract != null + ? "You need to send ${amount!.decimal.toStringAsFixed(tokenContract!.decimals)} ${tokenContract!.symbol}" + : "You need to send ${ref.watch(pAmountFormatter(coin)).format(amount!)}" + : "Select a wallet to pay", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + const SizedBox(height: 16), + ConditionalParent( + condition: !isDesktop, + builder: (child) => Expanded(child: child), + child: ListView.builder( + primary: isDesktop ? false : null, + shrinkWrap: isDesktop, + itemCount: walletIds.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: ShopInBitSendFromCard( + walletId: walletIds[index], + amount: amount, + address: address, + model: model, + tokenContract: tokenContract, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class ShopInBitSendFromCard extends ConsumerStatefulWidget { + const ShopInBitSendFromCard({ + super.key, + required this.walletId, + this.amount, + required this.address, + required this.model, + this.tokenContract, + }); + + final String walletId; + final Amount? amount; + final String address; + final ShopInBitOrderModel model; + final EthContract? tokenContract; + + @override + ConsumerState createState() => + _ShopInBitSendFromCardState(); +} + +class _ShopInBitSendFromCardState extends ConsumerState { + late final String walletId; + late final Amount? amount; + late final String address; + late final ShopInBitOrderModel model; + late final EthContract? tokenContract; + + Future _send() async { + final coin = ref.read(pWalletCoin(walletId)); + + final int fractionDigits = tokenContract != null + ? tokenContract!.decimals + : coin.fractionDigits; + + Amount? sendAmount = amount; + if (sendAmount == null) { + if (ShopInBitService.instance.client.sandbox) { + sendAmount = Amount( + rawValue: BigInt.from(10000), + fractionDigits: fractionDigits, + ); + } else { + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: "Payment amount not available yet", + 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(), + ), + ); + }, + ); + return; + } + } + + bool wasCancelled = false; + + try { + final parentWallet = ref.read(pWallets).getWallet(walletId); + + unawaited( + showDialog( + context: context, + useSafeArea: false, + barrierDismissible: false, + builder: (context) { + return ConditionalParent( + condition: Util.isDesktop, + builder: (child) => DesktopDialog( + maxWidth: 400, + maxHeight: double.infinity, + child: Padding(padding: const EdgeInsets.all(32), child: child), + ), + child: BuildingTransactionDialog( + coin: coin, + isSpark: false, + onCancel: () { + wasCancelled = true; + + Navigator.of(context).pop(); + }, + ), + ); + }, + ), + ); + + if (parentWallet is ExternalWallet) { + await parentWallet.init(); + await parentWallet.open(); + } + + final time = Future.delayed(const Duration(milliseconds: 2500)); + + TxData txData; + + // Use token wallet for ERC-20 tokens, parent wallet otherwise + final wallet = tokenContract != null + ? Wallet.loadTokenWallet( + ethWallet: parentWallet as EthereumWallet, + contract: tokenContract!, + ) + : parentWallet; + + if (tokenContract != null) { + await wallet.init(); + } + + final addressType = + wallet.cryptoCurrency.getAddressType(address) ?? + parentWallet.cryptoCurrency.getAddressType(address) ?? + AddressType.ethereum; + + final recipient = TxRecipient( + address: address, + amount: sendAmount, + isChange: false, + addressType: addressType, + ); + + final txDataFuture = wallet.prepareSend( + txData: TxData( + recipients: [recipient], + feeRateType: FeeRateType.average, + ), + ); + + final results = await Future.wait([txDataFuture, time]); + + txData = results.first as TxData; + + if (!wasCancelled) { + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(); + } + + txData = txData.copyWith(note: "ShopinBit payment"); + + if (mounted) { + await Navigator.of(context).push( + RouteGenerator.getRoute( + shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute, + builder: (_) => ShopInBitConfirmSendView( + txData: txData, + walletId: walletId, + routeOnSuccessName: Util.isDesktop + ? DesktopHomeView.routeName + : HomeView.routeName, + model: model, + tokenContract: tokenContract, + ), + settings: const RouteSettings( + name: ShopInBitConfirmSendView.routeName, + ), + ), + ); + } + } + } catch (e, s) { + Logging.instance.e("$e\n$s", error: e, stackTrace: s); + if (mounted && !wasCancelled) { + Navigator.of(context, rootNavigator: true).pop(); + + await showDialog( + context: context, + useSafeArea: false, + barrierDismissible: true, + builder: (context) { + return StackDialog( + title: "Transaction failed", + message: e.toString(), + 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(); + }, + ), + ); + }, + ); + } + } + } + + @override + void initState() { + walletId = widget.walletId; + amount = widget.amount; + address = widget.address; + model = widget.model; + tokenContract = widget.tokenContract; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final coin = ref.watch(pWalletCoin(walletId)); + + return RoundedWhiteContainer( + padding: const EdgeInsets.all(0), + child: MaterialButton( + splashColor: Theme.of(context).extension()!.highlight, + key: Key("walletsSheetItemButtonKey_$walletId"), + padding: const EdgeInsets.all(8), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + onPressed: () async { + if (mounted) { + unawaited(_send()); + } + }, + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: ref.watch(pCoinColor(coin)).withOpacity(0.5), + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + ), + child: Padding( + padding: const EdgeInsets.all(6), + child: SvgPicture.file( + File(ref.watch(coinIconProvider(coin))), + width: 24, + height: 24, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tokenContract != null + ? "${ref.watch(pWalletName(walletId))} (${tokenContract!.symbol})" + : ref.watch(pWalletName(walletId)), + style: STextStyles.titleBold12(context), + ), + const SizedBox(height: 2), + if (tokenContract != null) + Builder( + builder: (context) { + final balance = ref.watch( + pTokenBalance(( + walletId: walletId, + contractAddress: tokenContract!.address, + )), + ); + return Text( + "${balance.spendable.decimal.toStringAsFixed(tokenContract!.decimals)} ${tokenContract!.symbol}", + style: STextStyles.itemSubtitle(context), + ); + }, + ) + else + Text( + ref + .watch(pAmountFormatter(coin)) + .format( + ref.watch(pWalletBalance(walletId)).spendable, + ), + style: STextStyles.itemSubtitle(context), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_settings_view.dart b/lib/pages/shopinbit/shopinbit_settings_view.dart new file mode 100644 index 0000000000..0682b74e6e --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -0,0 +1,545 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../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/background.dart'; +import '../../widgets/custom_buttons/app_bar_icon_button.dart'; +import '../../widgets/desktop/primary_button.dart'; +import '../../widgets/rounded_container.dart'; +import '../../widgets/rounded_white_container.dart'; +import '../../widgets/stack_dialog.dart'; +import '../../widgets/stack_text_field.dart'; + +class ShopInBitSettingsView extends ConsumerStatefulWidget { + const ShopInBitSettingsView({super.key}); + + static const String routeName = "/shopInBitSettings"; + + @override + ConsumerState createState() => + _ShopInBitSettingsViewState(); +} + +class _ShopInBitSettingsViewState 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: (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( + 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; + + 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 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, + ), + child: TextField( + controller: _verifyKeyController, + focusNode: _verifyKeyFocusNode, + style: STextStyles.field(ctx), + decoration: standardInputDecoration( + "Enter current key", + _verifyKeyFocusNode, + ctx, + ), + 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, + ), + ), + ), + ), + 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), + ), + ), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ); + } + + @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, + ), + 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), + ), + ), + const SizedBox(width: 8), + 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, + ), + ), + ], + ), + ), + ] 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, + ), + child: TextField( + controller: _manualKeyController, + focusNode: _manualKeyFocusNode, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Enter customer key", + _manualKeyFocusNode, + context, + ), + onChanged: (_) => 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, + ), + child: TextField( + controller: _displayNameController, + focusNode: _displayNameFocusNode, + style: STextStyles.field(context), + decoration: standardInputDecoration( + "Display name", + _displayNameFocusNode, + context, + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(height: 12), + PrimaryButton( + label: "Save", + enabled: + !_savingName && + _displayNameController.text + .trim() + .isNotEmpty, + onPressed: _saveDisplayName, + ), + ], + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_setup_view.dart b/lib/pages/shopinbit/shopinbit_setup_view.dart new file mode 100644 index 0000000000..3c026a8392 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_setup_view.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/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 'shopinbit_step_2.dart'; + +class ShopInBitSetupView extends StatefulWidget { + const ShopInBitSetupView({super.key, required this.model}); + + static const String routeName = "/shopInBitSetup"; + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitSetupViewState(); +} + +class _ShopInBitSetupViewState extends State { + late final Future _keyFuture; + late final TextEditingController _nameController; + late final FocusNode _nameFocusNode; + + 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(); + + _nameFocusNode.addListener(() { + setState(() {}); + }); + } + + @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); + + if (mounted) { + Navigator.of(context).pushReplacementNamed( + ShopInBitStep2.routeName, + arguments: widget.model, + ); + } + } + + @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.all(16), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 32, + ), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Your ShopinBit Customer Key", + style: STextStyles.pageTitleH1(context), + ), + const SizedBox(height: 8), + Text( + "This is your ShopinBit customer key. Save it " + "somewhere safe: you'll need it to recover " + "your ShopinBit account on a new device.", + style: STextStyles.itemSubtitle(context), + ), + const SizedBox(height: 16), + FutureBuilder( + future: _keyFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != + ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasError) { + return Text( + "Failed to generate key. Please try again.", + style: STextStyles.itemSubtitle( + context, + ).copyWith( + color: + Theme.of(context) + .extension()! + .textError, + ), + ); + } + final key = snapshot.data!; + return RoundedWhiteContainer( + child: Row( + children: [ + Expanded( + child: SelectableText( + key, + style: STextStyles.itemSubtitle12( + context, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 20), + onPressed: () { + Clipboard.setData( + ClipboardData(text: key), + ); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard!", + context: context, + ); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(height: 32), + Text( + "Set a Display Name to use with ShopinBit staff", + 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, + ), + ), + ), + ), + const Spacer(), + PrimaryButton( + label: "Complete Setup", + enabled: _canContinue, + onPressed: _canContinue ? _completeSetup : null, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart new file mode 100644 index 0000000000..6bb0f6a7ce --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -0,0 +1,750 @@ +import 'dart:async'; + +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../notifications/show_flush_bar.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../../services/shopinbit/src/models/address.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/stack_text_field.dart'; +import 'shopinbit_payment_view.dart'; + +class ShopInBitShippingView extends StatefulWidget { + const ShopInBitShippingView({super.key, required this.model}); + + static const String routeName = "/shopInBitShipping"; + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitShippingViewState(); +} + +class _ShopInBitShippingViewState extends State { + late final TextEditingController _nameController; + late final TextEditingController _streetController; + late final TextEditingController _cityController; + late final TextEditingController _postalCodeController; + final TextEditingController _countrySearchController = + TextEditingController(); + late final FocusNode _nameFocusNode; + late final FocusNode _streetFocusNode; + late final FocusNode _cityFocusNode; + late final FocusNode _postalCodeFocusNode; + + // Billing address controllers + late final TextEditingController _billingNameController; + late final TextEditingController _billingStreetController; + late final TextEditingController _billingCityController; + late final TextEditingController _billingPostalCodeController; + final TextEditingController _billingCountrySearchController = + TextEditingController(); + late final FocusNode _billingNameFocusNode; + late final FocusNode _billingStreetFocusNode; + late final FocusNode _billingCityFocusNode; + late final FocusNode _billingPostalCodeFocusNode; + + String? _billingSelectedCountryIso; + bool _differentBilling = false; + + List> _countries = []; + String? _selectedCountryIso; + bool _loadingCountries = false; + + bool _submitting = false; + + bool get _canContinue { + if (_submitting) return false; + final shippingValid = + _nameController.text.trim().isNotEmpty && + _streetController.text.trim().isNotEmpty && + _cityController.text.trim().isNotEmpty && + _postalCodeController.text.trim().isNotEmpty && + _selectedCountryIso != null; + if (!shippingValid) return false; + if (_differentBilling) { + return _billingNameController.text.trim().isNotEmpty && + _billingStreetController.text.trim().isNotEmpty && + _billingCityController.text.trim().isNotEmpty && + _billingPostalCodeController.text.trim().isNotEmpty && + _billingSelectedCountryIso != null; + } + return true; + } + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _streetController = TextEditingController(); + _cityController = TextEditingController(); + _postalCodeController = TextEditingController(); + _nameFocusNode = FocusNode(); + _streetFocusNode = FocusNode(); + _cityFocusNode = FocusNode(); + _postalCodeFocusNode = FocusNode(); + + _billingNameController = TextEditingController(); + _billingStreetController = TextEditingController(); + _billingCityController = TextEditingController(); + _billingPostalCodeController = TextEditingController(); + _billingNameFocusNode = FocusNode(); + _billingStreetFocusNode = FocusNode(); + _billingCityFocusNode = FocusNode(); + _billingPostalCodeFocusNode = FocusNode(); + + for (final node in [ + _nameFocusNode, + _streetFocusNode, + _cityFocusNode, + _postalCodeFocusNode, + _billingNameFocusNode, + _billingStreetFocusNode, + _billingCityFocusNode, + _billingPostalCodeFocusNode, + ]) { + node.addListener(() => setState(() {})); + } + + _fetchCountries(); + } + + @override + void dispose() { + _nameController.dispose(); + _streetController.dispose(); + _cityController.dispose(); + _postalCodeController.dispose(); + _countrySearchController.dispose(); + _nameFocusNode.dispose(); + _streetFocusNode.dispose(); + _cityFocusNode.dispose(); + _postalCodeFocusNode.dispose(); + _billingNameController.dispose(); + _billingStreetController.dispose(); + _billingCityController.dispose(); + _billingPostalCodeController.dispose(); + _billingCountrySearchController.dispose(); + _billingNameFocusNode.dispose(); + _billingStreetFocusNode.dispose(); + _billingCityFocusNode.dispose(); + _billingPostalCodeFocusNode.dispose(); + super.dispose(); + } + + 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 _continue() async { + final name = _nameController.text.trim(); + final street = _streetController.text.trim(); + final city = _cityController.text.trim(); + final postalCode = _postalCodeController.text.trim(); + final country = _selectedCountryIso!; + + widget.model.setShippingAddress( + name: name, + street: street, + city: city, + postalCode: postalCode, + country: country, + ); + + if (widget.model.apiTicketId != 0) { + setState(() => _submitting = true); + try { + // Split name into first/last + final parts = name.split(' '); + final firstName = parts.first; + final lastName = parts.length > 1 ? parts.sublist(1).join(' ') : ''; + + Address? billingAddress; + if (_differentBilling) { + final billingName = _billingNameController.text.trim(); + final billingParts = billingName.split(' '); + final billingFirst = billingParts.first; + final billingLast = billingParts.length > 1 + ? billingParts.sublist(1).join(' ') + : ''; + billingAddress = Address( + firstName: billingFirst, + lastName: billingLast, + street: _billingStreetController.text.trim(), + zip: _billingPostalCodeController.text.trim(), + city: _billingCityController.text.trim(), + country: _billingSelectedCountryIso!, + ); + } + + 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, + ); + + if (resp.hasError) { + // Sandbox may fail here; continue anyway. + debugPrint("submitAddress failed: ${resp.exception?.message}"); + } + } catch (e) { + debugPrint("submitAddress threw: $e"); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + if (!mounted) return; + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + unawaited( + showDialog( + context: context, + builder: (_) => ShopInBitPaymentView(model: widget.model), + ), + ); + } else { + unawaited( + Navigator.of( + context, + ).pushNamed(ShopInBitPaymentView.routeName, arguments: widget.model), + ); + } + } + + Widget _buildField({ + required TextEditingController controller, + required FocusNode focusNode, + required String label, + required bool isDesktop, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular(Constants.size.circularBorderRadius), + child: TextField( + controller: controller, + focusNode: focusNode, + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + style: isDesktop + ? STextStyles.desktopTextExtraSmall(context).copyWith( + color: Theme.of( + context, + ).extension()!.textFieldActiveText, + height: 1.8, + ) + : STextStyles.field(context), + decoration: + standardInputDecoration( + label, + focusNode, + context, + desktopMed: isDesktop, + ).copyWith( + filled: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final spacing = SizedBox(height: isDesktop ? 16 : 12); + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Shipping address", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Where should we deliver your order?", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + SizedBox(height: isDesktop ? 32 : 24), + _buildField( + controller: _nameController, + focusNode: _nameFocusNode, + label: "Full name", + isDesktop: isDesktop, + ), + spacing, + _buildField( + controller: _streetController, + focusNode: _streetFocusNode, + label: "Street address", + isDesktop: isDesktop, + ), + spacing, + Row( + children: [ + Expanded( + child: _buildField( + controller: _cityController, + focusNode: _cityFocusNode, + label: "City", + isDesktop: isDesktop, + ), + ), + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: _buildField( + controller: _postalCodeController, + focusNode: _postalCodeFocusNode, + label: "Postal code", + isDesktop: isDesktop, + ), + ), + ], + ), + spacing, + 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..." : "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), + ), + ), + ), + ), + spacing, + // Billing address toggle. + GestureDetector( + onTap: () { + setState(() { + _differentBilling = !_differentBilling; + if (!_differentBilling) { + // Clear billing fields. + _billingNameController.clear(); + _billingStreetController.clear(); + _billingCityController.clear(); + _billingPostalCodeController.clear(); + _billingSelectedCountryIso = null; + } + }); + }, + child: Row( + children: [ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: _differentBilling, + onChanged: (v) { + setState(() { + _differentBilling = v ?? false; + if (!_differentBilling) { + _billingNameController.clear(); + _billingStreetController.clear(); + _billingCityController.clear(); + _billingPostalCodeController.clear(); + _billingSelectedCountryIso = null; + } + }); + }, + activeColor: Theme.of(context) + .extension()! + .accentColorBlue, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + "Different billing address?", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ), + ], + ), + ), + // Billing fields (expanded). + if (_differentBilling) ...[ + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Billing address", + style: isDesktop + ? STextStyles.desktopTextMedium(context) + : STextStyles.titleBold12(context), + ), + spacing, + _buildField( + controller: _billingNameController, + focusNode: _billingNameFocusNode, + label: "Full name", + isDesktop: isDesktop, + ), + spacing, + _buildField( + controller: _billingStreetController, + focusNode: _billingStreetFocusNode, + label: "Street address", + isDesktop: isDesktop, + ), + spacing, + Row( + children: [ + Expanded( + child: _buildField( + controller: _billingCityController, + focusNode: _billingCityFocusNode, + label: "City", + isDesktop: isDesktop, + ), + ), + SizedBox(width: isDesktop ? 16 : 12), + Expanded( + child: _buildField( + controller: _billingPostalCodeController, + focusNode: _billingPostalCodeFocusNode, + label: "Postal code", + isDesktop: isDesktop, + ), + ), + ], + ), + spacing, + // Billing country dropdown. + ClipRRect( + borderRadius: BorderRadius.circular( + Constants.size.circularBorderRadius, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton2( + value: _billingSelectedCountryIso, + 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) { + _billingCountrySearchController.clear(); + } + }, + onChanged: _loadingCountries + ? null + : (value) { + setState(() { + _billingSelectedCountryIso = value; + }); + }, + hint: Text( + _loadingCountries ? "Loading countries..." : "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: _billingCountrySearchController, + searchInnerWidgetHeight: 48, + searchInnerWidget: TextFormField( + controller: _billingCountrySearchController, + 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), + ), + ), + ), + ), + ], + const Spacer(), + PrimaryButton( + label: _submitting ? "Submitting..." : "Continue to payment", + enabled: _canContinue, + onPressed: _canContinue ? _continue : null, + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 600, + child: Column( + 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, + ), + ), + ], + ), + ); + } + + 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_1.dart b/lib/pages/shopinbit/shopinbit_step_1.dart new file mode 100644 index 0000000000..6e6a097c42 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_1.dart @@ -0,0 +1,200 @@ +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/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 '../exchange_view/sub_widgets/step_row.dart'; +import 'shopinbit_step_2.dart'; + +class ShopInBitStep1 extends StatefulWidget { + const ShopInBitStep1({super.key, required this.model}); + + static const String routeName = "/shopInBitStep1"; + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitStep1State(); +} + +class _ShopInBitStep1State extends State { + late final TextEditingController _nameController; + late final FocusNode _nameFocusNode; + + bool get _canContinue => _nameController.text.trim().isNotEmpty; + + @override + void initState() { + super.initState(); + _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, + ), + ), + ), + ), + const Spacer(), + PrimaryButton( + label: "Next", + enabled: _canContinue, + onPressed: _canContinue ? _continue : null, + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 400, + child: Column( + 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, + ), + ), + ], + ), + ); + } + + 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 new file mode 100644 index 0000000000..5dfef58711 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_2.dart @@ -0,0 +1,305 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../services/shopinbit/shopinbit_service.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/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 '../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 { + const ShopInBitStep2({super.key, required this.model}); + + static const String routeName = "/shopInBitStep2"; + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitStep2State(); +} + +class _ShopInBitStep2State extends State { + ShopInBitCategory? _selected; + + @override + void initState() { + super.initState(); + // Reset category selection. + widget.model.category = null; + _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(); + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ShopInBitStep3(model: widget.model), + ); + } else { + if (skipGuidelines) { + // Returning user — skip guidelines. + widget.model.guidelinesAccepted = true; + Navigator.of(context).pushNamed( + ShopInBitStep4.routeName, + arguments: widget.model, + ); + } else { + Navigator.of(context).pushNamed( + ShopInBitStep3.routeName, + arguments: widget.model, + ); + } + } + } + + 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, + 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, + ), + ), + ], + ), + ), + if (isSelected) + Icon( + Icons.check_circle, + color: Theme.of( + context, + ).extension()!.textDark, + size: isDesktop ? 24 : 20, + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + 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, + ), + 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, + 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: 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), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_step_3.dart b/lib/pages/shopinbit/shopinbit_step_3.dart new file mode 100644 index 0000000000..47db40d6e4 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_3.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; + +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../../themes/stack_colors.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/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 { + const ShopInBitStep3({super.key, required this.model}); + + static const String routeName = "/shopInBitStep3"; + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitStep3State(); +} + +class _ShopInBitStep3State extends State { + bool _agreed = false; + + String _guidelinesText() { + switch (widget.model.category) { + case ShopInBitCategory.concierge: + return "Concierge Service Guidelines:\n\n" + "\u2022 Minimum: fee of 100 EUR or minimum order " + "value of 1,000 EUR.\n\n" + "\u2022 Service Fee: 10% of the order total.\n\n" + "\u2022 Only legal products and services are allowed.\n\n" + "\u2022 Prohibited: precious metals, prescription " + "medicine, live animals, weapons, adult " + "entertainment, EU real estate.\n\n" + "\u2022 Provide a clear and detailed description of the " + "product or service you want to purchase.\n\n" + "\u2022 Include links to the exact item when possible."; + case ShopInBitCategory.travel: + return "Travel Service Guidelines:\n\n" + "\u2022 Recommended budget: 2,500 EUR and above " + "for custom trips.\n\n" + "\u2022 Minimum: fee of 100 EUR or booking value " + "of 1,000 EUR.\n\n" + "\u2022 Service Fee: 10% of the booking amount.\n\n" + "\u2022 Only legal travel services are allowed.\n\n" + "\u2022 Prohibited: sanctioned destinations, illegal " + "bookings, adult entertainment, real estate " + "disguised as travel.\n\n" + "\u2022 Provide full details of your travel request " + "including dates, destinations, and preferences."; + case ShopInBitCategory.car: + return "Car Service Guidelines:\n\n" + "\u2022 Minimum Order: \u20AC20,000.\n\n" + "\u2022 Research Fee: \u20AC223 (incl. VAT) \u2014 " + "one-time, credited toward purchase.\n\n" + "\u2022 Service Fee: 10% of the vehicle value.\n\n" + "\u2022 Only legal vehicle transactions are allowed.\n\n" + "\u2022 Prohibited: export to sanctioned regions, " + "armored/military vehicles without licensing, " + "weapons/tactical accessories, real estate " + "disguised as vehicle purchases.\n\n" + "\u2022 Provide details about the make, model, year, " + "and any specific requirements."; + case null: + return ""; + } + } + + 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); + } + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isDesktop) + StepRow( + count: 4, + current: 2, + width: MediaQuery.of(context).size.width - 32, + ), + if (!isDesktop) const SizedBox(height: 14), + Text( + "Service guidelines", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Please read the following carefully before continuing.", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + SizedBox(height: isDesktop ? 24 : 16), + Flexible( + child: RoundedWhiteContainer( + child: SingleChildScrollView( + child: Text( + _guidelinesText(), + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of(context) + .extension()! + .textDark, + ) + : STextStyles.itemSubtitle12(context), + ), + ), + ), + ), + CheckboxListTile( + value: _agreed, + onChanged: (v) => setState(() => _agreed = v ?? false), + title: Text( + "I have read and agree to the Service Guidelines", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + activeColor: + Theme.of(context).extension()!.accentColorBlue, + ), + SizedBox(height: isDesktop ? 24 : 16), + PrimaryButton( + label: "Next", + enabled: _agreed, + onPressed: _agreed ? _continue : null, + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 650, + 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: content, + ), + ), + ], + ), + ); + } + + 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_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart new file mode 100644 index 0000000000..2288aedcec --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_4.dart @@ -0,0 +1,2287 @@ +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'; + +class ShopInBitStep4 extends StatefulWidget { + const ShopInBitStep4({super.key, required this.model}); + + static const String routeName = "/shopInBitStep4"; + + 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; + 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 && + _departureCountryController.text.trim().isNotEmpty && + _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(() {}); + }); + _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(); + _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", + "Departure: ${_departureCityController.text.trim()}, " + "${_departureCountryController.text.trim()}", + ]; + + 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) { + 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 _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 _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 ? 32 : 24), + + // 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 ? 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(), + ], + ); + } + + 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), + + // 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), + + // Research fee info box + RoundedWhiteContainer( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + 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.", + ), + ], + ), + ), + ), + ], + ), + ), + 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, + ), + ), + ), + 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, + ), + + // === Where === + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Where", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.w500_14(context), + ), + SizedBox(height: isDesktop ? 12 : 8), + TextField( + controller: _departureCountryController, + focusNode: _departureCountryFocusNode, + 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 country", + _departureCountryFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + filled: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + errorText: departureCountryError, + ), + ), + 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, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.datetime, + onChanged: (_) => setState(() {}), + 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", + errorText: departureDateError, + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + TextField( + controller: _returnDateController, + focusNode: _returnDateFocusNode, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.datetime, + onChanged: (_) => setState(() {}), + 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", + 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", + "Spring (Mar-May)", + "Summer (Jun-Aug)", + "Fall (Sep-Nov)", + "Winter (Dec-Feb)", + ], + 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, + ), + ), + + // Travel doesn't need delivery country — destinations are in the form. + SizedBox(height: isDesktop ? 16 : 12), + _buildPrivacyCheckbox(isDesktop), + SizedBox(height: isDesktop ? 16 : 12), + _buildSubmitButton(), + ], + ); + } + + @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), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart new file mode 100644 index 0000000000..76299d1538 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -0,0 +1,549 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../../db/isar/main_db.dart'; +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../../themes/stack_colors.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/rounded_white_container.dart'; +import 'shopinbit_offer_view.dart'; + +class ShopInBitTicketDetail extends StatefulWidget { + const ShopInBitTicketDetail({super.key, required this.model}); + + static const String routeName = "/shopInBitTicketDetail"; + + final ShopInBitOrderModel model; + + @override + State createState() => _ShopInBitTicketDetailState(); +} + +class _ShopInBitTicketDetailState extends State { + late final TextEditingController _messageController; + + String _statusLabel(ShopInBitOrderStatus status) { + switch (status) { + case ShopInBitOrderStatus.pending: + return "Pending"; + case ShopInBitOrderStatus.reviewing: + return "Under review"; + case ShopInBitOrderStatus.offerAvailable: + return "Offer available"; + case ShopInBitOrderStatus.accepted: + return "Accepted"; + case ShopInBitOrderStatus.paymentPending: + return "Awaiting payment"; + case ShopInBitOrderStatus.paid: + return "Paid"; + case ShopInBitOrderStatus.shipping: + return "Shipping"; + case ShopInBitOrderStatus.delivered: + return "Delivered"; + case ShopInBitOrderStatus.closed: + return "Closed"; + case ShopInBitOrderStatus.cancelled: + return "Cancelled"; + case ShopInBitOrderStatus.refunded: + return "Refunded"; + } + } + + Color _statusColor(BuildContext context, ShopInBitOrderStatus status) { + switch (status) { + case ShopInBitOrderStatus.delivered: + return Theme.of(context).extension()!.accentColorGreen; + case ShopInBitOrderStatus.offerAvailable: + return Theme.of(context).extension()!.accentColorBlue; + case ShopInBitOrderStatus.pending: + case ShopInBitOrderStatus.reviewing: + return Theme.of(context).extension()!.accentColorYellow; + case ShopInBitOrderStatus.closed: + case ShopInBitOrderStatus.cancelled: + case ShopInBitOrderStatus.refunded: + return Theme.of(context).extension()!.textSubtitle1; + default: + return Theme.of(context).extension()!.accentColorDark; + } + } + + bool _sending = false; + bool _loading = false; + + @override + void initState() { + super.initState(); + _messageController = TextEditingController(); + if (widget.model.apiTicketId != 0) { + _loadFromApi(); + } + } + + @override + void dispose() { + _messageController.dispose(); + super.dispose(); + } + + bool get _isCarResearch => widget.model.category == ShopInBitCategory.car; + + Future _loadFromApi() async { + setState(() => _loading = true); + try { + final client = ShopInBitService.instance.client; + final id = widget.model.apiTicketId; + + // Car research tickets created via /car-research/log-payment are not + // accessible via /tickets/:id/* endpoints (API returns 403). Skip + // those calls for car tickets to avoid log spam. Local data is used. + if (!_isCarResearch) { + final messagesResp = await client.getMessages(id); + final statusResp = await client.getTicketStatus(id); + + if (!messagesResp.hasError && messagesResp.value != null) { + final apiMessages = messagesResp.value!; + widget.model.clearMessages(); + for (final m in apiMessages) { + widget.model.addMessage( + ShopInBitMessage( + text: m.content, + timestamp: m.timestamp, + isFromUser: !m.fromAgent, + ), + ); + } + } + + if (!statusResp.hasError && statusResp.value != null) { + widget.model.status = ShopInBitOrderModel.statusFromTicketState( + statusResp.value!.state, + ); + } + } + + unawaited( + MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket()), + ); + } catch (_) { + // Silently fall back to local data + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _sendMessage() async { + final text = _messageController.text.trim(); + if (text.isEmpty || _sending) return; + + setState(() => _sending = true); + _messageController.clear(); + + // Add optimistic local message + widget.model.addMessage( + ShopInBitMessage(text: text, timestamp: DateTime.now(), isFromUser: true), + ); + setState(() {}); + + try { + if (widget.model.apiTicketId != 0) { + await ShopInBitService.instance.client.sendMessage( + widget.model.apiTicketId, + text, + ); + // Reload messages from API to get accurate state + await _loadFromApi(); + } + unawaited( + MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket()), + ); + } catch (_) { + // Keep optimistic local message + } finally { + if (mounted) setState(() => _sending = false); + } + } + + String _formatTime(DateTime dt) { + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + return "$hour:$minute"; + } + + static final _imgTagRegex = RegExp( + r']+src="data:image/[^;]+;base64,([^"]+)"[^>]*/?>', + caseSensitive: false, + ); + + List _buildMessageContent( + String html, + bool isDesktop, + Color? textColor, + ) { + final textStyle = + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith(color: textColor); + + final widgets = []; + var lastEnd = 0; + + for (final match in _imgTagRegex.allMatches(html)) { + // Add any text before this + if (match.start > lastEnd) { + final textChunk = html + .substring(lastEnd, match.start) + .replaceAll(RegExp(r''), '') + .replaceAll(RegExp(r''), '\n') + .replaceAll(RegExp(r'<[^>]*>'), '') + .trim(); + if (textChunk.isNotEmpty) { + widgets.add(Text(textChunk, style: textStyle)); + } + } + + // Decode and render the image + try { + final bytes = base64Decode(match.group(1)!); + widgets.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Image.memory(bytes), + ), + ); + } catch (_) { + // Skip malformed images + } + + lastEnd = match.end; + } + + // Add any remaining text after the last + if (lastEnd < html.length) { + final textChunk = html + .substring(lastEnd) + .replaceAll(RegExp(r''), '') + .replaceAll(RegExp(r''), '\n') + .replaceAll(RegExp(r'<[^>]*>'), '') + .trim(); + if (textChunk.isNotEmpty) { + widgets.add(Text(textChunk, style: textStyle)); + } + } + + if (widgets.isEmpty) { + widgets.add(Text('', style: textStyle)); + } + + return widgets; + } + + Widget _chatBubble(ShopInBitMessage message, bool isDesktop) { + final textColor = message.isFromUser ? Colors.white : null; + + return Align( + alignment: message.isFromUser + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + constraints: BoxConstraints(maxWidth: isDesktop ? 380 : 260), + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: message.isFromUser + ? Theme.of(context).extension()!.accentColorBlue + : Theme.of(context).extension()!.popupBG, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(12), + topRight: const Radius.circular(12), + bottomLeft: message.isFromUser + ? const Radius.circular(12) + : Radius.zero, + bottomRight: message.isFromUser + ? Radius.zero + : const Radius.circular(12), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (message.isFromUser) + Text( + message.text, + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith(color: textColor), + ) + else + ..._buildMessageContent(message.text, isDesktop, textColor), + const SizedBox(height: 4), + Text( + _formatTime(message.timestamp), + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith( + fontSize: 10, + color: message.isFromUser + ? Colors.white.withOpacity(0.7) + : Theme.of(context) + .extension()! + .textSubtitle1 + .withOpacity(0.7), + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + final model = widget.model; + + final statusBar = RoundedWhiteContainer( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + model.ticketId ?? "Request", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: _statusColor(context, model.status).withOpacity(0.2), + ), + child: Text( + _statusLabel(model.status), + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context)) + .copyWith(color: _statusColor(context, model.status)), + ), + ), + ], + ), + ); + + final offerBanner = model.status == ShopInBitOrderStatus.offerAvailable + ? Padding( + padding: EdgeInsets.only(bottom: isDesktop ? 16 : 12), + child: RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Offer available", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(height: 4), + Text( + "${model.offerProductName ?? 'Item'} \u2014 " + "${model.offerPrice ?? '0'} EUR", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + SizedBox(height: isDesktop ? 12 : 8), + PrimaryButton( + label: "Review offer", + onPressed: () { + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + + builder: (_) => ShopInBitOfferView(model: model), + ); + } else { + Navigator.of(context).pushNamed( + ShopInBitOfferView.routeName, + arguments: model, + ); + } + }, + ), + ], + ), + ), + ) + : const SizedBox.shrink(); + + final chatArea = Expanded( + child: Stack( + children: [ + ListView.builder( + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: model.messages.length, + itemBuilder: (context, index) { + final message = model.messages[model.messages.length - 1 - index]; + return _chatBubble(message, isDesktop); + }, + ), + if (_loading) + const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), + ); + + final inputBar = Container( + padding: EdgeInsets.all(isDesktop ? 16 : 8), + decoration: BoxDecoration( + color: Theme.of(context).extension()!.popupBG, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + style: + (isDesktop + ? STextStyles.desktopTextExtraSmall(context) + : STextStyles.field(context)) + .copyWith( + color: Theme.of( + context, + ).extension()!.textDark, + ), + decoration: InputDecoration( + hintText: "Type a message...", + hintStyle: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.fieldLabel(context), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + IconButton( + onPressed: _sendMessage, + icon: Icon( + Icons.send, + color: Theme.of( + context, + ).extension()!.accentColorBlue, + ), + ), + ], + ), + ); + + final requestDetailsSection = _isCarResearch && model.requestDescription.isNotEmpty + ? Padding( + padding: EdgeInsets.only(bottom: isDesktop ? 12 : 8), + child: RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Request details", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + const SizedBox(height: 8), + Text( + model.requestDescription, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + ], + ), + ), + ) + : const SizedBox.shrink(); + + final body = Column( + children: [ + statusBar, + offerBanner, + requestDetailsSection, + chatArea, + SizedBox(height: isDesktop ? 12 : 8), + inputBar, + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 600, + maxHeight: 650, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text("Request", style: STextStyles.desktopH3(context)), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 8, + ), + child: body, + ), + ), + ], + ), + ); + } + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text( + model.ticketId ?? "Request", + style: STextStyles.navBarTitle(context), + ), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: body), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart new file mode 100644 index 0000000000..a6d0c8c91f --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; + +import '../../db/isar/main_db.dart'; +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../services/shopinbit/shopinbit_service.dart'; +import '../../themes/stack_colors.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/rounded_white_container.dart'; +import 'shopinbit_ticket_detail.dart'; + +class ShopInBitTicketsView extends StatefulWidget { + const ShopInBitTicketsView({super.key}); + + static const String routeName = "/shopInBitTickets"; + + @override + State createState() => _ShopInBitTicketsViewState(); +} + +class _ShopInBitTicketsViewState extends State { + List _tickets = []; + bool _syncing = false; + + @override + void initState() { + super.initState(); + _loadLocal(); + _syncFromApi(); + } + + void _loadLocal() { + _tickets = MainDB.instance + .getShopInBitTickets() + .map(ShopInBitOrderModel.fromIsarTicket) + .toList(); + } + + Future _syncFromApi() async { + setState(() => _syncing = true); + try { + final service = ShopInBitService.instance; + final customerKey = await service.ensureCustomerKey(); + final resp = await service.client.getTicketsByCustomer(customerKey); + + if (resp.hasError || resp.value == null) return; + + for (final ref in resp.value!) { + final localIdx = _tickets.indexWhere((t) => t.apiTicketId == ref.id); + if (localIdx < 0) continue; + + // Skip API calls for terminal tickets; they can still be + // refreshed on-demand when the user opens the detail view. + final localStatus = _tickets[localIdx].status; + if (localStatus == ShopInBitOrderStatus.closed || + localStatus == ShopInBitOrderStatus.cancelled || + localStatus == ShopInBitOrderStatus.refunded) { + continue; + } + + // Car research tickets return 403 on /tickets/:id/* endpoints. + if (_tickets[localIdx].category == ShopInBitCategory.car) continue; + + final statusResp = await service.client.getTicketStatus(ref.id); + if (statusResp.hasError || statusResp.value == null) continue; + + _tickets[localIdx].status = ShopInBitOrderModel.statusFromTicketState( + statusResp.value!.state, + ); + + final msgsResp = await service.client.getMessages(ref.id); + if (!msgsResp.hasError && msgsResp.value != null) { + _tickets[localIdx].clearMessages(); + for (final m in msgsResp.value!) { + _tickets[localIdx].addMessage( + ShopInBitMessage( + text: m.content, + timestamp: m.timestamp, + isFromUser: !m.fromAgent, + ), + ); + } + } + + await MainDB.instance.putShopInBitTicket( + _tickets[localIdx].toIsarTicket(), + ); + } + } catch (_) { + // Fall back to local data + } finally { + if (mounted) { + _loadLocal(); + setState(() => _syncing = false); + } + } + } + + 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: + return "Concierge"; + case ShopInBitCategory.travel: + return "Travel"; + case ShopInBitCategory.car: + return "Car"; + case null: + return ""; + } + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final list = _tickets.isEmpty + ? Center( + child: Text( + _syncing ? "Loading requests..." : "No requests yet", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ) + : ListView.separated( + shrinkWrap: true, + itemCount: _tickets.length, + separatorBuilder: (_, __) => SizedBox(height: isDesktop ? 16 : 12), + itemBuilder: (context, index) { + final ticket = _tickets[index]; + return GestureDetector( + onTap: () { + if (isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + builder: (_) => ShopInBitTicketDetail(model: ticket), + ); + } else { + Navigator.of(context).pushNamed( + ShopInBitTicketDetail.routeName, + arguments: ticket, + ); + } + }, + child: RoundedWhiteContainer( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + ticket.ticketId ?? "N/A", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.titleBold12(context), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: _statusColor( + context, + ticket.status, + ).withOpacity(0.2), + ), + child: Text( + _statusLabel(ticket.status), + style: + (isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + )) + .copyWith( + color: _statusColor( + context, + ticket.status, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + "${_categoryLabel(ticket.category)} \u2022 " + "${ticket.requestDescription}", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall( + context, + ) + : STextStyles.itemSubtitle12( + context, + ).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ], + ), + ), + SizedBox(width: isDesktop ? 16 : 8), + Icon( + Icons.chevron_right, + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ], + ), + ), + ); + }, + ); + + final content = Stack( + children: [ + list, + if (_syncing) + const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 550, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "My requests", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + child: content, + ), + ), + ], + ), + ); + } + + return Background( + child: Scaffold( + backgroundColor: Theme.of(context).extension()!.background, + appBar: AppBar( + leading: AppBarBackButton( + onPressed: () => Navigator.of(context).pop(), + ), + title: Text("My requests", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding(padding: const EdgeInsets.all(16), child: content), + ), + ), + ); + } +} diff --git a/lib/pages/wallet_view/wallet_view.dart b/lib/pages/wallet_view/wallet_view.dart index 74a129efd8..9b614f0f0f 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -109,6 +109,8 @@ 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'; @@ -1343,6 +1345,39 @@ class _WalletViewState extends ConsumerState { ); }, ), + if (!viewOnly) + WalletNavigationBarItemData( + label: "Services", + icon: SvgPicture.asset( + Assets.svg.solidSliders, + height: 20, + width: 20, + color: Theme.of( + context, + ).extension()!.bottomNavIconIcon, + ), + onTap: () { + Navigator.of(context).pushNamed( + ServicesView.routeName, + ); + }, + ), + WalletNavigationBarItemData( + label: "Gift cards", + icon: SvgPicture.asset( + Assets.svg.creditCard, + height: 20, + width: 20, + color: Theme.of( + context, + ).extension()!.bottomNavIconIcon, + ), + onTap: () { + Navigator.of(context).pushNamed( + GiftCardsView.routeName, + ); + }, + ), ], ), ), diff --git a/lib/pages_desktop_specific/desktop_home_view.dart b/lib/pages_desktop_specific/desktop_home_view.dart index d29aeb4bc9..d1cd371434 100644 --- a/lib/pages_desktop_specific/desktop_home_view.dart +++ b/lib/pages_desktop_specific/desktop_home_view.dart @@ -31,6 +31,7 @@ import 'address_book_view/desktop_address_book.dart'; import 'desktop_buy/desktop_buy_view.dart'; import 'desktop_exchange/desktop_exchange_view.dart'; import 'desktop_menu.dart'; +import 'more_view/sub_widgets/desktop_services_view.dart'; import 'my_stack_view/my_stack_view.dart'; import 'notifications/desktop_notifications_view.dart'; import 'password/desktop_unlock_app_dialog.dart'; @@ -59,10 +60,8 @@ class _DesktopHomeViewState extends ConsumerState { barrierDismissible: false, context: context, useSafeArea: false, - builder: - (context) => const Background( - child: Center(child: DesktopUnlockAppDialog()), - ), + builder: (context) => + const Background(child: Center(child: DesktopUnlockAppDialog())), ); } } @@ -135,6 +134,11 @@ class _DesktopHomeViewState extends ConsumerState { onGenerateRoute: RouteGenerator.generateRoute, initialRoute: DesktopBuyView.routeName, ), + DesktopMenuItemId.services: const Navigator( + key: Key("desktopServicesHomeKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: DesktopServicesView.routeName, + ), DesktopMenuItemId.notifications: const Navigator( key: Key("desktopNotificationsHomeKey"), onGenerateRoute: RouteGenerator.generateRoute, @@ -201,8 +205,9 @@ class _DesktopHomeViewState extends ConsumerState { if (ref.read(currentDesktopMenuItemProvider.state).state == DesktopMenuItemId.notifications && newKey != DesktopMenuItemId.notifications) { - final Set unreadNotificationIds = - ref.read(unreadNotificationsStateProvider.state).state; + final Set unreadNotificationIds = ref + .read(unreadNotificationsStateProvider.state) + .state; if (unreadNotificationIds.isNotEmpty) { final List> futures = []; @@ -244,12 +249,12 @@ class _DesktopHomeViewState extends ConsumerState { child: IndexedStack( index: ref - .watch(currentDesktopMenuItemProvider.state) - .state - .index > - 0 - ? 1 - : 0, + .watch(currentDesktopMenuItemProvider.state) + .state + .index > + 0 + ? 1 + : 0, children: [ myStackViewNav, contentViews[ref diff --git a/lib/pages_desktop_specific/desktop_menu.dart b/lib/pages_desktop_specific/desktop_menu.dart index c0cbf107f5..9835701692 100644 --- a/lib/pages_desktop_specific/desktop_menu.dart +++ b/lib/pages_desktop_specific/desktop_menu.dart @@ -29,6 +29,7 @@ enum DesktopMenuItemId { myStack, exchange, buy, + services, notifications, addressBook, settings, @@ -95,6 +96,7 @@ class _DesktopMenuState extends ConsumerState { DMIController(), DMIController(), DMIController(), + DMIController(), ]; torButtonController = DMIController(); @@ -217,6 +219,17 @@ class _DesktopMenuState extends ConsumerState { ), ], const SizedBox(height: 2), + DesktopMenuItem( + key: const ValueKey('services'), + duration: duration, + icon: const DesktopServicesIcon(), + label: "Services", + value: DesktopMenuItemId.services, + onChanged: updateSelectedMenuItem, + controller: controllers[3], + isExpandedInitially: !_isMinimized, + ), + const SizedBox(height: 2), DesktopMenuItem( key: const ValueKey('notifications'), duration: duration, @@ -224,7 +237,7 @@ class _DesktopMenuState extends ConsumerState { label: "Notifications", value: DesktopMenuItemId.notifications, onChanged: updateSelectedMenuItem, - controller: controllers[3], + controller: controllers[4], isExpandedInitially: !_isMinimized, ), const SizedBox(height: 2), @@ -235,7 +248,7 @@ class _DesktopMenuState extends ConsumerState { label: "Address Book", value: DesktopMenuItemId.addressBook, onChanged: updateSelectedMenuItem, - controller: controllers[4], + controller: controllers[5], isExpandedInitially: !_isMinimized, ), const SizedBox(height: 2), @@ -246,7 +259,7 @@ class _DesktopMenuState extends ConsumerState { label: "Settings", value: DesktopMenuItemId.settings, onChanged: updateSelectedMenuItem, - controller: controllers[5], + controller: controllers[6], isExpandedInitially: !_isMinimized, ), const SizedBox(height: 2), @@ -257,7 +270,7 @@ class _DesktopMenuState extends ConsumerState { label: "Support", value: DesktopMenuItemId.support, onChanged: updateSelectedMenuItem, - controller: controllers[6], + controller: controllers[7], isExpandedInitially: !_isMinimized, ), const SizedBox(height: 2), @@ -268,7 +281,7 @@ class _DesktopMenuState extends ConsumerState { label: "About", value: DesktopMenuItemId.about, onChanged: updateSelectedMenuItem, - controller: controllers[7], + controller: controllers[8], isExpandedInitially: !_isMinimized, ), const Spacer(), @@ -291,7 +304,7 @@ class _DesktopMenuState extends ConsumerState { // SystemNavigator.pop(); // } }, - controller: controllers[8], + controller: controllers[9], isExpandedInitially: !_isMinimized, ), ], diff --git a/lib/pages_desktop_specific/desktop_menu_item.dart b/lib/pages_desktop_specific/desktop_menu_item.dart index ea0d69a81c..3decfaec9b 100644 --- a/lib/pages_desktop_specific/desktop_menu_item.dart +++ b/lib/pages_desktop_specific/desktop_menu_item.dart @@ -41,13 +41,13 @@ class DesktopMyStackIcon extends ConsumerWidget { Assets.svg.walletDesktop, width: 20, height: 20, - color: DesktopMenuItemId.myStack == + color: + DesktopMenuItemId.myStack == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -61,13 +61,13 @@ class DesktopExchangeIcon extends ConsumerWidget { Assets.svg.exchangeDesktop, width: 20, height: 20, - color: DesktopMenuItemId.exchange == + color: + DesktopMenuItemId.exchange == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -81,13 +81,33 @@ class DesktopBuyIcon extends ConsumerWidget { File(ref.watch(themeAssetsProvider).buy), width: 20, height: 20, - color: DesktopMenuItemId.buy == + color: + DesktopMenuItemId.buy == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), + ); + } +} + +class DesktopServicesIcon extends ConsumerWidget { + const DesktopServicesIcon({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SvgPicture.asset( + Assets.svg.solidSliders, + width: 20, + height: 20, + color: + DesktopMenuItemId.services == + ref.watch(currentDesktopMenuItemProvider.state).state + ? Theme.of(context).extension()!.accentColorDark + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -98,15 +118,11 @@ class DesktopNotificationsIcon extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return ref.watch( - notificationsProvider.select((value) => value.hasUnreadNotifications), - ) + notificationsProvider.select((value) => value.hasUnreadNotifications), + ) ? SvgPicture.file( File( - ref.watch( - themeProvider.select( - (value) => value.assets.bellNew, - ), - ), + ref.watch(themeProvider.select((value) => value.assets.bellNew)), ), width: 20, height: 20, @@ -115,20 +131,19 @@ class DesktopNotificationsIcon extends ConsumerWidget { Assets.svg.bell, width: 20, height: 20, - color: ref.watch( - notificationsProvider - .select((value) => value.hasUnreadNotifications), - ) + color: + ref.watch( + notificationsProvider.select( + (value) => value.hasUnreadNotifications, + ), + ) ? null : DesktopMenuItemId.notifications == - ref.watch(currentDesktopMenuItemProvider.state).state - ? Theme.of(context) - .extension()! - .accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + ref.watch(currentDesktopMenuItemProvider.state).state + ? Theme.of(context).extension()!.accentColorDark + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -142,13 +157,13 @@ class DesktopAddressBookIcon extends ConsumerWidget { Assets.svg.addressBookDesktop, width: 20, height: 20, - color: DesktopMenuItemId.addressBook == + color: + DesktopMenuItemId.addressBook == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -162,13 +177,13 @@ class DesktopSettingsIcon extends ConsumerWidget { Assets.svg.gear, width: 20, height: 20, - color: DesktopMenuItemId.settings == + color: + DesktopMenuItemId.settings == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -182,13 +197,13 @@ class DesktopSupportIcon extends ConsumerWidget { Assets.svg.messageQuestion, width: 20, height: 20, - color: DesktopMenuItemId.support == + color: + DesktopMenuItemId.support == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -202,13 +217,13 @@ class DesktopAboutIcon extends ConsumerWidget { Assets.svg.aboutDesktop, width: 20, height: 20, - color: DesktopMenuItemId.about == + color: + DesktopMenuItemId.about == ref.watch(currentDesktopMenuItemProvider.state).state ? Theme.of(context).extension()!.accentColorDark - : Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + : Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -222,10 +237,9 @@ class DesktopExitIcon extends ConsumerWidget { Assets.svg.exitDesktop, width: 20, height: 20, - color: Theme.of(context) - .extension()! - .accentColorDark - .withOpacity(0.8), + color: Theme.of( + context, + ).extension()!.accentColorDark.withOpacity(0.8), ); } } @@ -294,10 +308,7 @@ class _DesktopMenuItemState extends ConsumerState> _iconOnly = !widget.isExpandedInitially; controller?.toggle = toggle; - animationController = AnimationController( - vsync: this, - duration: duration, - ); + animationController = AnimationController(vsync: this, duration: duration); if (_iconOnly) { animationController.value = 0; } else { @@ -321,25 +332,20 @@ class _DesktopMenuItemState extends ConsumerState> return TextButton( style: value == group ? Theme.of(context) - .extension()! - .getDesktopMenuButtonStyleSelected(context) - : Theme.of(context) - .extension()! - .getDesktopMenuButtonStyle(context), + .extension()! + .getDesktopMenuButtonStyleSelected(context) + : Theme.of( + context, + ).extension()!.getDesktopMenuButtonStyle(context), onPressed: () { onChanged(value); }, child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 16, - ), + padding: const EdgeInsets.symmetric(vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - AnimatedContainer( - duration: duration, - width: _iconOnly ? 0 : 16, - ), + AnimatedContainer(duration: duration, width: _iconOnly ? 0 : 16), icon, AnimatedOpacity( duration: duration, @@ -352,9 +358,7 @@ class _DesktopMenuItemState extends ConsumerState> width: labelLength, child: Row( children: [ - const SizedBox( - width: 12, - ), + const SizedBox(width: 12), Text( label, style: value == group diff --git a/lib/pages_desktop_specific/more_view/sub_widgets/desktop_gift_cards_view.dart b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_gift_cards_view.dart new file mode 100644 index 0000000000..8c328bd477 --- /dev/null +++ b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_gift_cards_view.dart @@ -0,0 +1,142 @@ +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'; +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/rounded_white_container.dart'; +import '../../../widgets/tor_subscription.dart'; + +class DesktopGiftCardsView extends ConsumerStatefulWidget { + const DesktopGiftCardsView({super.key}); + + static const String routeName = "/desktopGiftCardsView"; + + @override + ConsumerState createState() => + _DesktopGiftCardsViewState(); +} + +class _DesktopGiftCardsViewState extends ConsumerState { + late bool _torEnabled; + + @override + void initState() { + _torEnabled = AppConfig.hasFeature(AppFeature.tor) + ? ref.read(pTorService).status != TorConnectionStatus.disconnected + : false; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return TorSubscription( + onTorStatusChanged: (status) { + setState(() { + _torEnabled = status != TorConnectionStatus.disconnected; + }); + }, + 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.creditCard, + width: 48, + height: 48, + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: RichText( + textAlign: TextAlign.start, + text: TextSpan( + children: [ + TextSpan( + text: "CakePay", + style: STextStyles.desktopTextSmall(context), + ), + TextSpan( + text: + "\n\nPurchase gift cards with cryptocurrency.", + style: STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ], + ), + ), + ), + if (_torEnabled) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + "CakePay is not available while Tor is enabled", + style: STextStyles.desktopTextExtraExtraSmall(context) + .copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + child: Row( + children: [ + Expanded( + child: PrimaryButton( + buttonHeight: ButtonHeight.m, + label: "Browse Gift Cards", + enabled: !_torEnabled, + onPressed: () { + showDialog( + context: context, + builder: (_) => const CakePayVendorsView(), + ); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: SecondaryButton( + buttonHeight: ButtonHeight.m, + label: "My Orders", + onPressed: () { + showDialog( + context: context, + builder: (_) => const CakePayOrdersView(), + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/more_view/sub_widgets/desktop_services_view.dart b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_services_view.dart new file mode 100644 index 0000000000..4ecc0e9504 --- /dev/null +++ b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_services_view.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../route_generator.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +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 'desktop_gift_cards_view.dart'; +import 'desktop_shopinbit_view.dart'; + +final selectedServicesMenuItemStateProvider = StateProvider((_) => 0); + +class DesktopServicesView extends ConsumerStatefulWidget { + const DesktopServicesView({super.key}); + + static const String routeName = "/desktopServicesView"; + + @override + ConsumerState createState() => + _DesktopServicesViewState(); +} + +class _DesktopServicesViewState extends ConsumerState { + final List _labels = const ["Services", "Gift Cards"]; + + @override + Widget build(BuildContext context) { + final List contentViews = [ + const Navigator( + key: Key("servicesShopInBitDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: DesktopShopInBitView.routeName, + ), + const Navigator( + key: Key("servicesGiftCardsDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: DesktopGiftCardsView.routeName, + ), + ]; + + return DesktopScaffold( + background: Theme.of(context).extension()!.background, + appBar: DesktopAppBar( + isCompactHeight: true, + leading: Row( + children: [ + const SizedBox(width: 24, height: 24), + Text("Services", style: STextStyles.desktopH3(context)), + ], + ), + ), + body: Row( + children: [ + Padding( + padding: const EdgeInsets.all(15.0), + child: Align( + alignment: Alignment.topLeft, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 250, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int i = 0; i < _labels.length; i++) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (i > 0) const SizedBox(height: 2), + SettingsMenuItem( + icon: SvgPicture.asset( + Assets.svg.polygon, + width: 11, + height: 11, + color: + ref + .watch( + selectedServicesMenuItemStateProvider + .state, + ) + .state == + i + ? Theme.of( + context, + ) + .extension()! + .accentColorBlue + : Colors.transparent, + ), + label: _labels[i], + value: i, + group: ref + .watch( + selectedServicesMenuItemStateProvider + .state, + ) + .state, + onChanged: (newValue) => + ref + .read( + selectedServicesMenuItemStateProvider + .state, + ) + .state = + newValue, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: + contentViews[ref + .watch(selectedServicesMenuItemStateProvider.state) + .state], + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/more_view/sub_widgets/desktop_shopinbit_view.dart b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_shopinbit_view.dart new file mode 100644 index 0000000000..54c72a07a6 --- /dev/null +++ b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_shopinbit_view.dart @@ -0,0 +1,500 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../app_config.dart'; +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_tickets_view.dart'; +import '../../../providers/desktop/current_desktop_menu_item.dart'; +import '../../../services/shopinbit/shopinbit_service.dart'; +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.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 '../../desktop_menu.dart'; +import '../../settings/settings_menu.dart'; + +class DesktopShopInBitView extends ConsumerStatefulWidget { + const DesktopShopInBitView({super.key}); + + static const String routeName = "/desktopShopInBitView"; + + @override + ConsumerState createState() => + _DesktopServicesViewState(); +} + +class _DesktopServicesViewState extends ConsumerState { + Future _showOpenBrowserWarning(BuildContext context, String url) async { + final uri = Uri.parse(url); + final shouldContinue = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => 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); + }, + ), + ], + ), + ], + ), + ), + ), + ); + return shouldContinue ?? false; + } + + void _showShopDialog(BuildContext context) async { + final service = ShopInBitService.instance; + final model = ShopInBitOrderModel(); + + if (!service.loadSetupComplete()) { + // First-time user: show setup. + final completed = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _ShopInBitDesktopSetupDialog(model: model), + ); + if (completed != true) return; // user cancelled + } else { + // Returning user: restore display name. + final savedName = service.loadDisplayName(); + if (savedName != null && savedName.isNotEmpty) { + model.displayName = savedName; + } + } + + // Show warning dialog. + if (!mounted) return; + 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: (_) => + ShopInBitStep1(model: model), + ); + if (mounted) setState(() {}); + }, + ), + ], + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return 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.circleSliders, + width: 48, + height: 48, + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: RichText( + textAlign: TextAlign.start, + text: TextSpan( + style: STextStyles.desktopTextExtraExtraSmall(context), + children: [ + TextSpan( + text: "ShopinBit", + style: STextStyles.desktopTextSmall(context), + ), + const TextSpan( + text: + "\n\nTurn your crypto into Electronics, Flights, Hotel, " + "Cars or any other legal product or service... " + "ShopinBit is a concierge shopping service that helps " + "you 'live the good life with crypto'..." + "\n\n" + "Minimum order value of 1,000 EUR. " + "A 10% service fee applies to all orders.\n\n" + "By using ShopinBit, you agree to their ", + ), + TextSpan( + text: "Terms & Conditions", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 14), + recognizer: TapGestureRecognizer() + ..onTap = () async { + const url = + "https://api.shopinbit.com/static/policy/terms.html"; + final shouldOpen = await _showOpenBrowserWarning( + context, + url, + ); + if (shouldOpen) { + await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + } + }, + ), + const TextSpan(text: " and "), + TextSpan( + text: "Privacy Policy", + style: STextStyles.richLink( + context, + ).copyWith(fontSize: 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: "."), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + PrimaryButton( + width: 250, + buttonHeight: ButtonHeight.m, + enabled: true, + label: "Shop with ShopinBit", + onPressed: () => _showShopDialog(context), + ), + const SizedBox(width: 16), + Builder( + builder: (context) { + final count = MainDB.instance + .getShopInBitTickets() + .length; + return SecondaryButton( + width: 200, + buttonHeight: ButtonHeight.m, + label: count > 0 + ? "My requests ($count)" + : "My requests", + onPressed: () async { + await showDialog( + context: context, + builder: (_) => const ShopInBitTicketsView(), + ); + if (mounted) setState(() {}); + }, + ); + }, + ), + const SizedBox(width: 16), + SecondaryButton( + width: 140, + buttonHeight: ButtonHeight.m, + label: "Settings", + onPressed: () { + // ShopInBit is the last settings menu item. + var idx = 8; + if (AppConfig.hasFeature(AppFeature.themeSelection)) { + idx++; + } + ref + .read( + selectedSettingsMenuItemStateProvider.state, + ) + .state = + idx; + ref.read(currentDesktopMenuItemProvider.state).state = + DesktopMenuItemId.settings; + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ShopInBitDesktopSetupDialog extends StatefulWidget { + const _ShopInBitDesktopSetupDialog({required this.model}); + + final ShopInBitOrderModel model; + + @override + State<_ShopInBitDesktopSetupDialog> createState() => + _ShopInBitDesktopSetupDialogState(); +} + +class _ShopInBitDesktopSetupDialogState + extends State<_ShopInBitDesktopSetupDialog> { + late final Future _keyFuture; + late final TextEditingController _nameController; + late final FocusNode _nameFocusNode; + + 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(); + + _nameFocusNode.addListener(() { + setState(() {}); + }); + } + + @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); + if (mounted) { + Navigator.of(context, rootNavigator: true).pop(true); + } + } + + @override + Widget build(BuildContext context) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 500, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + "ShopinBit Setup", + style: STextStyles.desktopH3(context), + ), + ), + const DesktopDialogCloseButton(), + ], + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + "Your Customer Key", + style: STextStyles.desktopTextSmall(context).copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + "This is your ShopinBit customer key: save it " + "somewhere safe, you'll need it to recover " + "your ShopinBit account on a new device.", + style: + STextStyles.desktopTextExtraExtraSmall(context), + ), + const SizedBox(height: 12), + FutureBuilder( + future: _keyFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (snapshot.hasError) { + return Text( + "Failed to generate key. Please try again.", + style: STextStyles.desktopTextSmall(context).copyWith( + color: Theme.of(context) + .extension()! + .textError, + ), + ); + } + final key = snapshot.data!; + return RoundedWhiteContainer( + child: Row( + children: [ + Expanded( + child: SelectableText( + key, + style: + STextStyles.desktopTextExtraExtraSmall( + context, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 20), + onPressed: () { + Clipboard.setData( + ClipboardData(text: key), + ); + showFloatingFlushBar( + type: FlushBarType.info, + message: "Copied to clipboard!", + context: context, + ); + }, + ), + ], + ), + ); + }, + ), + const SizedBox(height: 24), + Text( + "Display Name", + style: STextStyles.desktopTextSmall(context).copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _nameController, + focusNode: _nameFocusNode, + onChanged: (_) => setState(() {}), + style: STextStyles.desktopTextSmall(context), + decoration: const InputDecoration( + hintText: "Display name", + ), + ), + const Spacer(), + PrimaryButton( + label: "Complete Setup", + enabled: _canContinue, + onPressed: _canContinue ? _completeSetup : null, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart index c38b33a61b..242e71e8d1 100644 --- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart +++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_auth_send.dart @@ -26,12 +26,10 @@ import '../../../../widgets/loading_indicator.dart'; import '../../../../widgets/stack_text_field.dart'; class DesktopAuthSend extends ConsumerStatefulWidget { - const DesktopAuthSend({ - super.key, - required this.coin, - }); + const DesktopAuthSend({super.key, required this.coin, this.tokenTicker}); final CryptoCurrency coin; + final String? tokenTicker; @override ConsumerState createState() => _DesktopAuthSendState(); @@ -59,12 +57,7 @@ class _DesktopAuthSendState extends ConsumerState { builder: (context) => const Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, - children: [ - LoadingIndicator( - width: 200, - height: 200, - ), - ], + children: [LoadingIndicator(width: 200, height: 200)], ), ), ); @@ -77,15 +70,8 @@ class _DesktopAuthSendState extends ConsumerState { if (mounted) { Navigator.of(context).pop(); - Navigator.of( - context, - rootNavigator: true, - ).pop(passwordIsValid); - await Future.delayed( - const Duration( - milliseconds: 100, - ), - ); + Navigator.of(context, rootNavigator: true).pop(passwordIsValid); + await Future.delayed(const Duration(milliseconds: 100)); } } finally { _lock = false; @@ -113,29 +99,17 @@ class _DesktopAuthSendState extends ConsumerState { return Column( mainAxisSize: MainAxisSize.min, children: [ - SvgPicture.asset( - Assets.svg.keys, - width: 100, - ), - const SizedBox( - height: 56, - ), + SvgPicture.asset(Assets.svg.keys, width: 100), + const SizedBox(height: 56), + Text("Confirm transaction", style: STextStyles.desktopH3(context)), + const SizedBox(height: 16), Text( - "Confirm transaction", - style: STextStyles.desktopH3(context), - ), - const SizedBox( - height: 16, - ), - Text( - "Enter your wallet password to send ${widget.coin.ticker.toUpperCase()}", + "Enter your wallet password to send ${widget.tokenTicker?.toUpperCase() ?? widget.coin.ticker.toUpperCase()}", style: STextStyles.desktopTextMedium(context).copyWith( color: Theme.of(context).extension()!.textDark3, ), ), - const SizedBox( - height: 24, - ), + const SizedBox(height: 24), ClipRRect( borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, @@ -144,9 +118,7 @@ class _DesktopAuthSendState extends ConsumerState { key: const Key("desktopLoginPasswordFieldKey"), focusNode: passwordFocusNode, controller: passwordController, - style: STextStyles.desktopTextMedium(context).copyWith( - height: 2, - ), + style: STextStyles.desktopTextMedium(context).copyWith(height: 2), obscureText: hidePassword, enableSuggestions: false, autocorrect: false, @@ -156,45 +128,44 @@ class _DesktopAuthSendState extends ConsumerState { _confirmPressed(); } }, - decoration: standardInputDecoration( - "Enter password", - passwordFocusNode, - context, - ).copyWith( - suffixIcon: UnconstrainedBox( - child: SizedBox( - height: 70, - child: Row( - children: [ - const SizedBox( - width: 24, - ), - GestureDetector( - key: const Key( - "restoreFromFilePasswordFieldShowPasswordButtonKey", - ), - onTap: () async { - setState(() { - hidePassword = !hidePassword; - }); - }, - child: SvgPicture.asset( - hidePassword ? Assets.svg.eye : Assets.svg.eyeSlash, - color: Theme.of(context) - .extension()! - .textDark3, - width: 24, - height: 24, - ), + decoration: + standardInputDecoration( + "Enter password", + passwordFocusNode, + context, + ).copyWith( + suffixIcon: UnconstrainedBox( + child: SizedBox( + height: 70, + child: Row( + children: [ + const SizedBox(width: 24), + GestureDetector( + key: const Key( + "restoreFromFilePasswordFieldShowPasswordButtonKey", + ), + onTap: () async { + setState(() { + hidePassword = !hidePassword; + }); + }, + child: SvgPicture.asset( + hidePassword + ? Assets.svg.eye + : Assets.svg.eyeSlash, + color: Theme.of( + context, + ).extension()!.textDark3, + width: 24, + height: 24, + ), + ), + const SizedBox(width: 12), + ], ), - const SizedBox( - width: 12, - ), - ], + ), ), ), - ), - ), onChanged: (newValue) { setState(() { _confirmEnabled = passwordController.text.isNotEmpty; @@ -202,9 +173,7 @@ class _DesktopAuthSendState extends ConsumerState { }, ), ), - const SizedBox( - height: 48, - ), + const SizedBox(height: 48), Row( children: [ Expanded( @@ -214,9 +183,7 @@ class _DesktopAuthSendState extends ConsumerState { onPressed: Navigator.of(context).pop, ), ), - const SizedBox( - width: 16, - ), + const SizedBox(width: 16), Expanded( child: PrimaryButton( enabled: _confirmEnabled, diff --git a/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart b/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart index f503d0bee3..600ebc9c52 100644 --- a/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart +++ b/lib/pages_desktop_specific/ordinals/desktop_ordinal_details_view.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; @@ -11,10 +12,13 @@ import '../../models/isar/models/blockchain_data/utxo.dart'; import '../../models/isar/ordinal.dart'; import '../../networking/http.dart'; import '../../notifications/show_flush_bar.dart'; +import '../../pages/ordinals/widgets/dialogs.dart'; +import '../../pages/send_view/confirm_transaction_view.dart'; import '../../pages/wallet_view/transaction_views/transaction_details_view.dart'; import '../../providers/db/main_db_provider.dart'; import '../../providers/global/wallets_provider.dart'; import '../../services/tor_service.dart'; +import '../desktop_home_view.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/amount/amount.dart'; import '../../utilities/amount/amount_formatter.dart'; @@ -23,10 +27,14 @@ import '../../utilities/constants.dart'; import '../../utilities/prefs.dart'; import '../../utilities/show_loading.dart'; import '../../utilities/text_styles.dart'; +import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart'; import '../../widgets/custom_buttons/app_bar_icon_button.dart'; import '../../widgets/desktop/desktop_app_bar.dart'; +import '../../widgets/desktop/desktop_dialog.dart'; import '../../widgets/desktop/desktop_scaffold.dart'; +import '../../widgets/desktop/primary_button.dart'; import '../../widgets/desktop/secondary_button.dart'; +import '../../widgets/ordinal_image.dart'; import '../../widgets/rounded_white_container.dart'; class DesktopOrdinalDetailsView extends ConsumerStatefulWidget { @@ -141,14 +149,7 @@ class _DesktopOrdinalDetailsViewState borderRadius: BorderRadius.circular( Constants.size.circularBorderRadius, ), - child: Image.network( - widget - .ordinal - .content, // Use the preview URL as the image source - fit: BoxFit.cover, - filterQuality: - FilterQuality.none, // Set the filter mode to nearest - ), + child: OrdinalImage(url: widget.ordinal.content), ), ), const SizedBox(width: 16), @@ -175,33 +176,140 @@ class _DesktopOrdinalDetailsViewState ), ), const SizedBox(width: 16), - // PrimaryButton( - // width: 150, - // label: "Send", - // icon: SvgPicture.asset( - // Assets.svg.send, - // width: 18, - // height: 18, - // color: Theme.of(context) - // .extension()! - // .buttonTextPrimary, - // ), - // buttonHeight: ButtonHeight.l, - // iconSpacing: 8, - // onPressed: () async { - // final response = await showDialog( - // context: context, - // builder: (_) => - // const SendOrdinalUnfreezeDialog(), - // ); - // if (response == "unfreeze") { - // // TODO: unfreeze and go to send ord screen - // } - // }, - // ), - // const SizedBox( - // width: 16, - // ), + PrimaryButton( + width: 150, + label: "Send", + icon: SvgPicture.asset( + Assets.svg.send, + width: 18, + height: 18, + color: Theme.of( + context, + ).extension()!.buttonTextPrimary, + ), + buttonHeight: ButtonHeight.l, + iconSpacing: 8, + onPressed: () async { + final utxo = widget.ordinal.getUTXO( + ref.read(mainDBProvider), + ); + if (utxo == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Could not find ordinal UTXO", + context: context, + ), + ); + return; + } + + if (utxo.isBlocked) { + final unfreezeResponse = + await showDialog( + context: context, + builder: (_) => + const SendOrdinalUnfreezeDialog(), + ); + if (unfreezeResponse != "unfreeze") return; + } + + if (!context.mounted) return; + + final address = await showDialog( + context: context, + builder: (_) => OrdinalRecipientAddressDialog( + inscriptionNumber: + widget.ordinal.inscriptionNumber, + ), + ); + if (address == null || address.isEmpty) return; + + final wallet = ref + .read(pWallets) + .getWallet(widget.walletId); + if (!wallet.cryptoCurrency.validateAddress( + address, + )) { + if (context.mounted) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: "Invalid address", + context: context, + ), + ); + } + return; + } + + if (!context.mounted) return; + + final OrdinalsInterface? ordinalsWallet = + wallet is OrdinalsInterface ? wallet : null; + if (ordinalsWallet == null) { + unawaited( + showFloatingFlushBar( + type: FlushBarType.warning, + message: + "Wallet does not support ordinals", + context: context, + ), + ); + return; + } + + bool didError = false; + final txData = await showLoading( + whileFuture: ordinalsWallet + .prepareOrdinalSend( + ordinalUtxo: utxo, + recipientAddress: address, + ), + context: context, + rootNavigator: true, + message: "Preparing transaction...", + onException: (e) { + didError = true; + String msg = e.toString(); + while (msg.isNotEmpty && + msg.startsWith("Exception:")) { + msg = msg.substring(10).trim(); + } + if (context.mounted) { + showFloatingFlushBar( + type: FlushBarType.warning, + message: msg, + context: context, + ); + } + }, + ); + + if (didError || + txData == null || + !context.mounted) { + return; + } + + await showDialog( + context: context, + builder: (context) => DesktopDialog( + maxHeight: + MediaQuery.of(context).size.height - 64, + maxWidth: 580, + child: ConfirmTransactionView( + walletId: widget.walletId, + txData: txData, + routeOnSuccessName: + DesktopHomeView.routeName, + onSuccess: () {}, + ), + ), + ); + }, + ), + const SizedBox(width: 16), SecondaryButton( width: 150, label: "Download", diff --git a/lib/pages_desktop_specific/settings/desktop_settings_view.dart b/lib/pages_desktop_specific/settings/desktop_settings_view.dart index d0747f7b63..4247186964 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 '../../providers/providers.dart'; import '../../route_generator.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/text_styles.dart'; @@ -25,6 +26,7 @@ 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'; @@ -39,69 +41,72 @@ class DesktopSettingsView extends ConsumerStatefulWidget { } class _DesktopSettingsViewState extends ConsumerState { - final List contentViews = [ - const Navigator( - key: Key("settingsBackupRestoreDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: BackupRestoreSettings.routeName, - ), //b+r - const Navigator( - key: Key("settingsSecurityDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: SecuritySettings.routeName, - ), //security - const Navigator( - key: Key("settingsCurrencyDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: CurrencySettings.routeName, - ), //currency - const Navigator( - key: Key("settingsLanguageDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: LanguageOptionSettings.routeName, - ), - const Navigator( - key: Key("settingsTorDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: TorSettings.routeName, - ), //tor - const Navigator( - key: Key("settingsNodesDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: NodesSettings.routeName, - ), //nodes - const Navigator( - key: Key("settingsSyncingPreferencesDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: SyncingPreferencesSettings.routeName, - ), //syncing prefs - if (AppConfig.hasFeature(AppFeature.themeSelection)) - const Navigator( - key: Key("settingsAppearanceDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: AppearanceOptionSettings.routeName, - ), //appearance - const Navigator( - key: Key("settingsAdvancedDesktopKey"), - onGenerateRoute: RouteGenerator.generateRoute, - initialRoute: AdvancedSettings.routeName, - ), //advanced - ]; - @override Widget build(BuildContext context) { + final familiarity = ref.watch( + prefsChangeNotifierProvider.select((v) => v.familiarity), + ); + + final List contentViews = [ + const Navigator( + key: Key("settingsBackupRestoreDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: BackupRestoreSettings.routeName, + ), //b+r + const Navigator( + key: Key("settingsSecurityDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: SecuritySettings.routeName, + ), //security + const Navigator( + key: Key("settingsCurrencyDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: CurrencySettings.routeName, + ), //currency + const Navigator( + key: Key("settingsLanguageDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: LanguageOptionSettings.routeName, + ), + const Navigator( + key: Key("settingsTorDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: TorSettings.routeName, + ), //tor + const Navigator( + key: Key("settingsNodesDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: NodesSettings.routeName, + ), //nodes + const Navigator( + key: Key("settingsSyncingPreferencesDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: SyncingPreferencesSettings.routeName, + ), //syncing prefs + if (AppConfig.hasFeature(AppFeature.themeSelection)) + const Navigator( + key: Key("settingsAppearanceDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: AppearanceOptionSettings.routeName, + ), //appearance + const Navigator( + key: Key("settingsAdvancedDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: AdvancedSettings.routeName, + ), //advanced + if (familiarity >= 6) + const Navigator( + key: Key("settingsShopInBitDesktopKey"), + onGenerateRoute: RouteGenerator.generateRoute, + initialRoute: ShopInBitDesktopSettings.routeName, + ), //shopinbit + ]; return DesktopScaffold( background: Theme.of(context).extension()!.background, appBar: const DesktopAppBar( isCompactHeight: true, leading: Row( - children: [ - SizedBox( - width: 24, - height: 24, - ), - DesktopSettingsTitle(), - ], + children: [SizedBox(width: 24, height: 24), DesktopSettingsTitle()], ), ), body: Row( @@ -110,14 +115,14 @@ class _DesktopSettingsViewState extends ConsumerState { padding: EdgeInsets.all(15.0), child: Align( alignment: Alignment.topLeft, - child: SingleChildScrollView( - child: SettingsMenu(), - ), + child: SingleChildScrollView(child: SettingsMenu()), ), ), Expanded( - child: contentViews[ - ref.watch(selectedSettingsMenuItemStateProvider.state).state], + child: + contentViews[ref + .watch(selectedSettingsMenuItemStateProvider.state) + .state], ), ], ), @@ -130,9 +135,6 @@ class DesktopSettingsTitle extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( - "Settings", - style: STextStyles.desktopH3(context), - ); + return Text("Settings", style: STextStyles.desktopH3(context)); } } diff --git a/lib/pages_desktop_specific/settings/settings_menu.dart b/lib/pages_desktop_specific/settings/settings_menu.dart index 1ec12e5f65..a7f5129c1a 100644 --- a/lib/pages_desktop_specific/settings/settings_menu.dart +++ b/lib/pages_desktop_specific/settings/settings_menu.dart @@ -13,6 +13,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; +import '../../providers/providers.dart'; import '../../themes/stack_colors.dart'; import '../../utilities/assets.dart'; import 'settings_menu_item.dart'; @@ -20,31 +21,34 @@ import 'settings_menu_item.dart'; final selectedSettingsMenuItemStateProvider = StateProvider((_) => 0); class SettingsMenu extends ConsumerStatefulWidget { - const SettingsMenu({ - super.key, - }); + const SettingsMenu({super.key}); @override ConsumerState createState() => _SettingsMenuState(); } class _SettingsMenuState extends ConsumerState { - final List labels = [ - "Backup and restore", - "Security", - "Currency", - "Language", - "Tor settings", - "Nodes", - "Syncing preferences", - if (AppConfig.hasFeature(AppFeature.themeSelection)) "Appearance", - "Advanced", - ]; - @override Widget build(BuildContext context) { debugPrint("BUILD: $runtimeType"); + final familiarity = ref.watch( + prefsChangeNotifierProvider.select((v) => v.familiarity), + ); + + final List labels = [ + "Backup and restore", + "Security", + "Currency", + "Language", + "Tor settings", + "Nodes", + "Syncing preferences", + if (AppConfig.hasFeature(AppFeature.themeSelection)) "Appearance", + "Advanced", + if (familiarity >= 6) "ShopinBit", + ]; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -57,25 +61,23 @@ class _SettingsMenuState extends ConsumerState { Column( mainAxisSize: MainAxisSize.min, children: [ - if (i > 0) - const SizedBox( - height: 2, - ), + if (i > 0) const SizedBox(height: 2), SettingsMenuItem( icon: SvgPicture.asset( Assets.svg.polygon, width: 11, height: 11, - color: ref + color: + ref .watch( selectedSettingsMenuItemStateProvider .state, ) .state == i - ? Theme.of(context) - .extension()! - .accentColorBlue + ? Theme.of( + context, + ).extension()!.accentColorBlue : Colors.transparent, ), label: labels[i], @@ -83,9 +85,13 @@ class _SettingsMenuState extends ConsumerState { group: ref .watch(selectedSettingsMenuItemStateProvider.state) .state, - onChanged: (newValue) => ref - .read(selectedSettingsMenuItemStateProvider.state) - .state = newValue, + onChanged: (newValue) => + ref + .read( + selectedSettingsMenuItemStateProvider.state, + ) + .state = + newValue, ), ], ), diff --git a/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart b/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart new file mode 100644 index 0000000000..f311c9ed93 --- /dev/null +++ b/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart @@ -0,0 +1,542 @@ +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/route_generator.dart b/lib/route_generator.dart index cad05cbcdb..83696c01af 100644 --- a/lib/route_generator.dart +++ b/lib/route_generator.dart @@ -29,6 +29,7 @@ import 'models/keys/key_data_interface.dart'; import 'models/keys/view_only_wallet_data.dart'; import 'models/paynym/paynym_account_lite.dart'; import 'models/send_view_auto_fill_data.dart'; +import 'models/shopinbit/shopinbit_order_model.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_solana_token_view.dart'; import 'pages/add_wallet_views/add_token_view/add_custom_token_view.dart'; import 'pages/add_wallet_views/add_token_view/edit_wallet_tokens_view.dart'; @@ -57,6 +58,12 @@ import 'pages/address_book_views/subviews/edit_contact_name_emoji_view.dart'; import 'pages/buy_view/buy_in_wallet_view.dart'; import 'pages/buy_view/buy_quote_preview.dart'; import 'pages/buy_view/buy_view.dart'; +import 'pages/cakepay/cakepay_card_detail_view.dart'; +import 'pages/cakepay/cakepay_confirm_send_view.dart'; +import 'pages/cakepay/cakepay_order_view.dart'; +import 'pages/cakepay/cakepay_orders_view.dart'; +import 'pages/cakepay/cakepay_send_from_view.dart'; +import 'pages/cakepay/cakepay_vendors_view.dart'; import 'pages/cashfusion/cashfusion_view.dart'; import 'pages/cashfusion/fusion_progress_view.dart'; import 'pages/churning/churning_progress_view.dart'; @@ -82,6 +89,15 @@ import 'pages/masternodes/create_masternode_view.dart'; import 'pages/masternodes/masternode_details_view.dart'; import 'pages/masternodes/masternodes_home_view.dart'; import 'pages/monkey/monkey_view.dart'; +import 'pages/cakepay/cakepay_card_detail_view.dart'; +import 'services/cakepay/src/models/card.dart'; +import 'pages/cakepay/cakepay_confirm_send_view.dart'; +import 'pages/cakepay/cakepay_order_view.dart'; +import 'pages/cakepay/cakepay_orders_view.dart'; +import 'pages/cakepay/cakepay_send_from_view.dart'; +import 'pages/cakepay/cakepay_vendors_view.dart'; +import 'pages/more_view/gift_cards_view.dart'; +import 'pages/more_view/services_view.dart'; import 'pages/namecoin_names/buy_domain_view.dart'; import 'pages/namecoin_names/confirm_name_transaction_view.dart'; import 'pages/namecoin_names/manage_domain_view.dart'; @@ -164,6 +180,21 @@ import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_setting import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/spark_view_key_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/wallet_settings_wallet_settings_view.dart'; import 'pages/settings_views/wallet_settings_view/wallet_settings_wallet_settings/xpub_view.dart'; +import 'pages/shopinbit/shopinbit_car_fee_view.dart'; +import 'pages/shopinbit/shopinbit_car_research_payment_view.dart'; +import 'pages/shopinbit/shopinbit_offer_view.dart'; +import 'pages/shopinbit/shopinbit_order_created.dart'; +import 'pages/shopinbit/shopinbit_payment_view.dart'; +import 'pages/shopinbit/shopinbit_send_from_view.dart'; +import 'pages/shopinbit/shopinbit_settings_view.dart'; +import 'pages/shopinbit/shopinbit_setup_view.dart'; +import 'pages/shopinbit/shopinbit_shipping_view.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/shopinbit/shopinbit_ticket_detail.dart'; +import 'pages/shopinbit/shopinbit_tickets_view.dart'; import 'pages/signing/signing_view.dart'; import 'pages/signing/sub_widgets/address_list.dart'; import 'pages/spark_names/buy_spark_name_view.dart'; @@ -199,6 +230,9 @@ import 'pages_desktop_specific/desktop_buy/desktop_buy_view.dart'; import 'pages_desktop_specific/desktop_exchange/desktop_all_trades_view.dart'; import 'pages_desktop_specific/desktop_exchange/desktop_exchange_view.dart'; import 'pages_desktop_specific/desktop_home_view.dart'; +import 'pages_desktop_specific/more_view/sub_widgets/desktop_gift_cards_view.dart'; +import 'pages_desktop_specific/more_view/sub_widgets/desktop_services_view.dart'; +import 'pages_desktop_specific/more_view/sub_widgets/desktop_shopinbit_view.dart'; import 'pages_desktop_specific/mweb_utxos_view.dart'; import 'pages_desktop_specific/my_stack_view/my_stack_view.dart'; import 'pages_desktop_specific/my_stack_view/wallet_view/desktop_sol_token_view.dart'; @@ -227,11 +261,14 @@ 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'; +import 'services/cakepay/src/models/card.dart'; import 'services/event_bus/events/global/node_connection_status_changed_event.dart'; import 'services/event_bus/events/global/wallet_sync_status_changed_event.dart'; +import 'services/shopinbit/src/models/car_research.dart'; import 'utilities/amount/amount.dart'; import 'utilities/enums/add_wallet_type_enum.dart'; import 'wallets/crypto_currency/crypto_currency.dart'; @@ -1029,6 +1066,225 @@ class RouteGenerator { } return _routeError("${settings.name} invalid args: ${args.toString()}"); + case ServicesView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ServicesView(), + settings: RouteSettings(name: settings.name), + ); + + case GiftCardsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const GiftCardsView(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitSetupView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitSetupView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CakePayVendorsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const CakePayVendorsView(), + settings: RouteSettings(name: settings.name), + ); + + case CakePayCardDetailView.routeName: + if (args is CakePayCard) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CakePayCardDetailView(card: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CakePayOrderView.routeName: + if (args is String) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CakePayOrderView(orderId: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CakePayOrdersView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const CakePayOrdersView(), + settings: RouteSettings(name: settings.name), + ); + + case CakePaySendFromView.routeName: + if (args is Map) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => CakePaySendFromView( + address: args['address'] as String, + orderId: args['orderId'] as String, + coin: args['coin'] as CryptoCurrency?, + amount: args['amount'] as Amount?, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case CakePayConfirmSendView.routeName: + return _routeError("${settings.name} should be pushed directly"); + + case ShopInBitStep1.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitStep1(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitStep2.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitStep2(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitStep3.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitStep3(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitStep4.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitStep4(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitOrderCreated.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitOrderCreated(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitTicketsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ShopInBitTicketsView(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitSettingsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const ShopInBitSettingsView(), + settings: RouteSettings(name: settings.name), + ); + + case ShopInBitTicketDetail.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitTicketDetail(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitOfferView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitOfferView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitShippingView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitShippingView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitCarFeeView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitCarFeeView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitCarResearchPaymentView.routeName: + if (args is (ShopInBitOrderModel, CarResearchInvoice)) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitCarResearchPaymentView( + model: args.$1, + invoice: args.$2, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitPaymentView.routeName: + if (args is ShopInBitOrderModel) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitPaymentView(model: args), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + + case ShopInBitSendFromView.routeName: + if (args + is Tuple4) { + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => ShopInBitSendFromView( + coin: args.item1, + amount: args.item2, + address: args.item3, + model: args.item4, + ), + settings: RouteSettings(name: settings.name), + ); + } + return _routeError("${settings.name} invalid args: ${args.toString()}"); + case GlobalSettingsView.routeName: return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, @@ -2332,6 +2588,27 @@ class RouteGenerator { settings: RouteSettings(name: settings.name), ); + case DesktopServicesView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const DesktopServicesView(), + settings: RouteSettings(name: settings.name), + ); + + case DesktopShopInBitView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const DesktopShopInBitView(), + settings: RouteSettings(name: settings.name), + ); + + case DesktopGiftCardsView.routeName: + return getRoute( + shouldUseMaterialRoute: useMaterialPageRoute, + builder: (_) => const DesktopGiftCardsView(), + settings: RouteSettings(name: settings.name), + ); + case MyStackView.routeName: return getRoute( shouldUseMaterialRoute: useMaterialPageRoute, @@ -2462,6 +2739,13 @@ 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_api.dart b/lib/services/cakepay/cakepay_api.dart new file mode 100644 index 0000000000..c3af35a833 --- /dev/null +++ b/lib/services/cakepay/cakepay_api.dart @@ -0,0 +1,10 @@ +export 'src/client.dart'; +export 'src/api_response.dart'; +export 'src/api_exception.dart'; +export 'src/endpoints.dart'; +export 'src/models/vendor.dart'; +export 'src/models/card.dart'; +export 'src/models/country.dart'; +export 'src/models/order.dart'; +export 'src/models/order_item.dart'; +export 'src/models/category.dart'; diff --git a/lib/services/cakepay/cakepay_service.dart b/lib/services/cakepay/cakepay_service.dart new file mode 100644 index 0000000000..1016bc4b77 --- /dev/null +++ b/lib/services/cakepay/cakepay_service.dart @@ -0,0 +1,51 @@ +import '../../db/hive/db.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, + ); + } + } + + /// 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 []; + } +} diff --git a/lib/services/cakepay/src/api_exception.dart b/lib/services/cakepay/src/api_exception.dart new file mode 100644 index 0000000000..6e35192572 --- /dev/null +++ b/lib/services/cakepay/src/api_exception.dart @@ -0,0 +1,24 @@ +class ApiException implements Exception { + final String message; + final int? statusCode; + final String? responseBody; + + ApiException(this.message, {this.statusCode, this.responseBody}); + + factory ApiException.fromResponse(int statusCode, String body) { + return ApiException( + 'HTTP $statusCode', + statusCode: statusCode, + responseBody: body, + ); + } + + factory ApiException.network(Object error) { + return ApiException('Network error: $error'); + } + + @override + String toString() => + 'ApiException: $message' + '${statusCode != null ? ' (status: $statusCode)' : ''}'; +} diff --git a/lib/services/cakepay/src/api_response.dart b/lib/services/cakepay/src/api_response.dart new file mode 100644 index 0000000000..27fd26d3e4 --- /dev/null +++ b/lib/services/cakepay/src/api_response.dart @@ -0,0 +1,19 @@ +import 'api_exception.dart'; + +class ApiResponse { + final T? value; + final ApiException? exception; + + ApiResponse({this.value, this.exception}); + + bool get hasError => exception != null; + + T get valueOrThrow { + if (exception != null) throw exception!; + if (value == null) throw ApiException('Response has no value'); + return value as T; + } + + @override + String toString() => '{error: $exception, value: $value}'; +} diff --git a/lib/services/cakepay/src/client.dart b/lib/services/cakepay/src/client.dart new file mode 100644 index 0000000000..daaf7f0440 --- /dev/null +++ b/lib/services/cakepay/src/client.dart @@ -0,0 +1,567 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../../app_config.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import 'api_exception.dart'; +import 'api_response.dart'; +import 'endpoints.dart'; +import 'models/card.dart'; +import 'models/country.dart'; +import 'models/order.dart'; +import 'models/vendor.dart'; + +const _kTag = "CakePayClient"; + +class CakePayClient { + final String baseUrl; + final String apiToken; + final HTTP _httpClient; + + CakePayClient({ + this.baseUrl = Endpoints.base, + required this.apiToken, + HTTP? httpClient, + }) : _httpClient = httpClient ?? const HTTP(); + + Map _headers() => { + 'Authorization': 'Bearer $apiToken', + 'Content-Type': 'application/json', + }; + + ({InternetAddress host, int port})? get _proxyInfo => + !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + // -- Marketplace -- + + Future>> getVendors({ + String? country, + String? countryCode, + String? search, + int? page, + int? pageSize, + bool? all, + bool? giftCards, + bool? prepaidCards, + bool? onDemand, + bool? custom, + }) async { + final query = {}; + if (country != null) query['country'] = country; + if (countryCode != null) query['country_code'] = countryCode; + if (search != null) query['search'] = search; + if (page != null) query['page'] = page.toString(); + if (pageSize != null) query['page_size'] = pageSize.toString(); + if (all != null) query['all'] = all.toString(); + if (giftCards != null) query['gift_cards'] = giftCards.toString(); + if (prepaidCards != null) query['prepaid_cards'] = prepaidCards.toString(); + if (onDemand != null) query['on_demand'] = onDemand.toString(); + if (custom != null) query['custom'] = custom.toString(); + + return _requestRaw( + 'GET', + '/marketplace/vendors/', + query: query, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayVendor.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayVendor.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + Future> getCard(int id) async { + return _request( + 'GET', + '/marketplace/cards/$id/', + parse: CakePayCard.fromJson, + ); + } + + Future>> searchCards({ + String? query, + String? category, + String? country, + double? minPrice, + double? maxPrice, + bool? availableOnly, + int? page, + }) async { + final params = {}; + if (query != null) params['query'] = query; + if (category != null) params['category'] = category; + if (country != null) params['country'] = country; + if (minPrice != null) params['min_price'] = minPrice.toString(); + if (maxPrice != null) params['max_price'] = maxPrice.toString(); + if (availableOnly != null) { + params['available_only'] = availableOnly.toString(); + } + if (page != null) params['page'] = page.toString(); + + return _requestRaw( + 'GET', + '/marketplace/cards/search/', + query: params, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + Future>> getFeaturedCards({int? page}) async { + final query = {}; + if (page != null) query['page'] = page.toString(); + + return _requestRaw( + 'GET', + '/marketplace/cards/featured/', + query: query, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + Future>> getCountries({ + int? page, + int? pageSize, + }) async { + final query = {}; + if (page != null) query['page'] = page.toString(); + if (pageSize != null) query['page_size'] = pageSize.toString(); + + return _requestRaw( + 'GET', + '/marketplace/countries/', + query: query, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayCountry.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayCountry.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + /// Fetches all countries by following pagination til last page. + Future>> getAllCountries({ + int pageSize = 250, + }) async { + try { + final allCountries = []; + int page = 1; + + while (true) { + final response = await _send( + 'GET', + '/marketplace/countries/', + query: {'page': page.toString(), 'page_size': pageSize.toString()}, + ); + + if (response.code < 200 || response.code >= 300) { + Logging.instance.w( + "$_kTag GET /marketplace/countries/ HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + + final decoded = jsonDecode(response.body); + + // Handle non-paginated response (plain list). + if (decoded is List) { + return ApiResponse( + value: decoded + .whereType>() + .map(CakePayCountry.fromJson) + .toList(), + ); + } + + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + allCountries.addAll( + results.whereType>().map( + CakePayCountry.fromJson, + ), + ); + } + + // If there is no next page we're done. + if (decoded['next'] == null) break; + } else { + break; + } + + page++; + } + + return ApiResponse(value: allCountries); + } on ApiException catch (e) { + Logging.instance.e("$_kTag getAllCountries threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag getAllCountries threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + /// List cards from the marketplace with optional pagination. + Future>> getCards({ + int? page, + int? pageSize, + }) async { + final query = {}; + if (page != null) query['page'] = page.toString(); + if (pageSize != null) query['page_size'] = pageSize.toString(); + + return _requestRaw( + 'GET', + '/marketplace/cards/', + query: query, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayCard.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + /// Fetches the list of marketplace providers. + /// + /// Endpoint: GET `/marketplace/providers/` + Future>>> getProviders() async { + return _requestRaw( + 'GET', + '/marketplace/providers/', + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded.whereType>().toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results.whereType>().toList(); + } + } + return []; + }, + ); + } + + /// Fetches marketplace statistics. + /// + /// Endpoint: GET `/marketplace/stats/` + Future>> getStats() async { + return _request('GET', '/marketplace/stats/', parse: (json) => json); + } + + Future>> getBannedCountries() async { + return _requestRaw( + 'GET', + '/core/banned_countries/', + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded.whereType().toList(); + } + return []; + }, + ); + } + + // -- Orders -- + + /// Create an order via the seller API. + /// + /// Posts to `/orders/seller/create/`. The response wraps the order object + /// in `{"message": "...", "order": {...}}`, so we extract `json['order']` + /// before parsing. + Future> createOrder({ + required int cardId, + required String price, + int? quantity, + String? userEmail, + bool? sendEmail, + String? externalOrderId, + String? markupPercent, + bool? confirmsNoVpn, + bool? confirmsVoidedRefund, + bool? confirmsTermsAgreed, + }) async { + final body = {'card_id': cardId, 'price': price}; + if (quantity != null) body['quantity'] = quantity; + if (userEmail != null) body['user_email'] = userEmail; + if (sendEmail != null) body['send_email'] = sendEmail; + if (externalOrderId != null) body['external_order_id'] = externalOrderId; + if (markupPercent != null) body['markup_percent'] = markupPercent; + if (confirmsNoVpn != null) body['confirms_no_vpn'] = confirmsNoVpn; + if (confirmsVoidedRefund != null) { + body['confirms_voided_refund'] = confirmsVoidedRefund; + } + if (confirmsTermsAgreed != null) { + body['confirms_terms_agreed'] = confirmsTermsAgreed; + } + + return _requestRaw( + 'POST', + '/orders/seller/create/', + body: body, + parse: (responseBody) { + final decoded = jsonDecode(responseBody); + if (decoded is Map) { + final orderData = decoded['order']; + if (orderData is Map) { + return CakePayOrder.fromJson(orderData); + } + return CakePayOrder.fromJson(decoded); + } + return CakePayOrder.fromJson({}); + }, + ); + } + + /// Fetch a single order via the seller API. + Future> getOrder(String orderId) async { + return _request( + 'GET', + '/orders/seller/order/$orderId/', + parse: CakePayOrder.fromJson, + ); + } + + /// Fetch the current user's orders. + /// + /// **Note:** This endpoint requires Knox user authentication (email OTP + /// flow), not the seller API key. It will fail when called with only the + /// seller bearer token. + Future>> getMyOrders({ + int? page, + List? orderIds, + }) async { + final query = {}; + if (page != null) query['page'] = page.toString(); + if (orderIds != null && orderIds.isNotEmpty) { + query['order_ids'] = orderIds.join(','); + } + + return _requestRaw( + 'GET', + '/orders/my_orders/', + query: query, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded + .whereType>() + .map(CakePayOrder.fromJson) + .toList(); + } + if (decoded is Map) { + final results = decoded['results']; + if (results is List) { + return results + .whereType>() + .map(CakePayOrder.fromJson) + .toList(); + } + } + return []; + }, + ); + } + + // -- Internal -- + + Future _send( + String method, + String path, { + Map? body, + Map? query, + }) async { + var uri = Uri.parse('$baseUrl$path'); + if (query != null && query.isNotEmpty) { + uri = uri.replace(queryParameters: query); + } + final headers = _headers(); + final proxy = _proxyInfo; + + Logging.instance.t("$_kTag $method $uri"); + + switch (method) { + case 'GET': + return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy); + case 'POST': + return _httpClient.post( + url: uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + proxyInfo: proxy, + ); + default: + throw ApiException('Unsupported method: $method'); + } + } + + Future> _request( + String method, + String path, { + Map? body, + Map? query, + required T Function(Map) parse, + }) async { + try { + final response = await _send(method, path, body: body, query: query); + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag $method $path HTTP:${response.code}"); + if (response.body.isEmpty) { + return ApiResponse(value: parse({})); + } + final json = jsonDecode(response.body) as Map; + return ApiResponse(value: parse(json)); + } else { + Logging.instance.w( + "$_kTag $method $path HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e("$_kTag _request($method $path) threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _request($method $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + Future> _requestRaw( + String method, + String path, { + Map? body, + Map? query, + required T Function(String) parse, + }) async { + try { + final response = await _send(method, path, body: body, query: query); + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag $method $path HTTP:${response.code}"); + return ApiResponse(value: parse(response.body)); + } else { + Logging.instance.w( + "$_kTag $method $path HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e("$_kTag _requestRaw($method $path) threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _requestRaw($method $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } +} diff --git a/lib/services/cakepay/src/endpoints.dart b/lib/services/cakepay/src/endpoints.dart new file mode 100644 index 0000000000..340128c09a --- /dev/null +++ b/lib/services/cakepay/src/endpoints.dart @@ -0,0 +1,3 @@ +class Endpoints { + static const base = 'https://api-prod.cakepay.com/api'; +} diff --git a/lib/services/cakepay/src/models/card.dart b/lib/services/cakepay/src/models/card.dart new file mode 100644 index 0000000000..2fed2f47e0 --- /dev/null +++ b/lib/services/cakepay/src/models/card.dart @@ -0,0 +1,109 @@ +class CakePayCard { + final int id; + final String name; + final String? type; + final String? description; + final String? termsAndConditions; + final String? howToUse; + final String? expiryAndValidity; + 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 bool available; + final String? lastUpdated; + + CakePayCard({ + required this.id, + required this.name, + this.type, + this.description, + this.termsAndConditions, + this.howToUse, + this.expiryAndValidity, + this.cardImageUrl, + this.country, + this.currencyCode, + required this.denominations, + this.minValue, + this.maxValue, + this.minValueUsd, + this.maxValueUsd, + required this.available, + this.lastUpdated, + }); + + factory CakePayCard.fromJson(Map json) { + final rawDenoms = json['denominations'] ?? json['denominations_list']; + final 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); + } + } + } + } + + 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?, + 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?, + ); + } + + 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(', '); + } + if (isRangeDenomination) { + return '${minValue!.toStringAsFixed(0)} - ${maxValue!.toStringAsFixed(0)}'; + } + return ''; + } + + @override + String toString() => 'CakePayCard($id, $name)'; +} + +double? _toDouble(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); + return null; +} diff --git a/lib/services/cakepay/src/models/category.dart b/lib/services/cakepay/src/models/category.dart new file mode 100644 index 0000000000..d097d03197 --- /dev/null +++ b/lib/services/cakepay/src/models/category.dart @@ -0,0 +1,31 @@ +class CakePayCategory { + final int id; + final String name; + final String? emoji; + final String? slug; + final bool isActive; + final int sortOrder; + + CakePayCategory({ + required this.id, + required this.name, + this.emoji, + this.slug, + required this.isActive, + required this.sortOrder, + }); + + factory CakePayCategory.fromJson(Map json) { + return CakePayCategory( + id: json['id'] as int? ?? 0, + name: (json['name'] ?? '') as String, + emoji: json['emoji'] as String?, + slug: json['slug'] as String?, + isActive: json['is_active'] as bool? ?? true, + sortOrder: json['sort_order'] as int? ?? 0, + ); + } + + @override + String toString() => 'CakePayCategory($id, $name)'; +} diff --git a/lib/services/cakepay/src/models/country.dart b/lib/services/cakepay/src/models/country.dart new file mode 100644 index 0000000000..36a65960ae --- /dev/null +++ b/lib/services/cakepay/src/models/country.dart @@ -0,0 +1,28 @@ +class CakePayCountry { + final String name; + final String countryCode; + final String currencyCode; + final String? image; + final bool available; + + CakePayCountry({ + required this.name, + required this.countryCode, + required this.currencyCode, + this.image, + required this.available, + }); + + factory CakePayCountry.fromJson(Map json) { + return CakePayCountry( + name: (json['name'] ?? '') as String, + countryCode: (json['country_code'] ?? '') as String, + currencyCode: (json['currency_code'] ?? '') as String, + image: json['image'] as String?, + available: json['available'] as bool? ?? true, + ); + } + + @override + String toString() => 'CakePayCountry($countryCode, $name)'; +} diff --git a/lib/services/cakepay/src/models/order.dart b/lib/services/cakepay/src/models/order.dart new file mode 100644 index 0000000000..4e88c3f09b --- /dev/null +++ b/lib/services/cakepay/src/models/order.dart @@ -0,0 +1,182 @@ +import 'order_item.dart'; + +enum CakePayOrderStatus { + new_('new'), + expiredButStillPending('expired_but_still_pending'), + expired('expired'), + failed('failed'), + paid('paid'), + paidPartial('paid_partial'), + pendingPurchase('pending_purchase'), + purchaseProcessing('purchase_processing'), + purchased('purchased'), + pendingEmail('pending_email'), + complete('complete'), + pendingRefund('pending_refund'), + refunded('refunded'); + + final String value; + const CakePayOrderStatus(this.value); + + static CakePayOrderStatus fromString(String s) { + return CakePayOrderStatus.values.firstWhere( + (e) => e.value == s, + orElse: () => CakePayOrderStatus.new_, + ); + } +} + +/// A single crypto payment option within [CakePayOrder.paymentOptions]. +/// +/// The API returns `payment_data` as a map whose keys are crypto tickers +/// (e.g. `"BTC"`, `"XMR"`) each mapping to an object with `amount_from` +/// and `address`. +class CakePayPaymentOption { + final String ticker; + final double amountFrom; + final String address; + + CakePayPaymentOption({ + required this.ticker, + required this.amountFrom, + required this.address, + }); + + @override + String toString() => 'CakePayPaymentOption($ticker, $amountFrom, $address)'; +} + +class CakePayOrder { + final String orderId; + final CakePayOrderStatus status; + final String? amountUsd; + final List? cards; + + /// Raw `payment_data` map preserved for backward compatibility. + /// + /// Prefer [paymentOptions] for structured access to crypto payment + /// methods. + final Map? paymentData; + + /// Structured crypto payment options parsed from `payment_data`. + /// + /// Keys are crypto tickers (e.g. `"BTC"`, `"XMR"`, `"BTC_LN"`). + final Map? paymentOptions; + + /// Unix-millis timestamp when the payment window expires. + final int? expirationTime; + + /// Unix-millis timestamp when the invoice was created. + final int? invoiceTime; + + final String? commission; + final double? markupPercent; + final String? createdAt; + final String? externalOrderId; + + CakePayOrder({ + required this.orderId, + required this.status, + this.amountUsd, + this.cards, + this.paymentData, + this.paymentOptions, + this.expirationTime, + this.invoiceTime, + this.commission, + this.markupPercent, + this.createdAt, + this.externalOrderId, + }); + + factory CakePayOrder.fromJson(Map json) { + final rawCards = json['cards']; + List? cards; + if (rawCards is List) { + cards = rawCards + .whereType>() + .map(CakePayOrderItem.fromJson) + .toList(); + } + + // ---- payment_data parsing ---- + final rawPayment = json['payment_data']; + Map? paymentData; + Map? paymentOptions; + int? expirationTime; + int? invoiceTime; + + if (rawPayment is Map) { + paymentData = rawPayment; + + // Extract top-level timing fields. + expirationTime = rawPayment['expiration_time'] as int?; + invoiceTime = rawPayment['invoice_time'] as int?; + + // Each remaining key whose value is a Map is a crypto payment option. + paymentOptions = {}; + for (final entry in rawPayment.entries) { + final v = entry.value; + if (v is Map) { + final amountFrom = _toDouble(v['amount_from']); + final address = v['address']?.toString(); + if (amountFrom != null && address != null) { + paymentOptions[entry.key] = CakePayPaymentOption( + ticker: entry.key, + amountFrom: amountFrom, + address: address, + ); + } + } + } + if (paymentOptions.isEmpty) { + paymentOptions = null; + } + } + + return CakePayOrder( + orderId: (json['order_id'] ?? json['id'])?.toString() ?? '', + status: CakePayOrderStatus.fromString( + (json['status'] ?? 'new') as String, + ), + amountUsd: json['amount_usd']?.toString(), + cards: cards, + paymentData: paymentData, + paymentOptions: paymentOptions, + expirationTime: expirationTime, + invoiceTime: invoiceTime, + commission: json['commission']?.toString(), + markupPercent: _toDouble(json['markup_percent']), + createdAt: json['created_at'] as String?, + externalOrderId: json['external_order_id'] as String?, + ); + } + + CakePayOrder copyWith({CakePayOrderStatus? status}) { + return CakePayOrder( + orderId: orderId, + status: status ?? this.status, + amountUsd: amountUsd, + cards: cards, + paymentData: paymentData, + paymentOptions: paymentOptions, + expirationTime: expirationTime, + invoiceTime: invoiceTime, + commission: commission, + markupPercent: markupPercent, + createdAt: createdAt, + externalOrderId: externalOrderId, + ); + } + + @override + String toString() => 'CakePayOrder($orderId, ${status.value})'; +} + +double? _toDouble(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); + return null; +} diff --git a/lib/services/cakepay/src/models/order_item.dart b/lib/services/cakepay/src/models/order_item.dart new file mode 100644 index 0000000000..b15386e019 --- /dev/null +++ b/lib/services/cakepay/src/models/order_item.dart @@ -0,0 +1,59 @@ +class CakePayOrderItem { + final int? cardId; + final String? name; + + /// The price string as returned by the API. + /// + /// May be a bare number (`"20.00"`) or include the currency + /// (`"20.00 EUR"`). Use [priceValue] when you need only the numeric + /// portion and [currencyCode] for the currency. + final String? price; + + /// The numeric portion of [price] (e.g. `"20.00"`). + final String? priceValue; + + /// Price expressed in USD, as returned by the API (e.g. `"$24.12"`). + final String? priceUsd; + + final int? quantity; + final String? currencyCode; + final String? cardImageUrl; + + CakePayOrderItem({ + this.cardId, + this.name, + this.price, + this.priceValue, + this.priceUsd, + this.quantity, + this.currencyCode, + this.cardImageUrl, + }); + + factory CakePayOrderItem.fromJson(Map json) { + final rawPrice = json['price']?.toString(); + + // The API may return price as "20.00 EUR" (with currency) or just + // "20.00". Extract the leading numeric portion so the UI can display + // it without duplicating the currency code. + String? priceValue; + if (rawPrice != null) { + final match = RegExp(r'^[\d.]+').firstMatch(rawPrice); + priceValue = match?.group(0) ?? rawPrice; + } + + return CakePayOrderItem( + cardId: json['card_id'] as int?, + name: json['name'] as String?, + price: rawPrice, + priceValue: priceValue, + priceUsd: json['price_usd']?.toString(), + quantity: json['quantity'] as int?, + currencyCode: json['currency_code'] as String?, + cardImageUrl: json['card_image_url'] as String?, + ); + } + + @override + String toString() => 'CakePayOrderItem($cardId, $name)'; +} diff --git a/lib/services/cakepay/src/models/vendor.dart b/lib/services/cakepay/src/models/vendor.dart new file mode 100644 index 0000000000..33f80035ae --- /dev/null +++ b/lib/services/cakepay/src/models/vendor.dart @@ -0,0 +1,43 @@ +import 'card.dart'; + +class CakePayVendor { + final int id; + final String name; + final bool available; + final String? cakeWarnings; + final String? country; + final List cards; + + CakePayVendor({ + required this.id, + required this.name, + required this.available, + this.cakeWarnings, + this.country, + required this.cards, + }); + + factory CakePayVendor.fromJson(Map json) { + final rawCards = json['cards']; + final cards = []; + if (rawCards is List) { + for (final c in rawCards) { + if (c is Map) { + cards.add(CakePayCard.fromJson(c)); + } + } + } + + return CakePayVendor( + id: json['id'] as int? ?? 0, + name: (json['name'] ?? '') as String, + available: json['available'] as bool? ?? true, + cakeWarnings: json['cake_warnings'] as String?, + country: json['country'] as String?, + cards: cards, + ); + } + + @override + String toString() => 'CakePayVendor($id, $name)'; +} diff --git a/lib/services/ord_api.dart b/lib/services/ord_api.dart new file mode 100644 index 0000000000..79800860fd --- /dev/null +++ b/lib/services/ord_api.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../app_config.dart'; +import '../networking/http.dart'; +import '../utilities/prefs.dart'; +import 'tor_service.dart'; + +class OrdAPI { + final String baseUrl; + final HTTP _client = const HTTP(); + + OrdAPI({required this.baseUrl}); + + static const _jsonHeaders = {'Accept': 'application/json'}; + + ({InternetAddress host, int port})? get _proxyInfo => + !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + /// Check an output for inscriptions. + /// Returns the list of inscription IDs found on the output, or empty list. + Future> getInscriptionIdsForOutput(String txid, int vout) async { + final response = await _client.get( + url: Uri.parse('$baseUrl/output/$txid:$vout'), + headers: _jsonHeaders, + proxyInfo: _proxyInfo, + ); + + if (response.code != 200) { + throw Exception( + 'OrdAPI getInscriptionIdsForOutput failed: ' + 'status=${response.code}', + ); + } + + final json = jsonDecode(response.body) as Map; + final inscriptions = json['inscriptions'] as List?; + + if (inscriptions == null || inscriptions.isEmpty) { + return []; + } + + return inscriptions.cast(); + } + + /// Fetch full inscription metadata by ID. + Future> getInscriptionData(String inscriptionId) async { + final response = await _client.get( + url: Uri.parse('$baseUrl/inscription/$inscriptionId'), + headers: _jsonHeaders, + proxyInfo: _proxyInfo, + ); + + if (response.code != 200) { + throw Exception( + 'OrdAPI getInscriptionData failed: ' + 'status=${response.code}', + ); + } + + return jsonDecode(response.body) as Map; + } + + /// Build the content URL for an inscription. + String contentUrl(String inscriptionId) => '$baseUrl/content/$inscriptionId'; +} diff --git a/lib/services/shopinbit/shopinbit_api.dart b/lib/services/shopinbit/shopinbit_api.dart new file mode 100644 index 0000000000..fd1f12c47c --- /dev/null +++ b/lib/services/shopinbit/shopinbit_api.dart @@ -0,0 +1,7 @@ +export 'src/client.dart'; +export 'src/token_manager.dart'; +export 'src/api_response.dart'; +export 'src/api_exception.dart'; +export 'src/webhook_verifier.dart'; +export 'src/endpoints.dart'; +export 'src/models/models.dart'; diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart new file mode 100644 index 0000000000..1caa8aa4ae --- /dev/null +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -0,0 +1,161 @@ +import '../../db/hive/db.dart'; +import '../../external_api_keys.dart'; +import '../../utilities/logger.dart'; +import 'src/client.dart'; + +class ShopInBitService { + static final instance = ShopInBitService._(); + ShopInBitService._(); + + ShopInBitClient? _client; + String? _customerKey; + bool? _guidelinesAccepted; + bool? _setupComplete; + String? _displayName; + + ShopInBitClient get client { + if (_client == null) { + _client = ShopInBitClient( + accessKey: kShopInBitAccessKey, + partnerSecret: kShopInBitPartnerSecret, + sandbox: true, + ); + // Pre-load customer key for ticket detail API calls. + loadCustomerKey(); + } + return _client!; + } + + String? get customerKey => _customerKey; + + 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; + } + + Future ensureCustomerKey() async { + if (_customerKey != null) return _customerKey!; + _customerKey = + DB.instance.get( + boxName: DB.boxNamePrefs, + key: "shopInBitCustomerKey", + ) + as String?; + if (_customerKey != null) { + Logging.instance.t("ShopInBitService: loaded customer key from DB"); + client.externalCustomerKey = _customerKey; + return _customerKey!; + } + 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, + ); + Logging.instance.i("ShopInBitService: customer key stored"); + return _customerKey!; + } + + Future setCustomerKey(String key) async { + _customerKey = key; + client.externalCustomerKey = key; + await DB.instance.put( + boxName: DB.boxNamePrefs, + key: "shopInBitCustomerKey", + value: key, + ); + Logging.instance.i("ShopInBitService: customer key manually set"); + } + + Future clearCustomerKey() async { + _customerKey = null; + client.externalCustomerKey = null; + await DB.instance.put( + boxName: DB.boxNamePrefs, + key: "shopInBitCustomerKey", + value: null, + ); + 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/api_exception.dart b/lib/services/shopinbit/src/api_exception.dart new file mode 100644 index 0000000000..6e35192572 --- /dev/null +++ b/lib/services/shopinbit/src/api_exception.dart @@ -0,0 +1,24 @@ +class ApiException implements Exception { + final String message; + final int? statusCode; + final String? responseBody; + + ApiException(this.message, {this.statusCode, this.responseBody}); + + factory ApiException.fromResponse(int statusCode, String body) { + return ApiException( + 'HTTP $statusCode', + statusCode: statusCode, + responseBody: body, + ); + } + + factory ApiException.network(Object error) { + return ApiException('Network error: $error'); + } + + @override + String toString() => + 'ApiException: $message' + '${statusCode != null ? ' (status: $statusCode)' : ''}'; +} diff --git a/lib/services/shopinbit/src/api_response.dart b/lib/services/shopinbit/src/api_response.dart new file mode 100644 index 0000000000..a1e9135063 --- /dev/null +++ b/lib/services/shopinbit/src/api_response.dart @@ -0,0 +1,18 @@ +import 'api_exception.dart'; + +class ApiResponse { + final T? value; + final ApiException? exception; + + ApiResponse({this.value, this.exception}); + + bool get hasError => exception != null; + + T get valueOrThrow { + if (exception != null) throw exception!; + return value as T; + } + + @override + String toString() => '{error: $exception, value: $value}'; +} diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart new file mode 100644 index 0000000000..fe48184129 --- /dev/null +++ b/lib/services/shopinbit/src/client.dart @@ -0,0 +1,659 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../../app_config.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import 'api_exception.dart'; +import 'api_response.dart'; +import 'endpoints.dart'; +import 'token_manager.dart'; +import 'models/address.dart'; +import 'models/car_research.dart'; +import 'models/message.dart'; +import 'models/payment.dart'; +import 'models/ticket.dart'; +import 'models/voucher.dart'; + +const _kTag = "ShopInBitClient"; + +class ShopInBitClient { + final String accessKey; + final String partnerSecret; + final String baseUrl; + final bool sandbox; + final HTTP _httpClient; + final TokenManager _tokenManager; + + String? _externalCustomerKey; + + String? get externalCustomerKey => _externalCustomerKey; + set externalCustomerKey(String? key) => _externalCustomerKey = key; + + ShopInBitClient({ + required this.accessKey, + required this.partnerSecret, + this.baseUrl = Endpoints.production, + this.sandbox = false, + String? externalCustomerKey, + HTTP? httpClient, + }) : _externalCustomerKey = externalCustomerKey, + _httpClient = httpClient ?? const HTTP(), + _tokenManager = TokenManager( + accessKey: accessKey, + partnerSecret: partnerSecret, + baseUrl: baseUrl, + httpClient: httpClient, + ); + + // -- Auth -- + + Future> authenticate() async { + try { + await _tokenManager.getValidToken(); + return ApiResponse(); + } on ApiException catch (e) { + return ApiResponse(exception: e); + } catch (e) { + return ApiResponse(exception: ApiException('Authentication failed: $e')); + } + } + + // -- Utility -- + + Future> generateKey() async { + return _request( + 'GET', + '/generate-key', + needsCustomerKey: false, + parse: (json) { + return json['external_customer_key'] as String; + }, + ); + } + + Future>> getHealth() async { + return _request( + 'GET', + '/health', + needsCustomerKey: false, + parse: (json) => json, + ); + } + + Future>>> getCountries() async { + return _requestRaw( + 'GET', + '/meta/countries', + needsCustomerKey: false, + needsAuth: false, + parse: (body) { + final decoded = jsonDecode(body); + if (decoded is List) { + return decoded.cast>(); + } + return [decoded as Map]; + }, + ); + } + + // -- Tickets -- + + Future> createRequest({ + required String customerPseudonym, + required String externalCustomerKey, + required String serviceType, + required String comment, + required String deliveryCountry, + String? voucherCode, + }) async { + return _request( + 'POST', + '/requests', + body: { + 'customer_pseudonym': customerPseudonym, + 'external_customer_key': externalCustomerKey, + 'service_type': serviceType, + 'comment': comment, + 'delivery_country': deliveryCountry, + if (voucherCode != null) 'voucher_code': voucherCode, + }, + parse: (json) { + return TicketRef( + id: json['ticket_id'] is int + ? json['ticket_id'] as int + : int.parse(json['ticket_id'].toString()), + number: json['ticket_number'].toString(), + ); + }, + ); + } + + Future> getTicketStatus(int ticketId) async { + return _request( + 'GET', + '/tickets/$ticketId/status', + parse: TicketStatus.fromJson, + ); + } + + Future> getTicketFull(int ticketId) async { + return _request( + 'GET', + '/tickets/$ticketId/full', + parse: TicketFull.fromJson, + ); + } + + Future>> getTicketsByCustomer( + String customerKey, + ) async { + return _request( + 'GET', + '/tickets/by-customer/$customerKey', + parse: (json) { + final list = json['tickets'] as List; + return list + .map((e) => TicketRef.fromJson(e as Map)) + .toList(); + }, + ); + } + + // -- Messages -- + + Future>> sendMessage( + int ticketId, + String message, + ) async { + return _request( + 'POST', + '/tickets/$ticketId/messages', + body: {'message': message}, + parse: (json) => json, + ); + } + + Future>> getMessages(int ticketId) async { + return _request( + 'GET', + '/tickets/$ticketId/messages', + parse: (json) { + final list = json['messages'] as List; + return list + .map((e) => TicketMessage.fromJson(e as Map)) + .toList(); + }, + ); + } + + // -- Attachments -- + + Future>> sendAttachments( + int ticketId, { + required String message, + required List> attachments, + }) async { + return _request( + 'POST', + '/tickets/$ticketId/attachments', + body: {'message': message, 'attachments': attachments}, + parse: (json) => json, + ); + } + + /// Build a URL for fetching an attachment via `/attachment-proxy/`. + /// + /// For use in HTTP clients that can set headers, use the returned URL with + /// the standard Authorization + External-Customer-Key headers. + /// For inline images (e.g. in HTML where headers can't be set), pass + /// [useQueryAuth] = true to append token and customer_key as query params. + Future> getAttachmentUrl( + String attachmentPath, { + bool useQueryAuth = false, + }) async { + try { + final token = await _tokenManager.getValidToken(); + final resolved = _resolvePath('/attachment-proxy/$attachmentPath'); + var uri = Uri.parse('$baseUrl$resolved'); + if (useQueryAuth) { + uri = uri.replace( + queryParameters: { + 'token': token, + if (_externalCustomerKey != null) + 'customer_key': _externalCustomerKey!, + }, + ); + } + return ApiResponse(value: uri); + } on ApiException catch (e) { + return ApiResponse(exception: e); + } catch (e) { + return ApiResponse(exception: ApiException.network(e)); + } + } + + /// Download an attachment from `/attachment-proxy/`. + Future> getAttachment(String attachmentPath) async { + try { + final token = await _tokenManager.getValidToken(); + final resolved = _resolvePath('/attachment-proxy/$attachmentPath'); + final uri = Uri.parse('$baseUrl$resolved'); + Logging.instance.t("$_kTag GET $uri"); + final headers = _headers(token); + final response = await _httpClient.get( + url: uri, + headers: headers, + proxyInfo: _proxyInfo, + ); + if (response.code >= 200 && response.code < 300) { + return ApiResponse(value: response); + } else { + Logging.instance.w( + "$_kTag GET $resolved HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e( + "$_kTag getAttachment($attachmentPath) threw: ", + error: e, + ); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag getAttachment($attachmentPath) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + // -- Address -- + + Future>> submitAddress( + int ticketId, { + required Address shipping, + Address? billing, + }) async { + return _request( + 'POST', + '/tickets/$ticketId/address', + body: {'shipping': shipping.toJson(), 'billing': billing?.toJson()}, + parse: (json) => json, + ); + } + + // -- Payment -- + + Future> getPayment( + int ticketId, { + bool retry = false, + }) async { + final path = '/tickets/$ticketId/payment'; + final query = retry ? {'retry': 'true'} : null; + return _request('GET', path, query: query, parse: PaymentInfo.fromJson); + } + + // -- Vouchers -- + + /// Pre-check a voucher code (does not consume usage or create a ticket). + Future> checkVoucher(String code) async { + return _request( + 'GET', + '/vouchers/validate', + query: {'code': code}, + parse: VoucherInfo.fromJson, + ); + } + + /// Redeem a VIP voucher (creates ticket in one call). VIP/VIP_PRIORITY only. + Future> redeemVipVoucher({ + required String voucherCode, + required String customerPseudonym, + required String serviceType, + required String comment, + String? deliveryCountry, + }) async { + return _request( + 'POST', + '/vouchers/validate', + body: { + 'voucher_code': voucherCode, + 'customer_pseudonym': customerPseudonym, + 'service_type': serviceType, + 'comment': comment, + if (deliveryCountry != null) 'delivery_country': deliveryCountry, + }, + parse: VipRedemptionResult.fromJson, + ); + } + + // -- Car Research Fee -- + + Future> createCarResearchInvoice({ + required Address billing, + }) async { + return _request( + 'POST', + '/car-research/invoice', + body: { + 'billing': billing.toJson(), + if (_externalCustomerKey != null) + 'external_customer_key': _externalCustomerKey, + }, + parse: CarResearchInvoice.fromJson, + ); + } + + Future>> getCarResearchInvoiceStatus( + String invoiceId, + ) async { + return _request( + 'GET', + '/car-research/invoice/$invoiceId/status', + parse: (json) => json, + ); + } + + Future> logCarResearchPayment( + String invoiceId, + ) async { + return _request( + 'POST', + '/car-research/log-payment', + body: { + 'invoice_id': invoiceId, + if (_externalCustomerKey != null) + 'external_customer_key': _externalCustomerKey, + }, + parse: CarResearchPaymentResult.fromJson, + ); + } + + // -- Push Notifications -- + + Future>> registerPushSubscription({ + String? deviceToken, + String? endpoint, + Map? keys, + String? platform, + String? environment, + String? expirationTime, + int? ticketId, + }) async { + return _request( + 'POST', + '/notifications/push-subscriptions', + body: { + if (deviceToken != null) 'deviceToken': deviceToken, + if (endpoint != null) 'endpoint': endpoint, + if (keys != null) 'keys': keys, + if (platform != null) 'platform': platform, + if (environment != null) 'environment': environment, + if (expirationTime != null) 'expirationTime': expirationTime, + if (ticketId != null) 'ticketId': ticketId, + }, + parse: (json) => json, + ); + } + + // -- Webhooks -- + + Future>>> listWebhooks() async { + return _request( + 'GET', + '/partners/webhooks', + needsCustomerKey: false, + parse: (json) { + if (json.containsKey('webhooks')) { + return (json['webhooks'] as List) + .cast>(); + } + return [json]; + }, + ); + } + + Future>> createWebhook({ + required String webhookUrl, + required List eventTypes, + }) async { + return _request( + 'POST', + '/partners/webhooks', + needsCustomerKey: false, + body: {'webhook_url': webhookUrl, 'event_types': eventTypes}, + parse: (json) => json, + ); + } + + Future>> rotateWebhookSecret( + String webhookId, + ) async { + return _request( + 'POST', + '/partners/webhooks/$webhookId/rotate', + needsCustomerKey: false, + parse: (json) => json, + ); + } + + Future> deleteWebhook(String webhookId) async { + return _request( + 'DELETE', + '/partners/webhooks/$webhookId', + needsCustomerKey: false, + parse: (_) => null, + ); + } + + // -- Sandbox -- + + Future>> sandboxSetState( + int ticketId, + String state, + ) async { + return _request( + 'POST', + '/sandbox/state/$ticketId/$state', + parse: (json) => json, + ); + } + + Future>> sandboxSetPayment( + int ticketId, + String status, + ) async { + return _request( + 'POST', + '/sandbox/payment/$ticketId/$status', + parse: (json) => json, + ); + } + + // -- Internals -- + + ({InternetAddress host, int port})? get _proxyInfo => + !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null; + + /// Prepend /sandbox to paths when in sandbox mode, except for paths that + /// already start with /sandbox, /meta, /health, or /token. + String _resolvePath(String path) { + if (!sandbox) return path; + if (path.startsWith('/sandbox') || + path.startsWith('/meta') || + path.startsWith('/health') || + path.startsWith('/token') || + path.startsWith('/partners')) { + return path; + } + return '/sandbox$path'; + } + + Map _headers(String token, {bool needsCustomerKey = true}) { + final h = { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + if (needsCustomerKey && _externalCustomerKey != null) { + h['External-Customer-Key'] = _externalCustomerKey!; + } + return h; + } + + Future _send( + String method, + String path, { + Map? body, + Map? query, + bool needsCustomerKey = true, + bool needsAuth = true, + }) async { + final resolved = _resolvePath(path); + var uri = Uri.parse('$baseUrl$resolved'); + if (query != null && query.isNotEmpty) { + uri = uri.replace(queryParameters: query); + } + final Map headers; + if (needsAuth) { + final token = await _tokenManager.getValidToken(); + headers = _headers(token, needsCustomerKey: needsCustomerKey); + } else { + headers = {'Accept': 'application/json'}; + } + final proxy = _proxyInfo; + + Logging.instance.t("$_kTag $method $uri"); + + switch (method) { + case 'GET': + return _httpClient.get(url: uri, headers: headers, proxyInfo: proxy); + case 'POST': + return _httpClient.post( + url: uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + proxyInfo: proxy, + ); + case 'PATCH': + return _httpClient.patch( + url: uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + proxyInfo: proxy, + ); + case 'DELETE': + return _httpClient.delete(url: uri, headers: headers, proxyInfo: proxy); + default: + throw ApiException('Unsupported method: $method'); + } + } + + Future> _request( + String method, + String path, { + Map? body, + Map? query, + bool needsCustomerKey = true, + required T Function(Map) parse, + }) async { + try { + final response = await _send( + method, + path, + body: body, + query: query, + needsCustomerKey: needsCustomerKey, + ); + + final resolved = _resolvePath(path); + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag $method $resolved HTTP:${response.code}"); + if (response.body.isEmpty) { + return ApiResponse(value: parse({})); + } + final json = jsonDecode(response.body) as Map; + return ApiResponse(value: parse(json)); + } else { + Logging.instance.w( + "$_kTag $method $resolved HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e("$_kTag _request($method $path) threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _request($method $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } + + /// Like [_request] but gives the parse function the raw response body + /// string, for endpoints that return non-object JSON (e.g. arrays). + Future> _requestRaw( + String method, + String path, { + Map? body, + Map? query, + bool needsCustomerKey = true, + bool needsAuth = true, + required T Function(String) parse, + }) async { + try { + final response = await _send( + method, + path, + body: body, + query: query, + needsCustomerKey: needsCustomerKey, + needsAuth: needsAuth, + ); + + final resolved = _resolvePath(path); + + if (response.code >= 200 && response.code < 300) { + Logging.instance.t("$_kTag $method $resolved HTTP:${response.code}"); + return ApiResponse(value: parse(response.body)); + } else { + Logging.instance.w( + "$_kTag $method $resolved HTTP:${response.code} " + "body: ${response.body}", + ); + return ApiResponse( + exception: ApiException.fromResponse(response.code, response.body), + ); + } + } on ApiException catch (e) { + Logging.instance.e("$_kTag _requestRaw($method $path) threw: ", error: e); + return ApiResponse(exception: e); + } catch (e, s) { + Logging.instance.e( + "$_kTag _requestRaw($method $path) threw: ", + error: e, + stackTrace: s, + ); + return ApiResponse(exception: ApiException.network(e)); + } + } +} diff --git a/lib/services/shopinbit/src/endpoints.dart b/lib/services/shopinbit/src/endpoints.dart new file mode 100644 index 0000000000..00a18f669b --- /dev/null +++ b/lib/services/shopinbit/src/endpoints.dart @@ -0,0 +1,3 @@ +class Endpoints { + static const production = 'https://api.shopinbit.com'; +} diff --git a/lib/services/shopinbit/src/models/address.dart b/lib/services/shopinbit/src/models/address.dart new file mode 100644 index 0000000000..5371a37d06 --- /dev/null +++ b/lib/services/shopinbit/src/models/address.dart @@ -0,0 +1,49 @@ +class Address { + final String? company; + final String? vat; + final String firstName; + final String lastName; + final String street; + final String zip; + final String city; + final String country; + final String? state; + + Address({ + this.company, + this.vat, + required this.firstName, + required this.lastName, + required this.street, + required this.zip, + required this.city, + required this.country, + this.state, + }); + + Map toJson() => { + 'company': company, + 'vat': vat, + 'firstName': firstName, + 'lastName': lastName, + 'street': street, + 'zip': zip, + 'city': city, + 'country': country, + 'state': state, + }; + + factory Address.fromJson(Map json) { + return Address( + company: json['company'] as String?, + vat: json['vat'] as String?, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + street: json['street'] as String, + zip: json['zip'] as String, + city: json['city'] as String, + country: json['country'] as String, + state: json['state'] as String?, + ); + } +} diff --git a/lib/services/shopinbit/src/models/auth_token.dart b/lib/services/shopinbit/src/models/auth_token.dart new file mode 100644 index 0000000000..af7816aadd --- /dev/null +++ b/lib/services/shopinbit/src/models/auth_token.dart @@ -0,0 +1,25 @@ +class AuthToken { + final String accessToken; + final String tokenType; + final DateTime expiresAt; + + AuthToken({ + required this.accessToken, + required this.tokenType, + required this.expiresAt, + }); + + factory AuthToken.fromJson(Map json) { + return AuthToken( + accessToken: json['access_token'] as String, + tokenType: json['token_type'] as String, + // Tokens valid for 10 minutes per API docs. + expiresAt: DateTime.now().add(const Duration(minutes: 10)), + ); + } + + bool get isExpired => DateTime.now().isAfter(expiresAt); + + bool get expiresSoon => + DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 1))); +} diff --git a/lib/services/shopinbit/src/models/car_research.dart b/lib/services/shopinbit/src/models/car_research.dart new file mode 100644 index 0000000000..ea1eceb0d2 --- /dev/null +++ b/lib/services/shopinbit/src/models/car_research.dart @@ -0,0 +1,43 @@ +class CarResearchInvoice { + final String btcpayInvoice; + final DateTime expiresAt; + final Map paymentLinks; + + CarResearchInvoice({ + required this.btcpayInvoice, + required this.expiresAt, + required this.paymentLinks, + }); + + factory CarResearchInvoice.fromJson(Map json) { + final linksRaw = json['payment_links'] as Map? ?? {}; + return CarResearchInvoice( + btcpayInvoice: json['btcpay_invoice'] as String, + expiresAt: DateTime.parse(json['expires_at'] as String), + paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), + ); + } +} + +class CarResearchPaymentResult { + final String status; + final int ticketId; + final String ticketNumber; + final String externalCustomerKey; + + CarResearchPaymentResult({ + required this.status, + required this.ticketId, + required this.ticketNumber, + required this.externalCustomerKey, + }); + + factory CarResearchPaymentResult.fromJson(Map json) { + return CarResearchPaymentResult( + status: json['status'] as String, + ticketId: json['ticket_id'] as int, + ticketNumber: json['ticket_number'] as String, + externalCustomerKey: json['external_customer_key'] as String, + ); + } +} diff --git a/lib/services/shopinbit/src/models/message.dart b/lib/services/shopinbit/src/models/message.dart new file mode 100644 index 0000000000..2048b72c27 --- /dev/null +++ b/lib/services/shopinbit/src/models/message.dart @@ -0,0 +1,19 @@ +class TicketMessage { + final DateTime timestamp; + final bool fromAgent; + final String content; + + TicketMessage({ + required this.timestamp, + required this.fromAgent, + required this.content, + }); + + factory TicketMessage.fromJson(Map json) { + return TicketMessage( + timestamp: DateTime.parse(json['timestamp'] as String), + fromAgent: json['from_agent'] as bool, + content: json['content'] as String, + ); + } +} diff --git a/lib/services/shopinbit/src/models/models.dart b/lib/services/shopinbit/src/models/models.dart new file mode 100644 index 0000000000..7d4208c2fc --- /dev/null +++ b/lib/services/shopinbit/src/models/models.dart @@ -0,0 +1,8 @@ +export 'auth_token.dart'; +export 'ticket.dart'; +export 'message.dart'; +export 'address.dart'; +export 'payment.dart'; +export 'car_research.dart'; +export 'voucher.dart'; +export 'webhook_event.dart'; diff --git a/lib/services/shopinbit/src/models/payment.dart b/lib/services/shopinbit/src/models/payment.dart new file mode 100644 index 0000000000..bd0938da29 --- /dev/null +++ b/lib/services/shopinbit/src/models/payment.dart @@ -0,0 +1,44 @@ +class PaymentInfo { + final String status; + final String customerPrice; + final String partnerPrice; + final int vatRate; + final String currency; + final DateTime? rateLockedUntil; + final Map paymentLinks; + final String? due; + + PaymentInfo({ + required this.status, + required this.customerPrice, + required this.partnerPrice, + required this.vatRate, + required this.currency, + this.rateLockedUntil, + required this.paymentLinks, + this.due, + }); + + factory PaymentInfo.fromJson(Map json) { + final linksRaw = json['payment_links'] as Map? ?? {}; + return PaymentInfo( + status: json['status'] as String, + customerPrice: (json['customer_price'] ?? '') as String, + partnerPrice: (json['partner_price'] ?? '') as String, + vatRate: _toInt(json['vat_rate']), + currency: (json['currency'] ?? 'EUR') as String, + rateLockedUntil: json['rate_locked_until'] != null + ? DateTime.parse(json['rate_locked_until'] as String) + : null, + paymentLinks: linksRaw.map((k, v) => MapEntry(k, v as String)), + due: json['due'] as String?, + ); + } +} + +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; +} diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart new file mode 100644 index 0000000000..eec6dd3604 --- /dev/null +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -0,0 +1,112 @@ +enum TicketState { + newTicket('NEW'), + checking('CHECKING'), + inProgress('IN PROGRESS'), + offerAvailable('OFFER AVAILABLE'), + clearing('CLEARING'), + shipped('SHIPPED'), + refunded('REFUNDED'), + fulfilled('FULFILLED'), + pendingClose('PENDING CLOSE'), + replyNeeded('REPLY NEEDED'), + closed('CLOSED'), + closedCancelled('CLOSED/CANCELLED'), + merged('MERGED'); + + final String value; + const TicketState(this.value); + + static TicketState fromString(String s) { + return TicketState.values.firstWhere( + (e) => e.value == s, + orElse: () => TicketState.newTicket, + ); + } +} + +class TicketRef { + final int id; + final String number; + + TicketRef({required this.id, required this.number}); + + factory TicketRef.fromJson(Map json) { + return TicketRef(id: _toInt(json['id']), number: json['number'].toString()); + } +} + +class TicketStatus { + final int ticketId; + final TicketState state; + final DateTime updatedAt; + final DateTime? lastAgentMessageAt; + final String? paymentInvoiceStatus; + final String? trackingLink; + + TicketStatus({ + required this.ticketId, + required this.state, + required this.updatedAt, + this.lastAgentMessageAt, + this.paymentInvoiceStatus, + this.trackingLink, + }); + + factory TicketStatus.fromJson(Map json) { + return TicketStatus( + ticketId: _toInt(json['ticket_id']), + state: TicketState.fromString(json['state'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + lastAgentMessageAt: json['last_agent_message_at'] != null + ? DateTime.parse(json['last_agent_message_at'] as String) + : null, + paymentInvoiceStatus: json['payment_invoice_status'] as String?, + trackingLink: json['tracking_link'] as String?, + ); + } +} + +class TicketFull { + final int id; + final String number; + final String productName; + final String customerPrice; + final String partnerPrice; + final String partnerCommission; + final String netPurchasePrice; + final String netShippingCosts; + final int vatRate; + + TicketFull({ + required this.id, + required this.number, + required this.productName, + required this.customerPrice, + required this.partnerPrice, + required this.partnerCommission, + required this.netPurchasePrice, + required this.netShippingCosts, + required this.vatRate, + }); + + factory TicketFull.fromJson(Map json) { + return TicketFull( + id: _toInt(json['id']), + number: json['number'].toString(), + productName: (json['product_name'] ?? '').toString(), + customerPrice: (json['customer_price'] ?? '').toString(), + partnerPrice: (json['partner_price'] ?? '').toString(), + partnerCommission: (json['partner_commission'] ?? '').toString(), + netPurchasePrice: (json['net_purchase_price'] ?? '').toString(), + netShippingCosts: (json['net_shipping_costs'] ?? '').toString(), + vatRate: _toInt(json['vat_rate']), + ); + } +} + +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; +} diff --git a/lib/services/shopinbit/src/models/voucher.dart b/lib/services/shopinbit/src/models/voucher.dart new file mode 100644 index 0000000000..97d048a8b4 --- /dev/null +++ b/lib/services/shopinbit/src/models/voucher.dart @@ -0,0 +1,71 @@ +class VoucherInfo { + final bool valid; + final String? voucherCode; + final double? discountAmount; + final String? voucherType; + final int? priorityLevel; + final int? usageCount; + final int? maxUsage; + final bool? isUnlimited; + final int? remainingUses; + final String? validFrom; + final String? validUntil; + final String? error; + + VoucherInfo({ + required this.valid, + this.voucherCode, + this.discountAmount, + this.voucherType, + this.priorityLevel, + this.usageCount, + this.maxUsage, + this.isUnlimited, + this.remainingUses, + this.validFrom, + this.validUntil, + this.error, + }); + + factory VoucherInfo.fromJson(Map json) { + return VoucherInfo( + valid: json['valid'] as bool? ?? false, + voucherCode: json['voucher_code'] as String?, + discountAmount: (json['discount_amount'] as num?)?.toDouble(), + voucherType: json['voucher_type'] as String?, + priorityLevel: json['priority_level'] as int?, + usageCount: json['usage_count'] as int?, + maxUsage: json['max_usage'] as int?, + isUnlimited: json['is_unlimited'] as bool?, + remainingUses: json['remaining_uses'] as int?, + validFrom: json['valid_from'] as String?, + validUntil: json['valid_until'] as String?, + error: json['error'] as String?, + ); + } +} + +class VipRedemptionResult { + final int ticketId; + final String ticketNumber; + final String externalCustomerKey; + final String voucherCode; + + VipRedemptionResult({ + required this.ticketId, + required this.ticketNumber, + required this.externalCustomerKey, + required this.voucherCode, + }); + + factory VipRedemptionResult.fromJson(Map json) { + return VipRedemptionResult( + ticketId: json['ticket_id'] is int + ? json['ticket_id'] as int + : int.parse(json['ticket_id'].toString()), + ticketNumber: json['ticket_number'] as String, + externalCustomerKey: json['external_customer_key'] as String, + voucherCode: json['voucher_code'] as String, + ); + } +} diff --git a/lib/services/shopinbit/src/models/webhook_event.dart b/lib/services/shopinbit/src/models/webhook_event.dart new file mode 100644 index 0000000000..7bf41694e8 --- /dev/null +++ b/lib/services/shopinbit/src/models/webhook_event.dart @@ -0,0 +1,28 @@ +enum WebhookEventType { + ticketStateChanged('ticket.state_changed'), + ticketMessageCreated('ticket.message_created'); + + final String value; + const WebhookEventType(this.value); + + static WebhookEventType fromString(String s) { + return WebhookEventType.values.firstWhere( + (e) => e.value == s, + orElse: () => WebhookEventType.ticketStateChanged, + ); + } +} + +class WebhookEvent { + final WebhookEventType eventType; + final Map data; + + WebhookEvent({required this.eventType, required this.data}); + + factory WebhookEvent.fromJson(Map json) { + return WebhookEvent( + eventType: WebhookEventType.fromString(json['event_type'] as String), + data: json['data'] as Map, + ); + } +} diff --git a/lib/services/shopinbit/src/token_manager.dart b/lib/services/shopinbit/src/token_manager.dart new file mode 100644 index 0000000000..a72d8135a0 --- /dev/null +++ b/lib/services/shopinbit/src/token_manager.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../../../app_config.dart'; +import '../../../networking/http.dart'; +import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../tor_service.dart'; +import 'api_exception.dart'; +import 'models/auth_token.dart'; + +class TokenManager { + final String accessKey; + final String partnerSecret; + final String baseUrl; + final HTTP _httpClient; + + AuthToken? _token; + Completer? _refreshCompleter; + + TokenManager({ + required this.accessKey, + required this.partnerSecret, + required this.baseUrl, + HTTP? httpClient, + }) : _httpClient = httpClient ?? const HTTP(); + + Future getValidToken() { + if (_token != null && !_token!.expiresSoon) { + return Future.value(_token!.accessToken); + } + + if (_refreshCompleter != null) { + return _refreshCompleter!.future; + } + + final completer = Completer(); + _refreshCompleter = completer; + + _authenticate() + .then((token) { + _token = token; + completer.complete(token.accessToken); + }) + .catchError((Object e) { + completer.completeError(e); + }) + .whenComplete(() { + _refreshCompleter = null; + }); + + return completer.future; + } + + Future _authenticate() async { + final uri = Uri.parse('$baseUrl/token'); + Logging.instance.t("ShopInBitClient POST $uri (authenticate)"); + + final Response response; + try { + final formBody = Uri( + queryParameters: {'username': accessKey, 'password': partnerSecret}, + ).query; + response = await _httpClient.post( + url: uri, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: formBody, + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + } catch (e, s) { + Logging.instance.e( + "ShopInBitClient authenticate() network error: ", + error: e, + stackTrace: s, + ); + throw ApiException.network(e); + } + + if (response.code != 200) { + Logging.instance.w( + "ShopInBitClient authenticate() HTTP:${response.code} " + "body: ${response.body}", + ); + throw ApiException.fromResponse(response.code, response.body); + } + + Logging.instance.t("ShopInBitClient authenticate() success"); + final json = jsonDecode(response.body) as Map; + return AuthToken.fromJson(json); + } + + void invalidate() { + _token = null; + } +} diff --git a/lib/services/shopinbit/src/webhook_verifier.dart b/lib/services/shopinbit/src/webhook_verifier.dart new file mode 100644 index 0000000000..a596a3f398 --- /dev/null +++ b/lib/services/shopinbit/src/webhook_verifier.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +class WebhookVerifier { + /// Verify a webhook delivery from ShopInBit. + /// + /// [body] is the raw request body. + /// [signatureHeader] is the `X-Concierge-Signature` header value, + /// formatted as `t=,v1=`. + /// [secret] is the subscription secret. + /// [toleranceSeconds] is the max age of the timestamp (default 300 = 5 min). + static bool verify( + String body, + String signatureHeader, + String secret, { + int toleranceSeconds = 300, + }) { + final parts = {}; + for (final segment in signatureHeader.split(',')) { + final idx = segment.indexOf('='); + if (idx == -1) continue; + parts[segment.substring(0, idx)] = segment.substring(idx + 1); + } + + final timestampStr = parts['t']; + final v1 = parts['v1']; + if (timestampStr == null || v1 == null) return false; + + final timestamp = int.tryParse(timestampStr); + if (timestamp == null) return false; + + // Check timestamp freshness. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if ((now - timestamp).abs() > toleranceSeconds) return false; + + // Compute HMAC-SHA256 of ".". + final payload = '$timestampStr.$body'; + final key = utf8.encode(secret); + final bytes = utf8.encode(payload); + final hmac = Hmac(sha256, key); + final digest = hmac.convert(bytes); + final expected = digest.toString(); + + // Constant-time comparison. + if (expected.length != v1.length) return false; + var result = 0; + for (var i = 0; i < expected.length; i++) { + result |= expected.codeUnitAt(i) ^ v1.codeUnitAt(i); + } + return result == 0; + } +} diff --git a/lib/utilities/default_sol_tokens.dart b/lib/utilities/default_sol_tokens.dart index fbc69a7ac7..65f11bd362 100644 --- a/lib/utilities/default_sol_tokens.dart +++ b/lib/utilities/default_sol_tokens.dart @@ -20,12 +20,12 @@ abstract class DefaultSolTokens { "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png", ), SolContract( - address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst", + address: "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", name: "Tether", symbol: "USDT", decimals: 6, logoUri: - "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenEst/logo.svg", + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB/logo.svg", ), SolContract( address: "MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac", @@ -36,20 +36,20 @@ abstract class DefaultSolTokens { "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/MangoCzJ36AjZyKwVj3VnYU4GTonjfVEnJmvvWaxLac/logo.png", ), SolContract( - address: "SRMuApVgqbCmmp3uVrwpad5p4stLBUq3nSoSnqQQXmk", + address: "SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt", name: "Serum", symbol: "SRM", decimals: 6, logoUri: - "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/SRMuApVgqbCmmp3uVrwpad5p4stLBUq3nSoSnqQQXmk/logo.png", + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt/logo.png", ), SolContract( - address: "orca8TvxvggsCKvVPXSHXDvKgJ3bNroWusDawg461mpD", + address: "orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE", name: "Orca", symbol: "ORCA", decimals: 6, logoUri: - "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/orcaEKTdK7LKz57chYcSKdBI6qrE5dS1zG4FqHWGcKc/logo.svg", + "https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE/logo.png", ), ]; } diff --git a/lib/utilities/prefs.dart b/lib/utilities/prefs.dart index 2f057de12c..20308539ea 100644 --- a/lib/utilities/prefs.dart +++ b/lib/utilities/prefs.dart @@ -728,7 +728,7 @@ class Prefs extends ChangeNotifier { Future _getLastAutoBackup() async { return await DB.instance.get( boxName: DB.boxNamePrefs, - key: "autoBackupFileUri", + key: "lastAutoBackup", ) as DateTime?; } diff --git a/lib/wallets/crypto_currency/coins/particl.dart b/lib/wallets/crypto_currency/coins/particl.dart index 2b07aad7ea..a631e94e4f 100644 --- a/lib/wallets/crypto_currency/coins/particl.dart +++ b/lib/wallets/crypto_currency/coins/particl.dart @@ -233,7 +233,7 @@ class Particl extends Bip39HDCurrency with ElectrumXCurrencyInterface { } @override - int get transactionVersion => 1; + int get transactionVersion => 160; @override BigInt get defaultFeeRate => BigInt.from(20000); diff --git a/lib/wallets/wallet/impl/litecoin_wallet.dart b/lib/wallets/wallet/impl/litecoin_wallet.dart index c9fa52a330..32cfe8fe6b 100644 --- a/lib/wallets/wallet/impl/litecoin_wallet.dart +++ b/lib/wallets/wallet/impl/litecoin_wallet.dart @@ -35,6 +35,9 @@ class LitecoinWallet @override int get isarTransactionVersion => 2; + @override + String get ordServerBaseUrl => 'https://ord-litecoin.stackwallet.com'; + LitecoinWallet(CryptoCurrencyNetwork network) : super(Litecoin(network) as T); @override @@ -86,9 +89,7 @@ class LitecoinWallet // Remove duplicates. final allAddressesSet = {...receivingAddresses, ...changeAddresses}; - final updateInscriptionsFuture = refreshInscriptions( - overrideAddressesToCheck: allAddressesSet.toList(), - ); + final updateInscriptionsFuture = refreshInscriptions(); // Fetch history from ElectrumX. final List> allTxHashes = await fetchHistory( diff --git a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart index 3850cb7500..a577b03ffa 100644 --- a/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart +++ b/lib/wallets/wallet/impl/mimblewimblecoin_wallet.dart @@ -41,6 +41,7 @@ class MimblewimblecoinWallet extends Bip39Wallet { : super(Mimblewimblecoin(network)); final syncMutex = Mutex(); + final _walletOpenMutex = Mutex(); NodeModel? _mimblewimblecoinNode; Timer? timer; @@ -95,24 +96,29 @@ class MimblewimblecoinWallet extends Bip39Wallet { } Future _ensureWalletOpen() async { - final existing = await secureStorageInterface.read( - key: '${walletId}_wallet', - ); - if (existing != null && existing.isNotEmpty) return existing; + return await _walletOpenMutex.protect(() async { + final existing = await secureStorageInterface.read( + key: '${walletId}_wallet', + ); + if (existing != null && existing.isNotEmpty) return existing; - final config = await _getRealConfig(); - final password = await secureStorageInterface.read( - key: '${walletId}_password', - ); - if (password == null) { - throw Exception('Wallet password not found'); - } - final opened = await libMwc.openWallet(config: config, password: password); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: opened, - ); - return opened; + final config = await _getRealConfig(); + final password = await secureStorageInterface.read( + key: '${walletId}_password', + ); + if (password == null) { + throw Exception('Wallet password not found'); + } + final opened = await libMwc.openWallet( + config: config, + password: password, + ); + await secureStorageInterface.write( + key: '${walletId}_wallet', + value: opened, + ); + return opened; + }); } /// Returns an empty String on success, error message on failure. @@ -576,6 +582,8 @@ class MimblewimblecoinWallet extends Bip39Wallet { final String nodeApiAddress = uri.toString(); final walletDir = await _currentWalletDirPath(); + await _ensureApiSecret(walletDir, nodeApiAddress); + final Map config = {}; config["wallet_dir"] = walletDir; config["check_node_api_http_addr"] = nodeApiAddress; @@ -585,6 +593,21 @@ class MimblewimblecoinWallet extends Bip39Wallet { return stringConfig; } + /// Write the node API secret to .api_secret in the wallet directory so that + /// the Rust HTTPNodeClient can authenticate to the MWC node. + Future _ensureApiSecret(String walletDir, String nodeUrl) async { + const defaultNodeHost = 'mwc713.mwc.mw'; + const defaultNodeSecret = '11ne3EAUtOXVKwhxm84U'; + + final file = File('$walletDir/.api_secret'); + if (nodeUrl.contains(defaultNodeHost)) { + await Directory(walletDir).create(recursive: true); + await file.writeAsString(defaultNodeSecret); + } else if (await file.exists()) { + await file.delete(); + } + } + Future _currentWalletDirPath() async { final Directory appDir = await StackFileSystem.applicationRootDirectory(); @@ -894,14 +917,17 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); //Open wallet - encodedWallet = await libMwc.openWallet( - config: stringConfig, - password: password, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: encodedWallet, - ); + encodedWallet = await _walletOpenMutex.protect(() async { + final opened = await libMwc.openWallet( + config: stringConfig, + password: password, + ); + await secureStorageInterface.write( + key: '${walletId}_wallet', + value: opened, + ); + return opened; + }); //Store MwcMqs address info await _generateAndStoreReceivingAddressForIndex(0); @@ -935,14 +961,16 @@ class MimblewimblecoinWallet extends Bip39Wallet { key: '${walletId}_password', ); - final walletOpen = await libMwc.openWallet( - config: config, - password: password!, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: walletOpen, - ); + await _walletOpenMutex.protect(() async { + final walletOpen = await libMwc.openWallet( + config: config, + password: password!, + ); + await secureStorageInterface.write( + key: '${walletId}_wallet', + value: walletOpen, + ); + }); await updateNode(); } catch (e, s) { @@ -1144,14 +1172,16 @@ class MimblewimblecoinWallet extends Bip39Wallet { ); //Open Wallet - final walletOpen = await libMwc.openWallet( - config: stringConfig, - password: password, - ); - await secureStorageInterface.write( - key: '${walletId}_wallet', - value: walletOpen, - ); + await _walletOpenMutex.protect(() async { + final walletOpen = await libMwc.openWallet( + config: stringConfig, + password: password, + ); + await secureStorageInterface.write( + key: '${walletId}_wallet', + value: walletOpen, + ); + }); await _generateAndStoreReceivingAddressForIndex( mimblewimblecoinData.receivingIndex, diff --git a/lib/wallets/wallet/impl/particl_wallet.dart b/lib/wallets/wallet/impl/particl_wallet.dart index 6f2b9764ea..65bc9c4c74 100644 --- a/lib/wallets/wallet/impl/particl_wallet.dart +++ b/lib/wallets/wallet/impl/particl_wallet.dart @@ -73,34 +73,40 @@ class ParticlWallet String? blockedReason; String? utxoLabel; + // Only check the specific output this UTXO corresponds to, not all outputs. + final vout = jsonUTXO["tx_pos"] as int; final outputs = jsonTX["vout"] as List? ?? []; - for (final output in outputs) { - if (output is Map) { - if (output['ct_fee'] != null) { - // Blind output, ignore for now. - blocked = true; - blockedReason = "Blind output."; - utxoLabel = "Unsupported output type."; - } else if (output['rangeproof'] != null) { - // Private RingCT output, ignore for now. - blocked = true; - blockedReason = "Confidential output."; - utxoLabel = "Unsupported output type."; - } else if (output['data_hex'] != null) { - // Data output, ignore for now. + // Use Map? because ElectrumX returns _Map. + Map? output; + for (final o in outputs) { + if (o is Map && o["n"] == vout) { + output = o; + break; + } + } + + if (output != null) { + if (output['ct_fee'] != null) { + blocked = true; + blockedReason = "Blind output."; + utxoLabel = "Unsupported output type."; + } else if (output['rangeproof'] != null) { + blocked = true; + blockedReason = "Confidential output."; + utxoLabel = "Unsupported output type."; + } else if (output['data_hex'] != null) { + blocked = true; + blockedReason = "Data output."; + utxoLabel = "Unsupported output type."; + } else if (output['scriptPubKey'] != null) { + if (output['scriptPubKey']?['asm'] is String && + (output['scriptPubKey']['asm'] as String).contains( + "OP_ISCOINSTAKE", + )) { blocked = true; - blockedReason = "Data output."; + blockedReason = "Spending staking"; utxoLabel = "Unsupported output type."; - } else if (output['scriptPubKey'] != null) { - if (output['scriptPubKey']?['asm'] is String && - (output['scriptPubKey']['asm'] as String).contains( - "OP_ISCOINSTAKE", - )) { - blocked = true; - blockedReason = "Spending staking"; - utxoLabel = "Unsupported output type."; - } } } } @@ -237,17 +243,12 @@ class ParticlWallet addresses.addAll(prevOut.addresses); } - InputV2 input = InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: map["scriptSig"]?["hex"] as String?, - scriptSigAsm: map["scriptSig"]?["asm"] as String?, - sequence: map["sequence"] as int?, + InputV2 input = InputV2.fromElectrumxJson( + json: map, outpoint: outpoint, - valueStringSats: valueStringSats, addresses: addresses, - witness: map["witness"] as String?, + valueStringSats: valueStringSats, coinbase: coinbase, - innerRedeemScriptAsm: map["innerRedeemscriptAsm"] as String?, - // Need addresses before we can know if the wallet owns this input. walletOwns: false, ); @@ -454,7 +455,7 @@ class ParticlWallet tempInputs.add( InputV2.isarCantDoRequiredInDefaultConstructor( - scriptSigHex: txb.inputs.first.script?.toHex, + scriptSigHex: txb.inputs[i].script?.toHex, scriptSigAsm: null, sequence: 0xffffffff - 1, outpoint: OutpointV2.isarCantDoRequiredInDefaultConstructor( @@ -511,6 +512,7 @@ class ParticlWallet ), witnessValue: insAndKeys[i].utxo.value, redeemScript: extraData[i].redeem, + isParticl: true, overridePrefix: cryptoCurrency.networkParams.bech32Hrp, ); } @@ -526,30 +528,8 @@ class ParticlWallet final builtTx = txb.build(cryptoCurrency.networkParams.bech32Hrp); final vSize = builtTx.virtualSize(); - // Strip trailing 0x00 bytes from hex. - // - // This is done to match the previous particl_wallet implementation. - // TODO: [prio=low] Rework Particl tx construction so as to obviate this. - String hexString = builtTx.toHex(isParticl: true).toString(); - if (hexString.length % 2 != 0) { - // Ensure the string has an even length. - Logging.instance.e( - "Hex string has odd length, which is unexpected.", - stackTrace: StackTrace.current, - ); - throw Exception("Invalid hex string length."); - } - // int maxStrips = 3; // Strip up to 3 0x00s (match previous particl_wallet). - while (hexString.endsWith('00') && hexString.length > 2) { - hexString = hexString.substring(0, hexString.length - 2); - // maxStrips--; - // if (maxStrips <= 0) { - // break; - // } - } - return txData.copyWith( - raw: hexString, + raw: builtTx.toHex(isParticl: true), vSize: vSize, tempTx: null, // builtTx.getId() requires an isParticl flag as well but the lib does not support that yet diff --git a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart index a311e05fd4..5fb99a19a6 100644 --- a/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart +++ b/lib/wallets/wallet/impl/sub_wallets/solana_token_wallet.dart @@ -510,7 +510,8 @@ class SolanaTokenWallet extends Wallet { (e) => e.containsKey("parsed") && e["program"] == "spl-token" && - e["parsed"]["type"] == "transferChecked", + (e["parsed"]["type"] == "transferChecked" || + e["parsed"]["type"] == "transfer"), ); if (splTransfers.length != 1) { @@ -522,9 +523,17 @@ class SolanaTokenWallet extends Wallet { continue; } final transfer = splTransfers.first; - final lamports = BigInt.parse( - transfer["parsed"]["info"]["tokenAmount"]["amount"].toString(), - ); + final transferType = transfer["parsed"]["type"] as String; + final BigInt lamports; + if (transferType == "transferChecked") { + lamports = BigInt.parse( + transfer["parsed"]["info"]["tokenAmount"]["amount"].toString(), + ); + } else { + lamports = BigInt.parse( + transfer["parsed"]["info"]["amount"].toString(), + ); + } final senderAddress = transfer["parsed"]["info"]["source"] as String; final receiverAddress = transfer["parsed"]["info"]["destination"] as String; diff --git a/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart b/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart index 0cfac6d1bb..eb818021a6 100644 --- a/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart +++ b/lib/wallets/wallet/intermediate/lib_xelis_wallet.dart @@ -169,7 +169,7 @@ abstract class LibXelisWallet await _eventSubscription?.cancel(); _eventSubscription = null; - if (wallet != null) { + if (wallet != null && await libXelis.isOnline(wallet!)) { await libXelis.offlineMode(wallet!); } await super.exit(); diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart index e183be63b6..bfeb24e72f 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/mweb_interface.dart @@ -14,6 +14,7 @@ import '../../../models/input.dart'; import '../../../models/isar/models/blockchain_data/v2/output_v2.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; +import '../../../models/isar/ordinal.dart'; import '../../../services/event_bus/events/global/blocks_remaining_event.dart'; import '../../../services/event_bus/events/global/refresh_percent_changed_event.dart'; import '../../../services/event_bus/events/global/wallet_sync_status_changed_event.dart'; @@ -649,6 +650,20 @@ mixin MwebInterface ), ); + // Never peg ordinal UTXOs into MWEB. + spendableUtxos.removeWhere((e) { + final ord = mainDB.isar.ordinals + .where() + .filter() + .walletIdEqualTo(walletId) + .and() + .utxoTXIDEqualTo(e.txid) + .and() + .utxoVOUTEqualTo(e.vout) + .findFirstSync(); + return ord != null; + }); + if (spendableUtxos.isEmpty) { throw Exception("No available UTXOs found to anonymize"); } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart index 686d1f90a9..f9165fb697 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart @@ -1,53 +1,79 @@ import 'package:isar_community/isar.dart'; import '../../../dto/ordinals/inscription_data.dart'; +import '../../../models/input.dart'; +import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/utxo.dart'; import '../../../models/isar/ordinal.dart'; -import '../../../services/litescribe_api.dart'; +import '../../../services/ord_api.dart'; +import '../../../utilities/amount/amount.dart'; +import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; +import '../../models/tx_data.dart'; import 'electrumx_interface.dart'; mixin OrdinalsInterface on ElectrumXInterface { - final LitescribeAPI _litescribeAPI = LitescribeAPI( - baseUrl: 'https://litescribe.io/api', - ); + /// Subclasses must provide the base URL for their ord server. + /// e.g. 'https://ord-litecoin.stackwallet.com' + String get ordServerBaseUrl; - // check if an inscription is in a given output - Future _inscriptionInAddress(String address) async { + late final OrdAPI _ordAPI = OrdAPI(baseUrl: ordServerBaseUrl); + + /// Check whether a specific output contains inscriptions. + Future _inscriptionInOutput(String txid, int vout) async { try { - return (await _litescribeAPI.getInscriptionsByAddress( - address, - )).isNotEmpty; + final ids = await _ordAPI.getInscriptionIdsForOutput(txid, vout); + return ids.isNotEmpty; } catch (e, s) { - Logging.instance.e("Litescribe api failure!", error: e, stackTrace: s); - + Logging.instance.e( + "Ord API output check failure!", + error: e, + stackTrace: s, + ); return false; } } - Future refreshInscriptions({ - List? overrideAddressesToCheck, - }) async { + Future refreshInscriptions() async { try { - final uniqueAddresses = - overrideAddressesToCheck ?? - await mainDB - .getUTXOs(walletId) - .filter() - .addressIsNotNull() - .distinctByAddress() - .addressProperty() - .findAll(); - final inscriptions = await _getInscriptionDataFromAddresses( - uniqueAddresses.cast(), - ); + final utxos = await mainDB.getUTXOs(walletId).findAll(); + + final List allInscriptions = []; + + for (final utxo in utxos) { + try { + final ids = await _ordAPI.getInscriptionIdsForOutput( + utxo.txid, + utxo.vout, + ); + + for (final inscriptionId in ids) { + try { + final json = await _ordAPI.getInscriptionData(inscriptionId); + allInscriptions.add( + InscriptionData.fromOrdJson( + json, + _ordAPI.contentUrl(inscriptionId), + ), + ); + } catch (e) { + Logging.instance.w( + "Failed to fetch inscription $inscriptionId: $e", + ); + } + } + } catch (e) { + Logging.instance.w( + "Failed to check output ${utxo.txid}:${utxo.vout}: $e", + ); + } + } - final ords = - inscriptions - .map((e) => Ordinal.fromInscriptionData(e, walletId)) - .toList(); + final ords = allInscriptions + .map((e) => Ordinal.fromInscriptionData(e, walletId)) + .toList(); await mainDB.isar.writeTxn(() async { await mainDB.isar.ordinals @@ -65,6 +91,70 @@ mixin OrdinalsInterface ); } } + + /// Build a transaction that sends the ordinal UTXO to [recipientAddress]. + /// + /// Uses coin-control send-all from the single ordinal UTXO so the ordinal + /// (at input offset 0) lands on the only output (the recipient) via FIFO. + /// If the UTXO value can't cover the fee, an exception is thrown. + Future prepareOrdinalSend({ + required UTXO ordinalUtxo, + required String recipientAddress, + FeeRateType feeRateType = FeeRateType.average, + }) async { + // Temporarily unblock so coinSelection accepts it. + final wasBlocked = ordinalUtxo.isBlocked; + // utxoForTx is the in-memory object passed to coinSelection; it must have + // isBlocked=false or the spendable-outputs filter will reject it. + UTXO utxoForTx = ordinalUtxo; + if (wasBlocked) { + final unblocked = ordinalUtxo.copyWith( + isBlocked: false, + blockedReason: null, + ); + unblocked.id = ordinalUtxo.id; + await mainDB.putUTXO(unblocked); + utxoForTx = unblocked; + } + + try { + final utxoValue = Amount( + rawValue: BigInt.from(ordinalUtxo.value), + fractionDigits: cryptoCurrency.fractionDigits, + ); + + final txData = TxData( + feeRateType: feeRateType, + recipients: [ + TxRecipient( + address: recipientAddress, + amount: utxoValue, + isChange: false, + addressType: + cryptoCurrency.getAddressType(recipientAddress) ?? + AddressType.unknown, + ), + ], + utxos: {StandardInput(utxoForTx)}, + ignoreCachedBalanceChecks: true, + note: + "Send ordinal #${(await mainDB.isar.ordinals.where().filter().walletIdEqualTo(walletId).and().utxoTXIDEqualTo(ordinalUtxo.txid).and().utxoVOUTEqualTo(ordinalUtxo.vout).findFirst())?.inscriptionNumber ?? "unknown"}", + ); + + return await prepareSend(txData: txData); + } finally { + // Re-block regardless of success or failure. + if (wasBlocked) { + final reblocked = ordinalUtxo.copyWith( + isBlocked: true, + blockedReason: "Ordinal", + ); + reblocked.id = ordinalUtxo.id; + await mainDB.putUTXO(reblocked); + } + } + } + // =================== Overrides ============================================= @override @@ -79,58 +169,20 @@ mixin OrdinalsInterface String? blockReason; String? label; + final txid = jsonTX["txid"] as String; + final vout = jsonUTXO["tx_pos"] as int; final utxoAmount = jsonUTXO["value"] as int; - // TODO: [prio=med] check following 3 todos - - // TODO check the specific output, not just the address in general - // TODO optimize by freezing output in OrdinalsInterface, so one ordinal API calls is made (or at least many less) - if (utxoOwnerAddress != null && - await _inscriptionInAddress(utxoOwnerAddress)) { + if (await _inscriptionInOutput(txid, vout)) { shouldBlock = true; blockReason = "Ordinal"; - label = "Ordinal detected at address"; - } else { - // TODO implement inscriptionInOutput - if (utxoAmount <= 10000) { - shouldBlock = true; - blockReason = "May contain ordinal"; - label = "Possible ordinal"; - } + label = "Ordinal detected at output"; + } else if (utxoAmount <= 10000) { + shouldBlock = true; + blockReason = "May contain ordinal"; + label = "Possible ordinal"; } return (blockedReason: blockReason, blocked: shouldBlock, utxoLabel: label); } - - @override - Future updateUTXOs() async { - final newUtxosAdded = await super.updateUTXOs(); - if (newUtxosAdded) { - try { - await refreshInscriptions(); - } catch (_) { - // do nothing but do not block/fail this updateUTXOs call based on litescribe call failures - } - } - - return newUtxosAdded; - } - - // ===================== Private ============================================= - Future> _getInscriptionDataFromAddresses( - List addresses, - ) async { - final List allInscriptions = []; - for (final String address in addresses) { - try { - final inscriptions = await _litescribeAPI.getInscriptionsByAddress( - address, - ); - allInscriptions.addAll(inscriptions); - } catch (e) { - throw Exception("Error fetching inscriptions for address $address: $e"); - } - } - return allInscriptions; - } } diff --git a/lib/widgets/ordinal_image.dart b/lib/widgets/ordinal_image.dart new file mode 100644 index 0000000000..abad5bd0c1 --- /dev/null +++ b/lib/widgets/ordinal_image.dart @@ -0,0 +1,81 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../app_config.dart'; +import '../networking/http.dart'; +import '../utilities/prefs.dart'; +import '../services/tor_service.dart'; + +/// Fetches and displays an image through the app's HTTP client, +/// respecting Tor proxy settings. Use this instead of [Image.network] +/// when the request must route through Tor. +class OrdinalImage extends StatefulWidget { + const OrdinalImage({ + super.key, + required this.url, + this.fit = BoxFit.cover, + this.filterQuality = FilterQuality.none, + }); + + final String url; + final BoxFit fit; + final FilterQuality filterQuality; + + @override + State createState() => _OrdinalImageState(); +} + +class _OrdinalImageState extends State { + late Future _future; + + @override + void initState() { + super.initState(); + _future = _fetchImage(); + } + + @override + void didUpdateWidget(OrdinalImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.url != widget.url) { + _future = _fetchImage(); + } + } + + Future _fetchImage() async { + final response = await const HTTP().get( + url: Uri.parse(widget.url), + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + if (response.code != 200) { + throw Exception('Failed to load image: status=${response.code}'); + } + + return Uint8List.fromList(response.bodyBytes); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Image.memory( + snapshot.data!, + fit: widget.fit, + filterQuality: widget.filterQuality, + ); + } else if (snapshot.hasError) { + return const Center(child: Icon(Icons.broken_image)); + } + return const Center(child: CircularProgressIndicator()); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 0aedf78678..fac0298771 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: "direct main" description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -114,8 +114,8 @@ packages: dependency: "direct main" description: path: "." - ref: "7145be16bb88cffbd53326f7fa4570e414be09e4" - resolved-ref: "7145be16bb88cffbd53326f7fa4570e414be09e4" + ref: b02aaf6c6b40fd5a6b3d77f875324717103f2019 + resolved-ref: b02aaf6c6b40fd5a6b3d77f875324717103f2019 url: "https://github.com/cypherstack/bitcoindart.git" source: git version: "3.0.2" @@ -227,10 +227,10 @@ packages: dependency: transitive description: name: built_value - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" url: "https://pub.dev" source: hosted - version: "8.12.1" + version: "8.12.4" calendar_date_picker2: dependency: "direct main" description: @@ -277,10 +277,10 @@ packages: dependency: "direct main" description: name: cbor - sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b + sha256: "2c5c37650f0a2d25149f03e748ab7b2857787bde338f95fe947738b80d713da2" url: "https://pub.dev" source: hosted - version: "6.3.7" + version: "6.5.1" characters: dependency: transitive description: @@ -329,14 +329,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: name: code_builder - sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.11.1" coinlib: dependency: "direct overridden" description: @@ -408,10 +416,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+2" crypto: dependency: "direct main" description: @@ -737,10 +745,10 @@ packages: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.8" dartx: dependency: transitive description: @@ -753,10 +761,10 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" decimal: dependency: "direct main" description: @@ -769,10 +777,10 @@ packages: dependency: "direct dev" description: name: dependency_validator - sha256: a5928c0e3773808027bdafeb13fb4be0e4fdd79819773ad3df34d0fcf42636f2 + sha256: d6084f8df7677843c8fd0e08b66c11d9c2ce9bae1bb1f18cc574bcb28ebe71b0 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.5" desktop_drop: dependency: "direct main" description: @@ -818,34 +826,34 @@ packages: dependency: transitive description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.9.2" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" drift: dependency: "direct main" description: name: drift - sha256: "3669e1b68d7bffb60192ac6ba9fd2c0306804d7a00e5879f6364c69ecde53a7f" + sha256: "970cd188fddb111b26ea6a9b07a62bf5c2432d74147b8122c67044ae3b97e99e" url: "https://pub.dev" source: hosted - version: "2.30.0" + version: "2.31.0" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: afe4d1d2cfce6606c86f11a6196e974a2ddbfaa992956ce61e054c9b1899c769 + sha256: "917184b2fb867b70a548a83bf0d36268423b38d39968c06cce4905683da49587" url: "https://pub.dev" source: hosted - version: "2.30.0" + version: "2.31.0" drift_flutter: dependency: "direct main" description: @@ -907,10 +915,10 @@ packages: dependency: "direct main" description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" ethereum_addresses: dependency: "direct main" description: @@ -940,10 +948,10 @@ packages: dependency: "direct main" description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -956,10 +964,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f2d9f173c2c14635cc0e9b14c143c49ef30b4934e8d1d274d6206fcb0086a06f + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" url: "https://pub.dev" source: hosted - version: "10.3.3" + version: "10.3.10" fixnum: dependency: "direct main" description: @@ -1069,10 +1077,10 @@ packages: dependency: "direct main" description: name: flutter_native_splash - sha256: "17d9671396fb8ec45ad10f4a975eb8a0f70bedf0fdaf0720b31ea9de6da8c4da" + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" url: "https://pub.dev" source: hosted - version: "2.3.7" + version: "2.4.7" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -1149,10 +1157,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -1167,10 +1175,10 @@ packages: dependency: "direct overridden" description: name: freezed - sha256: "03dd9b7423ff0e31b7e01b2204593e5e1ac5ee553b6ea9d8184dff4a26b9fb07" + sha256: f23ea33b3863f119b58ed1b586e881a46bd28715ddcc4dbc33104524e3434131 url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.5" freezed_annotation: dependency: "direct overridden" description: @@ -1220,10 +1228,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: "517b20870220c48752eafa0ba1a797a092fb22df0d89535fd9991e86ee2cdd9c" + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" google_identity_services_web: dependency: transitive description: @@ -1252,10 +1260,10 @@ packages: dependency: transitive description: name: grpc - sha256: "2dde469ddd8bbd7a33a0765da417abe1ad2142813efce3a86c512041294e2b26" + sha256: "15227eeed339bd0ef5afe515cb791b2e4bec0711ab56f37cc44257bcfaedc4bf" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" hex: dependency: "direct main" description: @@ -1276,18 +1284,18 @@ packages: dependency: "direct main" description: name: hive_ce - sha256: "81d39a03c4c0ba5938260a8c3547d2e71af59defecea21793d57fc3551f0d230" + sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" url: "https://pub.dev" source: hosted - version: "2.15.1" + version: "2.19.3" hive_ce_flutter: dependency: "direct main" description: name: hive_ce_flutter - sha256: "26d656c9e8974f0732f1d09020e2d7b08ba841b8961a02dbfb6caf01474b0e9a" + sha256: "2677e95a333ff15af43ccd06af7eb7abbf1a4f154ea071997f3de4346cae913a" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.4" hive_ce_generator: dependency: "direct dev" description: @@ -1304,6 +1312,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" html: dependency: transitive description: @@ -1344,22 +1360,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - ieee754: - dependency: transitive - description: - name: ieee754 - sha256: "7d87451c164a56c156180d34a4e93779372edd191d2c219206100b976203128c" - url: "https://pub.dev" - source: hosted - version: "1.0.3" image: dependency: "direct main" description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" import_sorter: dependency: "direct dev" description: @@ -1417,10 +1425,10 @@ packages: dependency: transitive description: name: isolate_channel - sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d + sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094 url: "https://pub.dev" source: hosted - version: "0.2.2+1" + version: "0.6.1" js: dependency: transitive description: @@ -1441,10 +1449,10 @@ packages: dependency: "direct overridden" description: name: json_rpc_2 - sha256: "3c46c2633aec07810c3d6a2eb08d575b5b4072980db08f1344e66aeb53d6e4a7" + sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" json_serializable: dependency: transitive description: @@ -1627,10 +1635,10 @@ packages: dependency: "direct dev" description: name: mockito - sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 url: "https://pub.dev" source: hosted - version: "5.6.1" + version: "5.6.3" mocktail: dependency: transitive description: @@ -1681,6 +1689,14 @@ packages: url: "https://github.com/cypherstack/nanodart" source: git version: "2.0.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nm: dependency: transitive description: @@ -1697,6 +1713,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" on_chain: dependency: "direct main" description: @@ -1765,10 +1789,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -1845,10 +1869,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.2" pinenacl: dependency: transitive description: @@ -1893,10 +1917,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" pretty_dio_logger: dependency: transitive description: @@ -1949,10 +1973,10 @@ packages: dependency: "direct main" description: name: qr_code_scanner_plus - sha256: b764e5004251c58d9dee0c295e6006e05bd8d249e78ac3383abdb5afe0a996cd + sha256: dae0596b2763c2fd0294f5cfddb1d3a21577ae4dc7fc1449eb5aafc957872f61 url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.1.1" qr_flutter: dependency: "direct main" description: @@ -2139,10 +2163,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" sqlite3: dependency: "direct main" description: @@ -2163,10 +2187,10 @@ packages: dependency: transitive description: name: sqlparser - sha256: "162435ede92bcc793ea939fdc0452eef0a73d11f8ed053b58a89792fba749da5" + sha256: "337e9997f7141ffdd054259128553c348635fa318f7ca492f07a4ab76f850d19" url: "https://pub.dev" source: hosted - version: "0.42.1" + version: "0.43.1" stack_trace: dependency: transitive description: @@ -2196,10 +2220,10 @@ packages: dependency: "direct main" description: name: stellar_flutter_sdk - sha256: eb07752e11c6365ee59a666f7a95964f761ec05250b0cecaf14698ebc66b09b0 + sha256: d3a7a38e262d7d96f2650a09d15fe831ef1686cb5b2f07feebbe0e3bfceceaf5 url: "https://pub.dev" source: hosted - version: "2.1.8" + version: "2.2.2" stream_channel: dependency: "direct main" description: @@ -2285,10 +2309,10 @@ packages: dependency: transitive description: name: time - sha256: "370572cf5d1e58adcb3e354c47515da3f7469dac3a95b447117e728e7be6f461" + sha256: "46187cf30bffdab28c56be9a63861b36e4ab7347bf403297595d6a97e10c789f" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" timezone: dependency: transitive description: @@ -2317,10 +2341,10 @@ packages: dependency: transitive description: name: toml - sha256: d968d149c8bd06dc14e09ea3a140f90a3f2ba71949e7a91df4a46f3107400e71 + sha256: "35cd2a1351c14bd213f130f8efcbd3e0c18181bff0c8ca7a08f6822a2bede786" url: "https://pub.dev" source: hosted - version: "0.16.0" + version: "0.17.0" tor_ffi_plugin: dependency: "direct main" description: @@ -2382,10 +2406,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -2414,10 +2438,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -2430,18 +2454,18 @@ packages: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "7076216a10d5c390315fbe536a30f1254c341e7543e6c4c8a815e591307772b1" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_graphics_codec: dependency: transitive description: @@ -2454,10 +2478,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.0" vector_math: dependency: transitive description: @@ -2470,10 +2494,10 @@ packages: dependency: transitive description: name: very_good_analysis - sha256: "96245839dbcc45dfab1af5fa551603b5c7a282028a64746c19c547d21a7f1e3a" + sha256: "27927d1140ce1b140f998b6340f730a626faa5b95110b3e34a238ff254d731d0" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.1.0" vm_service: dependency: transitive description: @@ -2502,10 +2526,10 @@ packages: dependency: transitive description: name: wakelock_plus_platform_interface - sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" wakelock_windows: dependency: "direct overridden" description: @@ -2535,10 +2559,10 @@ packages: dependency: transitive description: name: watcher - sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.4" + version: "1.2.1" web: dependency: "direct overridden" description: @@ -2641,10 +2665,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" xxh3: dependency: transitive description: @@ -2686,5 +2710,5 @@ packages: source: hosted version: "0.2.4" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.1 <4.0.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4 <4.0.0" diff --git a/scripts/app_config/templates/pubspec.template.yaml b/scripts/app_config/templates/pubspec.template.yaml index 6b551d4c48..860a3e5c5b 100644 --- a/scripts/app_config/templates/pubspec.template.yaml +++ b/scripts/app_config/templates/pubspec.template.yaml @@ -95,7 +95,7 @@ dependencies: bitcoindart: git: url: https://github.com/cypherstack/bitcoindart.git - ref: 7145be16bb88cffbd53326f7fa4570e414be09e4 + ref: b02aaf6c6b40fd5a6b3d77f875324717103f2019 stack_wallet_backup: git: @@ -325,6 +325,12 @@ dependency_overrides: url: https://github.com/cypherstack/bip47.git ref: 3ef6b94375d7b4d972b0bc0bd9597532381a88ec + # bip47 pins a different bitcoindart commit; override to ours + bitcoindart: + git: + url: https://github.com/cypherstack/bitcoindart.git + ref: b02aaf6c6b40fd5a6b3d77f875324717103f2019 + # required for dart 3, at least until a fix is merged upstream wakelock_windows: git: diff --git a/scripts/prebuild.ps1 b/scripts/prebuild.ps1 index 5dd966d6cd..a3acbdede3 100644 --- a/scripts/prebuild.ps1 +++ b/scripts/prebuild.ps1 @@ -2,7 +2,7 @@ $KEYS = "..\lib\external_api_keys.dart" if (-not (Test-Path $KEYS)) { Write-Host "prebuild.ps1: creating template lib/external_api_keys.dart file" - "const kChangeNowApiKey = '';" + "`nconst kSimpleSwapApiKey = '';" + "`nconst kNanswapApiKey = '';" + "`nconst kNanoSwapRpcApiKey = '';" + "`nconst kWizSwapApiKey = '';" | Out-File $KEYS -Encoding UTF8 + "const kChangeNowApiKey = '';" + "`nconst kSimpleSwapApiKey = '';" + "`nconst kNanswapApiKey = '';" + "`nconst kNanoSwapRpcApiKey = '';" + "`nconst kWizSwapApiKey = '';" + "`nconst kShopInBitAccessKey = '';" + "`nconst kShopInBitPartnerSecret = '';" + "`nconst kCakePayApiToken = '';" | Out-File $KEYS -Encoding UTF8 } # Create template wallet test parameter files if they don't already exist diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh index 44d4e0921c..0aa13ea223 100755 --- a/scripts/prebuild.sh +++ b/scripts/prebuild.sh @@ -4,7 +4,7 @@ KEYS=../lib/external_api_keys.dart if ! test -f "$KEYS"; then echo 'prebuild.sh: creating template lib/external_api_keys.dart file' - printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\n' > $KEYS + printf 'const kChangeNowApiKey = "";\nconst kSimpleSwapApiKey = "";\nconst kNanswapApiKey = "";\nconst kNanoSwapRpcApiKey = "";\nconst kWizSwapApiKey = "";\nconst kShopInBitAccessKey = "";\nconst kShopInBitPartnerSecret = "";\nconst kCakePayApiToken = "";\n' > $KEYS fi # Create template wallet test parameter files if they don't already exist