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/more_view/gift_cards_view.dart b/lib/pages/more_view/gift_cards_view.dart new file mode 100644 index 0000000000..cc25e61373 --- /dev/null +++ b/lib/pages/more_view/gift_cards_view.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.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/rounded_white_container.dart'; + +class GiftCardsView extends StatelessWidget { + const GiftCardsView({super.key}); + + static const String routeName = "/giftCardsView"; + + @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("Gift cards", style: STextStyles.navBarTitle(context)), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: RoundedWhiteContainer( + child: 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, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/more_view/services_view.dart b/lib/pages/more_view/services_view.dart new file mode 100644 index 0000000000..ff17f7ba26 --- /dev/null +++ b/lib/pages/more_view/services_view.dart @@ -0,0 +1,307 @@ +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 '../shopinbit/shopinbit_settings_view.dart'; +import '../shopinbit/shopinbit_step_1.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(); + await Navigator.of(context).pushNamed( + ShopInBitStep1.routeName, + arguments: ShopInBitOrderModel(), + ); + 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), + RichText( + text: TextSpan( + style: STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + children: [ + const TextSpan( + text: + "Concierge shopping service. Purchase " + "products and services using cryptocurrency.\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: "."), + ], + ), + ), + 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 tickets ($count)" + : "My tickets", + 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..ac670918c5 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..73986e4a57 100644 --- a/lib/pages/settings_views/global_settings_view/hidden_settings.dart +++ b/lib/pages/settings_views/global_settings_view/hidden_settings.dart @@ -14,6 +14,7 @@ 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 '../../../themes/stack_colors.dart'; @@ -41,19 +42,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 +80,8 @@ class HiddenSettings extends StatelessWidget { ref .read(prefsChangeNotifierProvider) .advancedFiroFeatures = !ref - .read(prefsChangeNotifierProvider) - .advancedFiroFeatures; + .read(prefsChangeNotifierProvider) + .advancedFiroFeatures; }, child: RoundedWhiteContainer( child: Text( @@ -94,10 +93,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 +107,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 +134,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 +149,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 +281,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 +296,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,28 +311,56 @@ 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 ticket(s)", + context: context, + ), + ); + } + }, + child: RoundedWhiteContainer( + child: Text( + "Delete all ShopInBit tickets", + 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, ), ), ), 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..340b99c7cb --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_car_fee_view.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; + +import '../../db/isar/main_db.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/rounded_white_container.dart'; +import '../../widgets/stack_text_field.dart'; +import 'shopinbit_order_created.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 TextEditingController _countryController; + late final FocusNode _nameFocusNode; + late final FocusNode _streetFocusNode; + late final FocusNode _cityFocusNode; + late final FocusNode _postalCodeFocusNode; + late final FocusNode _countryFocusNode; + + bool get _canContinue => + _nameController.text.trim().isNotEmpty && + _streetController.text.trim().isNotEmpty && + _cityController.text.trim().isNotEmpty && + _postalCodeController.text.trim().isNotEmpty && + _countryController.text.trim().isNotEmpty; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _streetController = TextEditingController(); + _cityController = TextEditingController(); + _postalCodeController = TextEditingController(); + _countryController = TextEditingController(); + _nameFocusNode = FocusNode(); + _streetFocusNode = FocusNode(); + _cityFocusNode = FocusNode(); + _postalCodeFocusNode = FocusNode(); + _countryFocusNode = FocusNode(); + + for (final node in [ + _nameFocusNode, + _streetFocusNode, + _cityFocusNode, + _postalCodeFocusNode, + _countryFocusNode, + ]) { + node.addListener(() => setState(() {})); + } + } + + @override + void dispose() { + _nameController.dispose(); + _streetController.dispose(); + _cityController.dispose(); + _postalCodeController.dispose(); + _countryController.dispose(); + _nameFocusNode.dispose(); + _streetFocusNode.dispose(); + _cityFocusNode.dispose(); + _postalCodeFocusNode.dispose(); + _countryFocusNode.dispose(); + super.dispose(); + } + + void _payFee() { + widget.model.ticketId = + "SIB-${DateTime.now().millisecondsSinceEpoch % 10000}"; + widget.model.status = ShopInBitOrderStatus.pending; + MainDB.instance.putShopInBitTicket(widget.model.toIsarTicket()); + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + builder: (_) => ShopInBitOrderCreated(model: widget.model), + ); + } else { + Navigator.of( + context, + ).pushNamed(ShopInBitOrderCreated.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( + "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( + "50.00 EUR", + style: isDesktop + ? STextStyles.desktopTextSmall(context) + : STextStyles.itemSubtitle(context), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 24 : 16), + Text( + "Billing 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, + _buildField( + controller: _countryController, + focusNode: _countryFocusNode, + label: "Country", + isDesktop: isDesktop, + ), + const Spacer(), + PrimaryButton( + label: "Pay research fee", + enabled: _canContinue, + onPressed: _canContinue ? _payFee : null, + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 650, + 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_confirm_send_view.dart b/lib/pages/shopinbit/shopinbit_confirm_send_view.dart new file mode 100644 index 0000000000..17a900e113 --- /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("Ticket 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..63dfab9190 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_offer_view.dart @@ -0,0 +1,253 @@ +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), + ), + ], + ), + ), + SizedBox(height: isDesktop ? 12 : 8), + RoundedWhiteContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Ticket", + style: isDesktop + ? STextStyles.desktopTextExtraExtraSmall(context) + : STextStyles.itemSubtitle12(context), + ), + const SizedBox(height: 4), + Text( + model.ticketId ?? "N/A", + 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..5f844f672a --- /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( + "Order 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( + "Ticket 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 ticket", + 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..c2726bb4a9 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_payment_view.dart @@ -0,0 +1,821 @@ +import 'dart:async'; + +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/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/qr.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).popUntil((route) { + if (route.settings.name == ShopInBitTicketsView.routeName) { + return true; + } + if (route.isFirst) { + return true; + } + return false; + }); + } + + 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), + ), + ); + } + } + + 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; + 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); + } + } + + const loadingOverlay = Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + + final methodSelector = 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( + "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), + if (!_isExpiredOrInvalid) ...[ + methodSelector, + SizedBox(height: isDesktop ? 24 : 16), + if (_currentAddress.isNotEmpty) + Center( + child: QR(data: _currentAddress, size: isDesktop ? 200 : 180), + ), + if (_currentAddress.isEmpty) + 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), + ), + ), + ], + ), + ], + ), + ), + ), + ], + 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: "."), + ], + ), + ), + ), + ], + ), + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + PrimaryButton( + label: hasWallets ? "PAY NOW" : "CHECK FOR PAYMENT", + enabled: _payNowEnabled, + onPressed: _payNowEnabled + ? (hasWallets ? _confirmPayment : _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: 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..716b006356 --- /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..42cc4b8d72 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_settings_view.dart @@ -0,0 +1,472 @@ +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(); + + String? _currentKey; + bool _loading = false; + + @override + void initState() { + super.initState(); + _currentKey = ShopInBitService.instance.loadCustomerKey(); + } + + @override + void dispose() { + _manualKeyController.dispose(); + _manualKeyFocusNode.dispose(); + _verifyKeyController.dispose(); + _verifyKeyFocusNode.dispose(); + super.dispose(); + } + + 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), + ], + ), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart new file mode 100644 index 0000000000..7537efb859 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart @@ -0,0 +1,467 @@ +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; + + List> _countries = []; + String? _selectedCountryIso; + bool _loadingCountries = false; + + bool _submitting = false; + + bool get _canContinue => + !_submitting && + _nameController.text.trim().isNotEmpty && + _streetController.text.trim().isNotEmpty && + _cityController.text.trim().isNotEmpty && + _postalCodeController.text.trim().isNotEmpty && + _selectedCountryIso != null; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(); + _streetController = TextEditingController(); + _cityController = TextEditingController(); + _postalCodeController = TextEditingController(); + _nameFocusNode = FocusNode(); + _streetFocusNode = FocusNode(); + _cityFocusNode = FocusNode(); + _postalCodeFocusNode = FocusNode(); + + for (final node in [ + _nameFocusNode, + _streetFocusNode, + _cityFocusNode, + _postalCodeFocusNode, + ]) { + 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(); + 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(' ') : ''; + + final resp = await ShopInBitService.instance.client.submitAddress( + widget.model.apiTicketId, + shipping: Address( + firstName: firstName, + lastName: lastName, + street: street, + zip: postalCode, + city: city, + country: country, + ), + ); + + if (resp.hasError) { + // Address submission may fail in sandbox (pricing not calculated). + // Log but proceed to payment. + 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), + ), + ), + ), + ), + 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..c7b35e49a4 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_1.dart @@ -0,0 +1,201 @@ +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_3.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(); + // Skip step 2 (category selection): only concierge is available initially + widget.model.category = ShopInBitCategory.concierge; + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + builder: (_) => ShopInBitStep3(model: widget.model), + ); + } else { + Navigator.of( + context, + ).pushNamed(ShopInBitStep3.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..c7aaf82133 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_2.dart @@ -0,0 +1,242 @@ +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 '../exchange_view/sub_widgets/step_row.dart'; +import 'shopinbit_step_3.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(); + _selected = widget.model.category; + } + + void _continue() { + widget.model.category = _selected; + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + builder: (_) => ShopInBitStep3(model: 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()!.accentColorBlue + : Theme.of(context).extension()!.background, + width: 2, + ), + color: Theme.of(context).extension()!.popupBG, + ), + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Row( + children: [ + SvgPicture.asset( + iconAsset, + width: isDesktop ? 32 : 24, + height: isDesktop ? 32 : 24, + ), + 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()!.accentColorBlue, + 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: 580, + 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_3.dart b/lib/pages/shopinbit/shopinbit_step_3.dart new file mode 100644 index 0000000000..13aa38d999 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_3.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; + +import '../../models/shopinbit/shopinbit_order_model.dart'; +import '../../themes/stack_colors.dart'; +import '../../utilities/text_styles.dart'; +import '../../utilities/util.dart'; +import '../../widgets/background.dart'; +import '../../widgets/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_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 { + 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 _continue() { + widget.model.guidelinesAccepted = true; + if (Util.isDesktop) { + Navigator.of(context, rootNavigator: true).pop(); + showDialog( + context: context, + 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) + : STextStyles.itemSubtitle12(context).copyWith( + color: Theme.of( + context, + ).extension()!.textSubtitle1, + ), + ), + ), + ), + ), + SizedBox(height: isDesktop ? 24 : 16), + PrimaryButton(label: "Next", onPressed: _continue), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 650, + 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_4.dart b/lib/pages/shopinbit/shopinbit_step_4.dart new file mode 100644 index 0000000000..44e17d2fa3 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_step_4.dart @@ -0,0 +1,604 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.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/stack_text_field.dart'; +import '../exchange_view/sub_widgets/step_row.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 { + late final TextEditingController _descriptionController; + late final FocusNode _descriptionFocusNode; + final TextEditingController _countrySearchController = + TextEditingController(); + + 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 _canContinue => + !_submitting && + _privacyAccepted && + _descriptionController.text.trim().isNotEmpty && + _selectedCountryIso != null; + + @override + void initState() { + super.initState(); + _descriptionController = TextEditingController( + text: widget.model.requestDescription, + ); + _descriptionFocusNode = FocusNode(); + _descriptionFocusNode.addListener(() => setState(() {})); + if (widget.model.deliveryCountry.isNotEmpty) { + _selectedCountryIso = widget.model.deliveryCountry; + } + _fetchCountries(); + } + + @override + void dispose() { + _descriptionController.dispose(); + _descriptionFocusNode.dispose(); + _countrySearchController.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 _submit() async { + widget.model.requestDescription = _descriptionController.text.trim(); + 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(); + + final categoryStr = switch (widget.model.category) { + ShopInBitCategory.concierge => "concierge", + ShopInBitCategory.travel => "travel", + ShopInBitCategory.car => "car", + null => "concierge", + }; + + 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); + } + } + + @override + Widget build(BuildContext context) { + final isDesktop = Util.isDesktop; + + final content = 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( + "Describe your request", + style: isDesktop + ? STextStyles.desktopH2(context) + : STextStyles.pageTitleH1(context), + ), + SizedBox(height: isDesktop ? 16 : 8), + Text( + "Provide details about what you'd like to purchase.", + 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( + "What would you like to purchase?", + _descriptionFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + filled: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + SizedBox(height: isDesktop ? 24 : 16), + 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), + ), + ), + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + 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: "."), + ], + ), + ), + ), + ], + ), + ), + ), + SizedBox(height: isDesktop ? 16 : 12), + PrimaryButton( + label: _submitting ? "Submitting..." : "Submit request", + enabled: _canContinue, + onPressed: _canContinue ? _submit : null, + ), + ], + ); + + if (isDesktop) { + return DesktopDialog( + maxWidth: 580, + maxHeight: 560, + 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_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart new file mode 100644 index 0000000000..e5c13c465d --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -0,0 +1,515 @@ +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(); + } + + Future _loadFromApi() async { + setState(() => _loading = true); + try { + final client = ShopInBitService.instance.client; + final id = widget.model.apiTicketId; + + final messagesResp = await client.getMessages(id); + final statusResp = await client.getTicketStatus(id); + + if (!messagesResp.hasError && messagesResp.value != null) { + final apiMessages = messagesResp.value!; + widget.model.clearMessages(); + for (final m in apiMessages) { + widget.model.addMessage( + ShopInBitMessage( + text: m.content, + timestamp: m.timestamp, + isFromUser: !m.fromAgent, + ), + ); + } + } + + if (!statusResp.hasError && statusResp.value != null) { + widget.model.status = ShopInBitOrderModel.statusFromTicketState( + statusResp.value!.state, + ); + } + + 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 ?? "Ticket", + 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 body = Column( + children: [ + statusBar, + offerBanner, + 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("Ticket", 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 ?? "Ticket", + 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..09b67d81c9 --- /dev/null +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -0,0 +1,337 @@ +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; + } + + 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 tickets..." : "No tickets 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 tickets", + 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 tickets", 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..8573b2a925 100644 --- a/lib/pages/wallet_view/wallet_view.dart +++ b/lib/pages/wallet_view/wallet_view.dart @@ -109,6 +109,7 @@ 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/services_view.dart'; import '../token_view/my_tokens_view.dart'; import 'sub_widgets/transactions_list.dart'; import 'sub_widgets/wallet_summary.dart'; @@ -1343,6 +1344,23 @@ 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, + ); + }, + ), ], ), ), 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..1915691aa7 --- /dev/null +++ b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_gift_cards_view.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +import '../../../themes/stack_colors.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/rounded_white_container.dart'; + +class DesktopGiftCardsView extends StatelessWidget { + const DesktopGiftCardsView({super.key}); + + static const String routeName = "/desktopGiftCardsView"; + + @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.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, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} 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..b433326b9d --- /dev/null +++ b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_services_view.dart @@ -0,0 +1,130 @@ +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_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..5ee6bf430f --- /dev/null +++ b/lib/pages_desktop_specific/more_view/sub_widgets/desktop_shopinbit_view.dart @@ -0,0 +1,318 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../app_config.dart'; +import '../../../db/isar/main_db.dart'; +import '../../../models/shopinbit/shopinbit_order_model.dart'; +import '../../../pages/shopinbit/shopinbit_step_1.dart'; +import '../../../pages/shopinbit/shopinbit_tickets_view.dart'; +import '../../../providers/desktop/current_desktop_menu_item.dart'; +import '../../../utilities/assets.dart'; +import '../../../utilities/text_styles.dart'; +import '../../../widgets/desktop/desktop_dialog.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) { + showDialog( + context: context, + barrierDismissible: true, + 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", + // "\n\nBy continuing, you agree to the ShopInBit ", + ), + // TextSpan( + // text: "Privacy Policy", + // style: STextStyles.richLink(dialogContext).copyWith( + // fontSize: 18, + // ), + // 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 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, + builder: (_) => + ShopInBitStep1(model: ShopInBitOrderModel()), + ); + 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\nConcierge shopping service. Purchase " + "products and services using cryptocurrency.\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 tickets ($count)" + : "My tickets", + 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; + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} 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..27f816ea41 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..1d39a5d966 --- /dev/null +++ b/lib/pages_desktop_specific/settings/settings_menu/shopinbit_settings.dart @@ -0,0 +1,474 @@ +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(); + + String? _currentKey; + bool _loading = false; + + @override + void initState() { + super.initState(); + _currentKey = ShopInBitService.instance.loadCustomerKey(); + } + + @override + void dispose() { + _manualKeyController.dispose(); + _manualKeyFocusNode.dispose(); + _verifyKeyController.dispose(); + _verifyKeyFocusNode.dispose(); + super.dispose(); + } + + 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 conversations. 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 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 Padding( + padding: EdgeInsets.all(10.0), + child: Divider(thickness: 0.5), + ), + 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, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/route_generator.dart b/lib/route_generator.dart index cad05cbcdb..de60755664 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,8 @@ 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/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 +173,19 @@ 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_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_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 +221,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,9 +252,11 @@ 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 'utilities/amount/amount.dart'; @@ -1029,6 +1056,150 @@ 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 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 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 +2503,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 +2654,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/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..75ec3c014b --- /dev/null +++ b/lib/services/shopinbit/shopinbit_service.dart @@ -0,0 +1,84 @@ +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; + + ShopInBitClient get client { + return _client ??= ShopInBitClient( + accessKey: kShopInBitAccessKey, + partnerSecret: kShopInBitPartnerSecret, + sandbox: true, + ); + } + + 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"); + } +} 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..5e8c0ff1ec --- /dev/null +++ b/lib/services/shopinbit/src/client.dart @@ -0,0 +1,651 @@ +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()}, + 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}, + 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.sh b/scripts/prebuild.sh index 44d4e0921c..c1bb5bc2f6 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 = "";\n' > $KEYS fi # Create template wallet test parameter files if they don't already exist