diff --git a/.fvmrc b/.fvmrc index b987073ac..f62032637 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.29.3" + "flutter": "3.38.5" } \ No newline at end of file diff --git a/.github/workflows/analyze_and_test.yml b/.github/workflows/analyze_and_test.yml index 5a816e10b..3c5756375 100644 --- a/.github/workflows/analyze_and_test.yml +++ b/.github/workflows/analyze_and_test.yml @@ -15,14 +15,14 @@ jobs: - name: Install FVM run: | - curl -fsSL https://fvm.app/install.sh | bash - echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH + curl -fsSL https://fvm.app/install.sh | bash -s -- 4.0.5 + echo "$HOME/fvm/bin" >> $GITHUB_PATH - run: make fvm-check - run: make clean - run: make deps - run: make build-runner - - run: make l10n + - run: make translations - name: Create .env file run: | diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..30225eb7c --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,28 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +permissions: + id-token: write # needed for OIDC + contents: write # so it can write the repo + issues: write # so it can comment on issues + pull-requests: write # so it can comment on PRs + +jobs: + claude: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 # Fetch full history for branch operations + persist-credentials: true # Keep credentials for subsequent git commands + + - uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--model claude-opus-4-5-20251101" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9fd3ef536..da09ea4b8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -134,6 +134,11 @@ In this project, BLoCs/Cubits are used for state management, but other solutions - The **domain and presentation layer should use entities**, not models. - Only the **data layer should use models**, and transform them to entities. +### Rules for AI: +- Always follow the architecture of the project when developing features. +- Always use colors from the theme; never use raw colors. When the user explains something using raw color language; find the closest color in the theme and based on the application use a color from the theme that would make sense in dark mode too but do not try to change the color theme file by yourself ever. +- + ### Further reading You can read more about Clean Architecture principles applied to Flutter in the following articles: diff --git a/README.md b/README.md index 37099ef7b..9a829efec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Analyze](https://github.com/SatoshiPortal/bullbitcoin-mobile/actions/workflows/analyze.yml/badge.svg)](https://github.com/SatoshiPortal/bullbitcoin-mobile/actions/workflows/analyze.yml) [![Build](https://github.com/SatoshiPortal/bullbitcoin-mobile/actions/workflows/build.yml/badge.svg)](https://github.com/SatoshiPortal/bullbitcoin-mobile/actions/workflows/build.yml) +[![Analyze and Test](https://github.com/SatoshiPortal/bullbitcoin-mobile/actions/workflows/analyze_and_test.yml/badge.svg?branch=develop)](https://github.com/SatoshiPortal/bullbitcoin-mobile/actions/workflows/analyze_and_test.yml) # About BULL Wallet @@ -16,22 +16,13 @@ At launch, two wallets are generated: the Secure Bitcoin Wallet and the Instant Both these wallets are able to send and receive Lightning Network payments via the swap provider. -## Core dependencies +### Translations -- [bdk](https://github.com/bitcoindevkit/bdk) -- [bdk-flutter](https://github.com/LtbLightning/bdk-flutter) -- [lwk](https://github.com/Blockstream/lwk) -- [lwk-dart](https://github.com/SatoshiPortal/lwk-dart) -- [boltz-rust](https://github.com/SatoshiPortal/boltz-rust) -- [boltz-dart](https://github.com/SatoshiPortal/boltz-dart) +[![Translation status](https://hosted.weblate.org/widget/bull/open-graph.png)](https://hosted.weblate.org/engage/bull/) -## Default external service providers +### [Dependencies](https://github.com/SatoshiPortal/bullbitcoin-mobile/blob/develop/pubspec.yaml#L9) -- mempool.space API for fee estimates -- mempool.bullbitcoin.com / mempool.space for transaction and address explorer -- bullbitcoin.com API for fiat prices -- bullbitcoin.com and blockstream.info electrum servers for blockchain data -- boltz.exchange for swap services +### [Default service providers](https://github.com/SatoshiPortal/bullbitcoin-mobile/blob/develop/lib/core/utils/constants.dart#L60) ### General features @@ -68,8 +59,7 @@ We try to always use a wallet that is the same network as the recipient: if reci Automated selection of the wallet can be overridden by the user at any time. This will most likely trigger a warning that the user can choose to ignore. -### Wallet security - +## Wallet security - An optional PIN from 4 to 8 digits can be set for access to the app. - The PIN is optional to prevent users from being accidentally locked out of a wallet without having first performed a backup. - Private keys are stored in secure storage and only accessed via the application when signing transactions, viewing the wallet’s private keys for back-up (mnemonic or xpriv). This prevents malicious applications from accessing the private keys. @@ -106,36 +96,16 @@ When installing the BULL Wallet app, a self-custodial wallet will be created, re When spending or selling Bitcoin, the exchange will create a payment invoice (BIP21) that will automatically be opened by the same application. All the user has to do is to confirm or reject that transaction. The experience will be functionally the same as that of a custodial exchange, with the exception that the user will have to do a backup of the Bitcoin wallet. -## Current roadmap - -Suggestion to this roadmap can be proposed as Github issues. - -- [x] Bumping replace-by-fee transactions -- [ ] Re-implement smarter coin selection and labelling -- [x] One mnemonic: new wallets are always created as a BIP39 passphrase -- [x] Good UX/UI for creating PSBTs from watch-only wallets -- [x] Good UX/UI for decoding and broadcasting PSBTs -- [x] Better UX/UI for importing watch-only wallets -- [x] Integration of Coinkite's BBQR library to export public keys, export PSBTs and import PSBTs -- [x] Bitcoin <> Liquid network swaps (depends on Boltz backend update) -- [ ] Integrate a client-side passphrase strength estimator -- [x] Encrypted cloud backups connected to a key server: RecoverBull -- [ ] Store persistent encrypted wallet backup on device -- [ ] Biometric authentication -- [ ] Show fiat value of transactions at the approximated time they were made -- [ ] Spanish and French translations -- [x] Payjoin integration -- [ ] Integrate Bull Bitcoin Fee multiple -- [x] Auto-consolidation mode for spend -- [ ] Small UTXO warning and consolidation suggestions -- [ ] Configurable mempool explorer URLs -- [ ] Configurable swap provider (similar to Electrum server) +## Roadmap + +The roadmap is based on the Github issues. You can suggest your own ideas by creating a new issue. The issues are then prioritized by the maintainers according to their importance and feasibility. ## Acknowledgements -- The project is entirely financed by bullbitcoin.com +- The project is entirely financed by [bullbitcoin.com](https://bullbitcoin.com) - Created by Francis Pouliot and Vishal Menon -- Main developers: Vishal, Morteza and Sai +- Maintainers: [i5hi](https://github.com/i5hi), [mocodesmo](https://github.com/mocodesmo), [ethicnology](https://github.com/ethicnology), [kumulynja](https://github.com/kumulynja), [basantagoswami](https://github.com/basantagoswami) and Q&A [kiranmetri](https://github.com/kiranmetri) +- Thanks to all [contributors](https://github.com/SatoshiPortal/bullbitcoin-mobile/graphs/contributors) - Thanks to Raj for his work on Boltz-rust - Thanks to the BDK team: BitcoinZavior and ThunderBiscuit - Eternal gratitude to the Boltz team Michael and Killian @@ -143,5 +113,3 @@ Suggestion to this roadmap can be proposed as Github issues. - Thanks to Blockstream for developing the Liquid Network ![image](https://github.com/BullishNode/bullbitcoin-mobile/assets/75800272/a61e4ccc-897d-410f-b97b-37a7c2b240cb) - -This project is tested with BrowserStack diff --git a/analysis_options.yaml b/analysis_options.yaml index b11676955..02291bcd2 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,18 +1,17 @@ -include: package:lint/analysis_options.yaml +include: package:lints/recommended.yaml linter: rules: avoid_classes_with_only_static_members: false - avoid_dynamic_calls: false - no_wildcard_variable_uses: false - unawaited_futures: true avoid_redundant_argument_values: false + avoid_dynamic_calls: false analyzer: + errors: + todo: ignore exclude: - lib/**/*.g.dart - lib/**/*.freezed.dart - - maintainance/** - pubspec.yaml diff --git a/android/app/build.gradle b/android/app/build.gradle index 5c475fe32..9bb9def6a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,7 +23,7 @@ def flutterVersionName = localProperties.getProperty('flutter.versionName') ?: ' android { - compileSdk = 35 + compileSdk = 36 ndkVersion = "29.0.13113456" compileOptions { @@ -43,7 +43,7 @@ android { applicationId = 'com.bullbitcoin.mobile' minSdkVersion 26 // TODO: targetSdkVersion flutter.targetSdkVersion - targetSdkVersion 35 + targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index be24835cb..5114c96da 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + diff --git a/android/build.gradle b/android/build.gradle index 8ab2d4467..6b31c8760 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -8,7 +8,7 @@ subprojects { if (project.plugins.hasPlugin("com.android.application") || project.plugins.hasPlugin("com.android.library")) { project.android { - compileSdk = 35 + compileSdk = 36 buildToolsVersion "35.0.0" } } diff --git a/integration_test/sqlite_transactions_test.dart b/integration_test/sqlite_transactions_test.dart index cfd2f3b59..0a148a999 100644 --- a/integration_test/sqlite_transactions_test.dart +++ b/integration_test/sqlite_transactions_test.dart @@ -1,11 +1,8 @@ -import 'dart:convert'; - import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_server_network.dart'; import 'package:bb_mobile/core/electrum/frameworks/drift/datasources/electrum_remote_datasource.dart'; import 'package:bb_mobile/core/electrum/frameworks/drift/models/electrum_server_model.dart'; import 'package:bb_mobile/core/storage/sqlite_database.dart'; -import 'package:bb_mobile/core/transaction/data/transaction_repository.dart'; -import 'package:bb_mobile/core/transaction/domain/entities/tx.dart'; +import 'package:bb_mobile/core/transactions/bitcoin_transaction_repository.dart'; import 'package:bb_mobile/core/utils/constants.dart'; import 'package:bb_mobile/locator.dart'; import 'package:bb_mobile/main.dart'; @@ -21,8 +18,9 @@ Future main({bool isInitialized = false}) async { url: ApiServiceConstants.bbElectrumUrl, network: ElectrumServerNetwork.bitcoinMainnet, ), + sqlite: sqlite, ); - final transactionRepository = TransactionRepository( + final transactionRepository = BitcoinTransactionRepository( electrumRemoteDatasource: electrumDatasource, ); @@ -33,47 +31,11 @@ Future main({bool isInitialized = false}) async { tearDownAll(() {}); group('Sqlite Integration Tests', () { - test('Fetch tx bytes from Electrum and store it into Sqlite', () async { - // Ensure the tx does not exists in sqlite - final sqliteTx = - await sqlite.managers.transactions - .filter((e) => e.txid(txid)) - .getSingleOrNull(); - expect(sqliteTx, isNull); - - // Fetch the transaction from electrum - final txBytes = await electrumDatasource.getTransaction(txid); - // Converts the bytes into entity - final txEntity = await RawBitcoinTxEntity.fromBytes(txBytes); - - // Store the transaction into sqlite - await sqlite.managers.transactions.create( - (t) => t( - txid: txEntity.txid, - version: txEntity.version, - size: txEntity.size.toString(), - vsize: txEntity.vsize.toString(), - locktime: txEntity.locktime, - vin: json.encode(txEntity.vin.map((e) => e.toJson()).toList()), - vout: json.encode(txEntity.vout.map((e) => e.toJson()).toList()), - ), - ); - - // Fetch the transaction locally from sqlite - final sqliteFetched = - await sqlite.managers.transactions - .filter((e) => e.txid(txid)) - .getSingleOrNull(); - expect(sqliteFetched, isNotNull); - expect(sqliteFetched!.txid, txid); - }); - test('Ensure the repository works', () async { // Ensure the tx does not exists in sqlite - var sqliteTx = - await sqlite.managers.transactions - .filter((e) => e.txid(txid)) - .getSingleOrNull(); + var sqliteTx = await sqlite.managers.transactions + .filter((e) => e.txid(txid)) + .getSingleOrNull(); expect(sqliteTx, isNull); // Fetch a transaction and cache it in sqlite if not present @@ -81,10 +43,9 @@ Future main({bool isInitialized = false}) async { expect(tx.txid, txid); // Ensure the tx is now stored in sqlite - sqliteTx = - await sqlite.managers.transactions - .filter((e) => e.txid(txid)) - .getSingleOrNull(); + sqliteTx = await sqlite.managers.transactions + .filter((e) => e.txid(tx.txid)) + .getSingleOrNull(); expect(sqliteTx, isNotNull); expect(tx.txid, sqliteTx!.txid); }); diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 7c5696400..163000d85 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 14.0 diff --git a/ios/Podfile b/ios/Podfile index 452978c7d..1fec6de18 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '13.0' +platform :ios, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -51,6 +51,7 @@ post_install do |installer| config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', 'PERMISSION_CAMERA=1', + 'PERMISSION_PHOTOS=1', ] end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d04b373cf..3a64874d3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -107,9 +107,9 @@ PODS: - Flutter - PromisesObjC (2.4.0) - ScreenProtectorKit (1.3.1) - - SDWebImage (5.21.4): - - SDWebImage/Core (= 5.21.4) - - SDWebImage/Core (5.21.4) + - SDWebImage (5.21.5): + - SDWebImage/Core (= 5.21.5) + - SDWebImage/Core (5.21.5) - share_plus (0.0.1): - Flutter - sqlite3 (3.51.1): @@ -150,7 +150,7 @@ PODS: - webview_flutter_wkwebview (0.0.1): - Flutter - FlutterMacOS - - workmanager (0.0.1): + - workmanager_apple (0.0.1): - Flutter DEPENDENCIES: @@ -181,7 +181,7 @@ DEPENDENCIES: - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - webview_cookie_manager (from `.symlinks/plugins/webview_cookie_manager/ios`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) - - workmanager (from `.symlinks/plugins/workmanager/ios`) + - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) SPEC REPOS: trunk: @@ -254,8 +254,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_cookie_manager/ios" webview_flutter_wkwebview: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" - workmanager: - :path: ".symlinks/plugins/workmanager/ios" + workmanager_apple: + :path: ".symlinks/plugins/workmanager_apple/ios" SPEC CHECKSUMS: AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 @@ -263,12 +263,12 @@ SPEC CHECKSUMS: ark_wallet: cb7a2af0c3a711d488709b7b91b6e639cfd441b1 bdk_flutter: cef9180019b4c6b67a3e3dfb74611683fe140107 boltz: 6388ec2412f3753b63a9e65c97f87ea26f43bddc - camera_avfoundation: be3be85408cd4126f250386828e9b1dfa40ab436 + camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 dart_bbqr: bfd89cc8a74538d94ef6d87d11e4a2ad55578e7d DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_nfc_kit: e1b71583eafd2c9650bc86844a7f2d185fb414f6 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 @@ -278,28 +278,28 @@ SPEC CHECKSUMS: GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e lwk: 22e06bc5664247d6b2dac91cfe209b63b70dd580 no_screenshot: 6d183496405a3ab709a67a54e5cd0f639e94729e package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 payjoin_flutter: 6397d7b698cdad6453be4949ab6aca1863f6c5e5 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 ScreenProtectorKit: 83a6281b02c7a5902ee6eac4f5045f674e902ae4 - SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf + SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 tor: 767208930250ef7be241963b75568c55c0a81890 universal_ble: ff19787898040d721109c6324472e5dd4bc86adc - url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b webview_cookie_manager: d63a76cabdf42a7ea3d92768ac67d4853a1367f8 - webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 - workmanager: b89e4e4445d8b57ee2fdbf1c3925696ebe5b8990 + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d + workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 -PODFILE CHECKSUM: 3402bb9f9a69470d12a30feec0fd288c203ab3f5 +PODFILE CHECKSUM: cc3fb9f3ad75d6f5db35855d6f129b8b2fb2d9f5 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 80f969456..20ec96b0a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -220,7 +220,7 @@ ORGANIZATIONNAME = ""; TargetAttributes = { 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 13.0; + CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; }; 97C146ED1CF9000F007C117D = { @@ -473,7 +473,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -496,7 +496,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BULL; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -610,7 +610,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -662,7 +662,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -687,7 +687,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BULL; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -716,7 +716,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BULL; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 30ceea5b1..bc109bc67 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,6 +1,6 @@ import Flutter import UIKit -import workmanager +import workmanager_apple @main @objc class AppDelegate: FlutterAppDelegate { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d9a2cb2fb..6f013b1cb 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -46,7 +46,7 @@ NSCameraUsageDescription This app needs access to your camera to scan QR codes NSPhotoLibraryUsageDescription - This app needs access to your files to export and import wallet files like psbts, xpubs and recovery files + This app needs access to your photos and files to export and import wallet files like psbts, xpubs and recovery files, and to attach files in support chat NSBluetoothPeripheralUsageDescription We need access to Bluetooth in order to connect to your hardware wallet when needed NSBluetoothAlwaysUsageDescription diff --git a/l10n.yaml b/l10n.yaml index b8d04410b..e82b3e01d 100644 --- a/l10n.yaml +++ b/l10n.yaml @@ -4,4 +4,3 @@ template-arb-file: app_en.arb output-localization-file: localization.dart untranslated-messages-file: untranslated-messages.txt nullable-getter: false -synthetic-package: false # needed after flutter_gen is deprecated: https://docs.flutter.dev/release/breaking-changes/flutter-generate-i10n-source diff --git a/lib/core/ark/usecases/create_ark_secret_usecase.dart b/lib/core/ark/usecases/create_ark_secret_usecase.dart index 57852e947..a90976312 100644 --- a/lib/core/ark/usecases/create_ark_secret_usecase.dart +++ b/lib/core/ark/usecases/create_ark_secret_usecase.dart @@ -42,7 +42,7 @@ class CreateArkSecretUsecase { // If a revoked derivation exists, reactivate it if (existingArkDerivation != null) { if (existingArkDerivation.status == Bip85Status.revoked) { - await _bip85Repository.reactivate(existingArkDerivation.path); + await _bip85Repository.activate(existingArkDerivation); final xprvBase58 = Bip32Derivation.getXprvFromSeed( defaultSeed.bytes, Network.bitcoinMainnet, diff --git a/lib/core/ark/usecases/revoke_ark_usecase.dart b/lib/core/ark/usecases/revoke_ark_usecase.dart index d18247c8e..dfb4b5fea 100644 --- a/lib/core/ark/usecases/revoke_ark_usecase.dart +++ b/lib/core/ark/usecases/revoke_ark_usecase.dart @@ -15,7 +15,7 @@ class RevokeArkUsecase { for (final derivation in derivations) { if (derivation.application == Bip85Application.hex && derivation.index == Ark.bip85Index) { - await _bip85Repository.revoke(derivation.path); + await _bip85Repository.revoke(derivation); break; } } diff --git a/lib/core/background_tasks/handler.dart b/lib/core/background_tasks/handler.dart index cd6a0f123..122140335 100644 --- a/lib/core/background_tasks/handler.dart +++ b/lib/core/background_tasks/handler.dart @@ -1,10 +1,10 @@ -import 'package:bb_mobile/core/background_tasks/locator.dart'; import 'package:bb_mobile/core/background_tasks/tasks.dart'; import 'package:bb_mobile/core/storage/sqlite_database.dart'; import 'package:bb_mobile/core/swaps/domain/usecases/restart_swap_watcher_usecase.dart'; import 'package:bb_mobile/core/utils/logger.dart' show log; import 'package:bb_mobile/core/wallet/domain/usecases/get_wallets_usecase.dart'; import 'package:bb_mobile/core/wallet/domain/usecases/sync_wallet_usecase.dart'; +import 'package:bb_mobile/locator.dart'; import 'package:bb_mobile/main.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get_it/get_it.dart'; @@ -31,7 +31,7 @@ Future tasksHandler(String task) async { await driftIsolate.connect(singleClientMode: true), ); final locator = GetIt.asNewInstance(); - await TaskLocator.setup(locator, sqlite); + await AppLocator.setup(locator, sqlite); final syncWalletUsecase = locator(); final getWalletsUsecase = locator(); diff --git a/lib/core/background_tasks/locator.dart b/lib/core/background_tasks/locator.dart deleted file mode 100644 index a1589327f..000000000 --- a/lib/core/background_tasks/locator.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:bb_mobile/core/ark/locator.dart'; -import 'package:bb_mobile/core/bip85/bip85_locator.dart'; -import 'package:bb_mobile/core/blockchain/blockchain_locator.dart'; -import 'package:bb_mobile/core/electrum/frameworks/di/electrum_locator.dart'; -import 'package:bb_mobile/core/exchange/exchange_locator.dart' as core; -import 'package:bb_mobile/core/fees/fees_locator.dart'; -import 'package:bb_mobile/core/payjoin/payjoin_locator.dart'; -import 'package:bb_mobile/core/recoverbull/recoverbull_locator.dart'; -import 'package:bb_mobile/core/seed/seed_locator.dart'; -import 'package:bb_mobile/core/settings/settings_locator.dart'; -import 'package:bb_mobile/core/status/status_locator.dart'; -import 'package:bb_mobile/core/storage/sqlite_database.dart'; -import 'package:bb_mobile/core/storage/storage_locator.dart'; -import 'package:bb_mobile/core/swaps/swaps_locator.dart'; -import 'package:bb_mobile/core/tor/tor_locator.dart'; -import 'package:bb_mobile/core/wallet/wallet_locator.dart'; -import 'package:bb_mobile/features/exchange/exchange_locator.dart' as features; -import 'package:get_it/get_it.dart'; - -class TaskLocator { - static Future setup( - GetIt backgroundLocator, - SqliteDatabase sqlite, - ) async { - backgroundLocator.enableRegisteringMultipleInstancesOfOneType(); - - registerDatabase(backgroundLocator, sqlite); - await registerDatasources(backgroundLocator); - registerPorts(backgroundLocator); - await registerRepositories(backgroundLocator); - registerServices(backgroundLocator); - registerUsecases(backgroundLocator); - registerFeatures(backgroundLocator); - } - - static void registerDatabase(GetIt backgroundLocator, SqliteDatabase sqlite) { - backgroundLocator.registerLazySingleton(() => sqlite); - } - - static Future registerDatasources(GetIt backgroundLocator) async { - BlockchainLocator.registerDatasources(backgroundLocator); - await ElectrumLocator.registerDatasources(backgroundLocator); - // SeedLocator.registerDatasources(); - await StorageLocator.registerDatasources(backgroundLocator); - await SwapsLocator.registerDatasources(backgroundLocator); - await WalletLocator.registerDatasources(backgroundLocator); - await SettingsLocator.registerDatasources(backgroundLocator); - FeesLocator.registerDatasources(backgroundLocator); - SeedLocator.registerDatasources(backgroundLocator); - core.ExchangeLocator.registerDatasources(backgroundLocator); - PayjoinLocator.registerDatasources(backgroundLocator); - await TorLocator.registerDatasources(backgroundLocator); - await RecoverbullLocator.registerDatasources(backgroundLocator); - Bip85DerivationsLocator.registerDatasources(backgroundLocator); - } - - static void registerPorts(GetIt backgroundLocator) { - ElectrumLocator.registerPorts(backgroundLocator); - BlockchainLocator.registerPorts(backgroundLocator); - SwapsLocator.registerPorts(backgroundLocator); - WalletLocator.registerPorts(backgroundLocator); - } - - static Future registerRepositories(GetIt backgroundLocator) async { - BlockchainLocator.registerRepositories(backgroundLocator); - ElectrumLocator.registerRepositories(backgroundLocator); - StorageLocator.registerRepositories(backgroundLocator); - await SettingsLocator.registerRepositories(backgroundLocator); - SwapsLocator.registerRepositories(backgroundLocator); - WalletLocator.registerRepositories(backgroundLocator); - FeesLocator.registerRepositories(backgroundLocator); - SeedLocator.registerRepositories(backgroundLocator); - core.ExchangeLocator.registerRepositories(backgroundLocator); - PayjoinLocator.registerRepositories(backgroundLocator); - await TorLocator.registerRepositories(backgroundLocator); - await RecoverbullLocator.registerRepositories(backgroundLocator); - Bip85DerivationsLocator.registerRepositories(backgroundLocator); - } - - static void registerServices(GetIt backgroundLocator) { - SwapsLocator.registerServices(backgroundLocator); - } - - static void registerUsecases(GetIt backgroundLocator) { - ElectrumLocator.registerUsecases(backgroundLocator); - BlockchainLocator.registerUsecases(backgroundLocator); - StorageLocator.registerUsecases(backgroundLocator); - SettingsLocator.registerUsecases(backgroundLocator); - SwapsLocator.registerUsecases(backgroundLocator); - WalletLocator.registerUsecases(backgroundLocator); - FeesLocator.registerUseCases(backgroundLocator); - SeedLocator.registerUsecases(backgroundLocator); - SeedLocator.registerServices(backgroundLocator); - core.ExchangeLocator.registerUseCases(backgroundLocator); - PayjoinLocator.registerUsecases(backgroundLocator); - TorLocator.registerUsecases(backgroundLocator); - RecoverbullLocator.registerUsecases(backgroundLocator); - Bip85DerivationsLocator.registerUsecases(backgroundLocator); - } - - static void registerFeatures(GetIt backgroundLocator) { - features.ExchangeLocator.setup(backgroundLocator); - ArkCoreLocator.setup(backgroundLocator); - StatusLocator.setup(backgroundLocator); - } -} diff --git a/lib/core/bbqr/bbqr.dart b/lib/core/bbqr/bbqr.dart index 677a5e594..f9c025bf6 100644 --- a/lib/core/bbqr/bbqr.dart +++ b/lib/core/bbqr/bbqr.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:bb_mobile/core/bbqr/bbqr_options.dart'; import 'package:bb_mobile/core/errors/bull_exception.dart'; -import 'package:bb_mobile/core/transaction/domain/entities/tx.dart'; +import 'package:bb_mobile/core/utils/bitcoin_tx.dart'; import 'package:bb_mobile/core/utils/logger.dart'; import 'package:bdk_flutter/bdk_flutter.dart' as bdk; import 'package:convert/convert.dart'; @@ -13,7 +13,7 @@ enum TxFormat { psbt, hex } class ScannedTransaction { final TxFormat format; final String data; - final RawBitcoinTxEntity tx; + final BitcoinTx tx; ScannedTransaction({ required this.format, @@ -33,7 +33,7 @@ class Bbqr { Future<(ScannedTransaction?, Bbqr)> scanTransaction(String payload) async { if (!BbqrOptions.isValid(payload)) { try { - final tx = await RawBitcoinTxEntity.fromBytes(hex.decode(payload)); + final tx = await BitcoinTx.fromBytes(hex.decode(payload)); return ( ScannedTransaction(format: TxFormat.hex, data: payload, tx: tx), this, @@ -59,7 +59,7 @@ class Bbqr { final bbqrJoiner = await bbqr.Joined.tryFromParts(parts: bbqrParts); try { - final tx = await RawBitcoinTxEntity.fromBytes(bbqrJoiner.data); + final tx = await BitcoinTx.fromBytes(bbqrJoiner.data); return ( ScannedTransaction( format: TxFormat.hex, @@ -72,7 +72,7 @@ class Bbqr { try { final psbtBase64 = base64.encode(bbqrJoiner.data); - final tx = await RawBitcoinTxEntity.fromPsbt(psbtBase64); + final tx = await BitcoinTx.fromPsbt(psbtBase64); return ( ScannedTransaction(format: TxFormat.psbt, data: psbtBase64, tx: tx), this, @@ -96,7 +96,7 @@ class Bbqr { if (minSplitNumber < BigInt.from(1)) minSplitNumber = BigInt.from(1); final defaultOptions = await bbqr.SplitOptions.default_(); - final bbqrOptions = bbqr.SplitOptions.new( + final bbqrOptions = bbqr.SplitOptions( minVersion: defaultOptions.minVersion, maxVersion: defaultOptions.maxVersion, encoding: defaultOptions.encoding, diff --git a/lib/core/bip85/bip85_locator.dart b/lib/core/bip85/bip85_locator.dart index 7f65adce4..29204ffcb 100644 --- a/lib/core/bip85/bip85_locator.dart +++ b/lib/core/bip85/bip85_locator.dart @@ -1,8 +1,11 @@ import 'package:bb_mobile/core/bip85/data/bip85_datasource.dart'; import 'package:bb_mobile/core/bip85/data/bip85_repository.dart'; +import 'package:bb_mobile/core/bip85/domain/activate_bip85_derivation_usecase.dart'; +import 'package:bb_mobile/core/bip85/domain/alias_bip85_derivation_usecase.dart'; import 'package:bb_mobile/core/bip85/domain/derive_next_bip85_hex_from_default_wallet_usecase.dart'; import 'package:bb_mobile/core/bip85/domain/derive_next_bip85_mnemonic_from_default_wallet_usecase.dart'; import 'package:bb_mobile/core/bip85/domain/fetch_all_derivations_usecase.dart'; +import 'package:bb_mobile/core/bip85/domain/revoke_bip85_derivation_usecase.dart'; import 'package:bb_mobile/core/seed/data/repository/seed_repository.dart'; import 'package:bb_mobile/core/storage/sqlite_database.dart'; import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart'; @@ -43,5 +46,20 @@ class Bip85DerivationsLocator { bip85Repository: locator(), ), ); + locator.registerFactory( + () => AliasBip85DerivationUsecase( + bip85Repository: locator(), + ), + ); + locator.registerFactory( + () => RevokeBip85DerivationUsecase( + bip85Repository: locator(), + ), + ); + locator.registerFactory( + () => ActivateBip85DerivationUsecase( + bip85Repository: locator(), + ), + ); } } diff --git a/lib/core/bip85/data/bip85_datasource.dart b/lib/core/bip85/data/bip85_datasource.dart index bd7a002b5..c17a22f62 100644 --- a/lib/core/bip85/data/bip85_datasource.dart +++ b/lib/core/bip85/data/bip85_datasource.dart @@ -88,10 +88,9 @@ class Bip85Datasource { } Future fetch(String path) async { - final row = - await _sqlite.managers.bip85Derivations - .filter((b) => b.path(path)) - .getSingleOrNull(); + final row = await _sqlite.managers.bip85Derivations + .filter((b) => b.path(path)) + .getSingleOrNull(); return row != null ? Bip85DerivationModel.fromSqlite(row) : null; } @@ -99,13 +98,13 @@ class Bip85Datasource { Future fetchNextIndexForApplication( Bip85ApplicationColumn application, ) async { - final rows = - await _sqlite.managers.bip85Derivations - .filter((b) => b.application(application)) - .get(); + final rows = await _sqlite.managers.bip85Derivations + .filter((b) => b.application(application)) + .get(); - final models = - rows.map((row) => Bip85DerivationModel.fromSqlite(row)).toList(); + final models = rows + .map((row) => Bip85DerivationModel.fromSqlite(row)) + .toList(); int nextIndex = 0; for (final model in models) { @@ -134,7 +133,7 @@ class Bip85Datasource { } } - Future reactivate(String path) async { + Future activate(String path) async { try { await _sqlite.managers.bip85Derivations .filter((b) => b.path(path)) @@ -144,6 +143,16 @@ class Bip85Datasource { } } + Future alias(String path, String alias) async { + try { + await _sqlite.managers.bip85Derivations + .filter((b) => b.path(path)) + .update((b) => b(alias: Value(alias))); + } catch (e) { + rethrow; + } + } + // We should not use _store without properly formatting the derivation path. Future _store(Bip85DerivationModel bip85) async { try { diff --git a/lib/core/bip85/data/bip85_repository.dart b/lib/core/bip85/data/bip85_repository.dart index 6060355fe..82b8fae8b 100644 --- a/lib/core/bip85/data/bip85_repository.dart +++ b/lib/core/bip85/data/bip85_repository.dart @@ -67,17 +67,25 @@ class Bip85Repository { } } - Future revoke(String path) async { + Future revoke(Bip85DerivationEntity derivation) async { try { - await _datasource.revoke(path); + await _datasource.revoke(derivation.path); } catch (e) { rethrow; } } - Future reactivate(String path) async { + Future activate(Bip85DerivationEntity derivation) async { try { - await _datasource.reactivate(path); + await _datasource.activate(derivation.path); + } catch (e) { + rethrow; + } + } + + Future alias(Bip85DerivationEntity derivation, String alias) async { + try { + await _datasource.alias(derivation.path, alias); } catch (e) { rethrow; } diff --git a/lib/core/bip85/domain/activate_bip85_derivation_usecase.dart b/lib/core/bip85/domain/activate_bip85_derivation_usecase.dart new file mode 100644 index 000000000..52188440a --- /dev/null +++ b/lib/core/bip85/domain/activate_bip85_derivation_usecase.dart @@ -0,0 +1,17 @@ +import 'package:bb_mobile/core/bip85/data/bip85_repository.dart'; +import 'package:bb_mobile/core/bip85/domain/bip85_derivation_entity.dart'; + +class ActivateBip85DerivationUsecase { + final Bip85Repository _bip85Repository; + + ActivateBip85DerivationUsecase({required Bip85Repository bip85Repository}) + : _bip85Repository = bip85Repository; + + Future execute(Bip85DerivationEntity derivation) async { + try { + await _bip85Repository.activate(derivation); + } catch (e) { + rethrow; + } + } +} diff --git a/lib/core/bip85/domain/alias_bip85_derivation_usecase.dart b/lib/core/bip85/domain/alias_bip85_derivation_usecase.dart new file mode 100644 index 000000000..25d73abaa --- /dev/null +++ b/lib/core/bip85/domain/alias_bip85_derivation_usecase.dart @@ -0,0 +1,16 @@ +import 'package:bb_mobile/core/bip85/data/bip85_repository.dart'; +import 'package:bb_mobile/core/bip85/domain/bip85_derivation_entity.dart'; + +class AliasBip85DerivationUsecase { + final Bip85Repository _bip85Repository; + + AliasBip85DerivationUsecase({required Bip85Repository bip85Repository}) + : _bip85Repository = bip85Repository; + + Future execute({ + required Bip85DerivationEntity derivation, + required String alias, + }) async { + await _bip85Repository.alias(derivation, alias); + } +} diff --git a/lib/core/bip85/domain/revoke_bip85_derivation_usecase.dart b/lib/core/bip85/domain/revoke_bip85_derivation_usecase.dart new file mode 100644 index 000000000..f3690bb1c --- /dev/null +++ b/lib/core/bip85/domain/revoke_bip85_derivation_usecase.dart @@ -0,0 +1,17 @@ +import 'package:bb_mobile/core/bip85/data/bip85_repository.dart'; +import 'package:bb_mobile/core/bip85/domain/bip85_derivation_entity.dart'; + +class RevokeBip85DerivationUsecase { + final Bip85Repository _bip85Repository; + + RevokeBip85DerivationUsecase({required Bip85Repository bip85Repository}) + : _bip85Repository = bip85Repository; + + Future execute(Bip85DerivationEntity derivation) async { + try { + await _bip85Repository.revoke(derivation); + } catch (e) { + rethrow; + } + } +} diff --git a/lib/core/bitbox/bitbox_locator.dart b/lib/core/bitbox/bitbox_locator.dart index 5a9925d72..2b5e8ac76 100644 --- a/lib/core/bitbox/bitbox_locator.dart +++ b/lib/core/bitbox/bitbox_locator.dart @@ -9,16 +9,16 @@ import 'package:bb_mobile/core/bitbox/domain/usecases/sign_psbt_bitbox_usecase.d import 'package:bb_mobile/core/bitbox/domain/usecases/unlock_bitbox_device_usecase.dart'; import 'package:bb_mobile/core/bitbox/domain/usecases/verify_address_bitbox_usecase.dart'; import 'package:bb_mobile/core/settings/data/settings_repository.dart'; -import 'package:bb_mobile/locator.dart'; +import 'package:get_it/get_it.dart'; class BitBoxCoreLocator { - static void registerDatasources() { + static void registerDatasources(GetIt locator) { locator.registerLazySingleton( () => BitBoxDeviceDatasource(), ); } - static void registerRepositories() { + static void registerRepositories(GetIt locator) { locator.registerLazySingleton( () => BitBoxDeviceRepositoryImpl( datasource: locator(), @@ -26,7 +26,7 @@ class BitBoxCoreLocator { ); } - static void registerUsecases() { + static void registerUsecases(GetIt locator) { locator.registerLazySingleton( () => ScanBitBoxDevicesUsecase( repository: locator(), diff --git a/lib/core/bitbox/domain/errors/bitbox_errors.dart b/lib/core/bitbox/domain/errors/bitbox_errors.dart index bc5f3c077..2250b2236 100644 --- a/lib/core/bitbox/domain/errors/bitbox_errors.dart +++ b/lib/core/bitbox/domain/errors/bitbox_errors.dart @@ -1,4 +1,6 @@ // lib/core/bitbox/domain/errors/bitbox_errors.dart +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'bitbox_errors.freezed.dart'; @@ -23,21 +25,23 @@ sealed class BitBoxError with _$BitBoxError { const BitBoxError._(); - String get message => when( - permissionDenied: () => 'USB permissions are required to connect to BitBox devices.', - noDevicesFound: () => 'No BitBox devices found. Make sure your device is powered on and connected via USB.', - multipleDevicesFound: () => 'Multiple BitBox devices found. Please ensure only one device is connected.', - deviceNotFound: () => 'BitBox device not found.', - connectionTypeNotInitialized: () => 'Connection type not initialized.', - noActiveConnection: () => 'No active connection to BitBox device.', - deviceMismatch: () => 'Device mismatch detected.', - invalidMagicBytes: () => 'Invalid PSBT format detected.', - deviceNotPaired: () => 'Device not paired. Please complete the pairing process first.', - handshakeFailed: () => 'Failed to establish secure connection. Please try again.', - operationTimeout: () => 'Operation timed out. Please try again.', - connectionFailed: () => 'Failed to connect to BitBox device. Please check your connection.', - invalidResponse: () => 'Invalid response from BitBox device. Please try again.', - operationCancelled: () => 'Operation was cancelled. Please try again.', + /// Returns the localized error message. + String toTranslated(BuildContext context) => when( + permissionDenied: () => context.loc.bitboxErrorPermissionDenied, + noDevicesFound: () => context.loc.bitboxErrorNoDevicesFound, + multipleDevicesFound: () => context.loc.bitboxErrorMultipleDevicesFound, + deviceNotFound: () => context.loc.bitboxErrorDeviceNotFound, + connectionTypeNotInitialized: () => + context.loc.bitboxErrorConnectionTypeNotInitialized, + noActiveConnection: () => context.loc.bitboxErrorNoActiveConnection, + deviceMismatch: () => context.loc.bitboxErrorDeviceMismatch, + invalidMagicBytes: () => context.loc.bitboxErrorInvalidMagicBytes, + deviceNotPaired: () => context.loc.bitboxErrorDeviceNotPaired, + handshakeFailed: () => context.loc.bitboxErrorHandshakeFailed, + operationTimeout: () => context.loc.bitboxErrorOperationTimeout, + connectionFailed: () => context.loc.bitboxErrorConnectionFailed, + invalidResponse: () => context.loc.bitboxErrorInvalidResponse, + operationCancelled: () => context.loc.bitboxErrorOperationCancelled, operationFailed: (msg) => msg, ); } diff --git a/lib/core/core_locator.dart b/lib/core/core_locator.dart index 70a1fbc8c..ab4e62c5e 100644 --- a/lib/core/core_locator.dart +++ b/lib/core/core_locator.dart @@ -6,6 +6,7 @@ import 'package:bb_mobile/core/exchange/exchange_locator.dart'; import 'package:bb_mobile/core/fees/fees_locator.dart'; import 'package:bb_mobile/core/labels/labels_locator.dart'; import 'package:bb_mobile/core/ledger/ledger_locator.dart'; +import 'package:bb_mobile/core/mempool/mempool_locator.dart'; import 'package:bb_mobile/core/payjoin/payjoin_locator.dart'; import 'package:bb_mobile/core/recoverbull/recoverbull_locator.dart'; import 'package:bb_mobile/core/seed/seed_locator.dart'; @@ -15,46 +16,49 @@ import 'package:bb_mobile/core/storage/storage_locator.dart'; import 'package:bb_mobile/core/swaps/swaps_locator.dart'; import 'package:bb_mobile/core/tor/tor_locator.dart'; import 'package:bb_mobile/core/wallet/wallet_locator.dart'; -import 'package:bb_mobile/locator.dart'; +import 'package:get_it/get_it.dart'; class CoreLocator { - static void register() { - locator.registerLazySingleton(() => SqliteDatabase()); + static void register(GetIt locator, SqliteDatabase database) { + locator.registerLazySingleton(() => database); } - static Future registerDatasources() async { + static Future registerDatasources(GetIt locator) async { + LabelsLocator.registerDatasources(locator); await TorLocator.registerDatasources(locator); BlockchainLocator.registerDatasources(locator); await ElectrumLocator.registerDatasources(locator); ExchangeLocator.registerDatasources(locator); FeesLocator.registerDatasources(locator); + await MempoolLocator.registerDatasources(locator); PayjoinLocator.registerDatasources(locator); await RecoverbullLocator.registerDatasources(locator); SeedLocator.registerDatasources(locator); await StorageLocator.registerDatasources(locator); await SwapsLocator.registerDatasources(locator); await WalletLocator.registerDatasources(locator); - LabelsLocator.registerDatasources(); await SettingsLocator.registerDatasources(locator); Bip85DerivationsLocator.registerDatasources(locator); - LedgerLocator.registerDatasources(); - BitBoxCoreLocator.registerDatasources(); + LedgerLocator.registerDatasources(locator); + BitBoxCoreLocator.registerDatasources(locator); } - static void registerPorts() { + static void registerPorts(GetIt locator) { ElectrumLocator.registerPorts(locator); BlockchainLocator.registerPorts(locator); + MempoolLocator.registerPorts(locator); SwapsLocator.registerPorts(locator); WalletLocator.registerPorts(locator); } - static Future registerRepositories() async { + static Future registerRepositories(GetIt locator) async { + LabelsLocator.registerRepositories(locator); await TorLocator.registerRepositories(locator); BlockchainLocator.registerRepositories(locator); ElectrumLocator.registerRepositories(locator); ExchangeLocator.registerRepositories(locator); FeesLocator.registerRepositories(locator); - LabelsLocator.registerRepositories(); + MempoolLocator.registerRepositories(locator); PayjoinLocator.registerRepositories(locator); SeedLocator.registerRepositories(locator); StorageLocator.registerRepositories(locator); @@ -63,21 +67,23 @@ class CoreLocator { SwapsLocator.registerRepositories(locator); WalletLocator.registerRepositories(locator); Bip85DerivationsLocator.registerRepositories(locator); - LedgerLocator.registerRepositories(); - BitBoxCoreLocator.registerRepositories(); + LedgerLocator.registerRepositories(locator); + BitBoxCoreLocator.registerRepositories(locator); } - static void registerServices() { + static void registerServices(GetIt locator) { + MempoolLocator.registerServices(locator); SeedLocator.registerServices(locator); SwapsLocator.registerServices(locator); } - static void registerUsecases() { + static void registerUsecases(GetIt locator) { + LabelsLocator.registerUseCases(locator); ElectrumLocator.registerUsecases(locator); BlockchainLocator.registerUsecases(locator); ExchangeLocator.registerUseCases(locator); FeesLocator.registerUseCases(locator); - LabelsLocator.registerUseCases(); + MempoolLocator.registerUsecases(locator); PayjoinLocator.registerUsecases(locator); RecoverbullLocator.registerUsecases(locator); SeedLocator.registerUsecases(locator); @@ -87,7 +93,7 @@ class CoreLocator { TorLocator.registerUsecases(locator); WalletLocator.registerUsecases(locator); Bip85DerivationsLocator.registerUsecases(locator); - LedgerLocator.registerUsecases(); - BitBoxCoreLocator.registerUsecases(); + LedgerLocator.registerUsecases(locator); + BitBoxCoreLocator.registerUsecases(locator); } } diff --git a/lib/core/electrum/frameworks/drift/datasources/electrum_remote_datasource.dart b/lib/core/electrum/frameworks/drift/datasources/electrum_remote_datasource.dart index 12c6a7074..e5022c723 100644 --- a/lib/core/electrum/frameworks/drift/datasources/electrum_remote_datasource.dart +++ b/lib/core/electrum/frameworks/drift/datasources/electrum_remote_datasource.dart @@ -2,18 +2,49 @@ import 'dart:convert'; import 'dart:io' show SecureSocket; import 'package:bb_mobile/core/electrum/frameworks/drift/models/electrum_server_model.dart'; +import 'package:bb_mobile/core/storage/sqlite_database.dart'; +import 'package:bb_mobile/core/utils/bitcoin_tx.dart'; import 'package:convert/convert.dart'; class ElectrumRemoteDatasource { final ElectrumServerModel _server; + final SqliteDatabase _sqlite; late Uri _uri; - ElectrumRemoteDatasource({required ElectrumServerModel server}) - : _server = server { + ElectrumRemoteDatasource({ + required ElectrumServerModel server, + required SqliteDatabase sqlite, + }) : _server = server, + _sqlite = sqlite { _uri = Uri.parse(_server.url); } - Future> getTransaction(String txid) async { + Future fetch({required String txid}) async { + final cachedTransaction = await _sqlite.managers.transactions + .filter((e) => e.txid(txid)) + .getSingleOrNull(); + + if (cachedTransaction != null) return cachedTransaction; + + // If not found in cache, fetch from Electrum + final txBytes = await _getTransaction(txid); + final tx = await BitcoinTx.fromBytes(txBytes); + + final txModel = TransactionModel( + txid: tx.txid, + version: tx.version, + size: tx.size.toString(), + vsize: tx.vsize.toString(), + locktime: tx.locktime, + vin: json.encode(tx.vin.map((e) => e.toJson()).toList()), + vout: json.encode(tx.vout.map((e) => e.toJson()).toList()), + ); + await _sqlite.into(_sqlite.transactions).insert(txModel); + + return txModel; + } + + Future> _getTransaction(String txid) async { try { final socket = await SecureSocket.connect(_uri.host, _uri.port); diff --git a/lib/core/errors/autoswap_errors.dart b/lib/core/errors/autoswap_errors.dart index 7e6430def..bac429860 100644 --- a/lib/core/errors/autoswap_errors.dart +++ b/lib/core/errors/autoswap_errors.dart @@ -1,6 +1,7 @@ import 'package:bb_mobile/core/errors/bull_exception.dart'; import 'package:bb_mobile/core/settings/domain/settings_entity.dart'; import 'package:bb_mobile/core/utils/amount_conversions.dart'; +import 'package:bb_mobile/generated/l10n/localization.dart'; class MinimumAmountThresholdException extends BullException { final int minimumThresholdSats; @@ -11,12 +12,22 @@ class MinimumAmountThresholdException extends BullException { 'Minimum balance threshold is $minimumThresholdSats ${bitcoinUnit.code}', ); - String displayMessage() { + String displayMessage([AppLocalizations? loc]) { + if (loc == null) { + if (bitcoinUnit == BitcoinUnit.btc) { + final btcAmount = ConvertAmount.satsToBtc(minimumThresholdSats); + return 'Minimum balance threshold is $btcAmount BTC'; + } + return 'Minimum balance threshold is $minimumThresholdSats sats'; + } + if (bitcoinUnit == BitcoinUnit.btc) { final btcAmount = ConvertAmount.satsToBtc(minimumThresholdSats); - return 'Minimum balance threshold is $btcAmount BTC'; + return loc.autoswapMinimumThresholdErrorBtc(btcAmount.toString()); } - return 'Minimum balance threshold is $minimumThresholdSats sats'; + return loc.autoswapMinimumThresholdErrorSats( + minimumThresholdSats.toString(), + ); } } @@ -26,7 +37,12 @@ class MaximumFeeThresholdException extends BullException { MaximumFeeThresholdException(this.maximumThreshold) : super('Maximum fee threshold is $maximumThreshold%'); - String displayMessage() => 'Maximum fee threshold is $maximumThreshold%'; + String displayMessage([AppLocalizations? loc]) { + if (loc == null) { + return 'Maximum fee threshold is $maximumThreshold%'; + } + return loc.autoswapMaximumFeeError(maximumThreshold.toString()); + } } class AutoSwapProcessException extends BullException { diff --git a/lib/core/exchange/data/datasources/bullbitcoin_api_datasource.dart b/lib/core/exchange/data/datasources/bullbitcoin_api_datasource.dart index ec4823f3c..5964aa0b2 100644 --- a/lib/core/exchange/data/datasources/bullbitcoin_api_datasource.dart +++ b/lib/core/exchange/data/datasources/bullbitcoin_api_datasource.dart @@ -24,6 +24,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource { final _ordersPath = '/ak/api-orders'; final _orderTriggerPath = '/ak/api-ordertrigger'; final _recipientsPath = '/ak/api-recipients'; + final _messagesPath = '/ak/api-commcenter'; BullbitcoinApiDatasource({required Dio bullbitcoinApiHttpClient}) : _http = bullbitcoinApiHttpClient; @@ -93,8 +94,13 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource { throw 'Unable to fetch user summary from Bull Bitcoin API'; } + final result = resp.data['result']; + if (result == null) { + return null; + } + final userSummary = UserSummaryModel.fromJson( - resp.data['result'] as Map, + result as Map, ); return userSummary; @@ -583,6 +589,44 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource { return resp.data['result'] as Map; } + + Future>> listAnnouncements({ + required String apiKey, + }) async { + try { + final resp = await _http.post( + _messagesPath, + data: { + 'jsonrpc': '2.0', + 'id': '1', + 'method': 'listAnnouncements', + 'params': { + 'paginator': {'pageSize': 5, 'page': 1}, + 'sortBy': {'id': 'updatedAt', 'sort': 'desc'}, + }, + }, + options: Options(headers: {'X-API-Key': apiKey}), + ); + + if (resp.statusCode == null || resp.statusCode != 200) { + throw Exception('Failed to list announcements'); + } + + final error = resp.data['error']; + if (error != null) { + throw Exception('Failed to list announcements: $error'); + } + + final result = resp.data['result'] as Map?; + if (result == null) { + return []; + } + final items = result['elements'] as List? ?? []; + return items.cast>(); + } catch (e) { + rethrow; + } + } } class BullBitcoinApiMinAmountException extends BullException { diff --git a/lib/core/exchange/data/datasources/bullbitcoin_api_key_datasource.dart b/lib/core/exchange/data/datasources/bullbitcoin_api_key_datasource.dart index 08838c6cc..10208ac24 100644 --- a/lib/core/exchange/data/datasources/bullbitcoin_api_key_datasource.dart +++ b/lib/core/exchange/data/datasources/bullbitcoin_api_key_datasource.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:bb_mobile/core/exchange/data/models/api_key_model.dart'; import 'package:bb_mobile/core/storage/data/datasources/key_value_storage/key_value_storage_datasource.dart'; -import 'package:bb_mobile/core/utils/logger.dart'; +import 'package:bb_mobile/core/utils/logger.dart' show log; class BullbitcoinApiKeyDatasource { static const String _apiKeyStorageKey = 'exchange_api_key'; diff --git a/lib/core/exchange/data/datasources/exchange_support_chat_datasource.dart b/lib/core/exchange/data/datasources/exchange_support_chat_datasource.dart new file mode 100644 index 000000000..c0b15e5d4 --- /dev/null +++ b/lib/core/exchange/data/datasources/exchange_support_chat_datasource.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:bb_mobile/core/exchange/data/models/support_chat_message_attachment_model.dart'; +import 'package:bb_mobile/core/exchange/data/models/support_chat_message_model.dart'; +import 'package:dio/dio.dart'; + +class ExchangeSupportChatDatasource { + final Dio _http; + final _messagesPath = '/ak/api-commcenter'; + + ExchangeSupportChatDatasource({required Dio bullbitcoinApiHttpClient}) + : _http = bullbitcoinApiHttpClient; + + Future> listMessages({ + required String apiKey, + required String userId, + int? page, + int? pageSize, + }) async { + try { + final params = { + 'sortBy': {'id': 'createdAt', 'sort': 'desc'}, + 'paginator': {'page': page ?? 1, 'pageSize': pageSize ?? 10}, + }; + + final resp = await _http.post( + _messagesPath, + data: { + 'jsonrpc': '2.0', + 'id': '0', + 'method': 'listMessages', + 'params': params, + }, + options: Options(headers: {'X-API-Key': apiKey}), + ); + + if (resp.statusCode != 200) { + throw Exception('Failed to list messages'); + } + + final error = resp.data['error']; + if (error != null) { + throw Exception('Failed to list messages: $error'); + } + + final elements = resp.data['result']['elements'] as List?; + if (elements == null) return []; + + return elements + .map( + (e) => SupportChatMessageModel.fromJsonWithUserId( + e as Map, + userId, + ), + ) + .toList(); + } catch (e) { + rethrow; + } + } + + Future sendMessage({ + required String apiKey, + required String text, + List? attachments, + }) async { + try { + final params = {'text': text, 'source': 'BULL Mobile'}; + + final attachmentsList = attachments + ?.where((attachment) => attachment.fileData != null) + .map((attachment) { + return { + 'fileName': attachment.fileName, + 'fileType': attachment.fileType, + 'fileSize': attachment.fileSize, + 'fileData': base64Encode(attachment.fileData!), + }; + }) + .toList(); + + params['attachments'] = attachmentsList; + + final requestData = { + 'jsonrpc': '2.0', + 'id': '0', + 'method': 'sendMessageToSupport', + 'params': {'element': params}, + }; + + final resp = await _http.post( + _messagesPath, + data: requestData, + options: Options(headers: {'X-API-Key': apiKey}), + ); + + if (resp.statusCode != 200) { + throw Exception('Failed to send message'); + } + + final error = resp.data['error']; + if (error != null) { + throw Exception('Failed to send message: $error'); + } + } catch (e) { + rethrow; + } + } + + Future getMessageAttachment({ + required String apiKey, + required String attachmentId, + }) async { + try { + final requestData = { + 'jsonrpc': '2.0', + 'id': '0', + 'method': 'getMessageAttachment', + 'params': {'attachmentId': attachmentId}, + }; + + final resp = await _http.post( + _messagesPath, + data: requestData, + options: Options(headers: {'X-API-Key': apiKey}), + ); + + if (resp.statusCode != 200) { + throw Exception('Failed to get message attachment'); + } + + final error = resp.data['error']; + if (error != null) { + throw Exception('Failed to get message attachment: $error'); + } + + final result = resp.data['result'] as Map?; + if (result == null) { + throw Exception('No result in response'); + } + + final fileData = result['fileData']; + Uint8List? fileDataBytes; + + if (fileData != null) { + if (fileData is Map && fileData['data'] != null) { + fileDataBytes = Uint8List.fromList( + List.from(fileData['data'] as List), + ); + } else if (fileData is List) { + fileDataBytes = Uint8List.fromList(List.from(fileData)); + } else if (fileData is String) { + fileDataBytes = Uint8List.fromList(base64Decode(fileData)); + } + } + + return SupportChatMessageAttachmentModel( + attachmentId: result['attachmentId'] as String?, + fileName: result['fileName'] as String?, + fileType: result['fileType'] as String?, + fileSize: result['fileSize'] as int?, + fileData: fileDataBytes, + messageId: result['messageId'] as String?, + createdAt: result['createdAt'] != null + ? DateTime.tryParse(result['createdAt'] as String) + : null, + ); + } catch (e) { + rethrow; + } + } +} diff --git a/lib/core/exchange/data/datasources/price_local_datasource.dart b/lib/core/exchange/data/datasources/price_local_datasource.dart new file mode 100644 index 000000000..155f04f5b --- /dev/null +++ b/lib/core/exchange/data/datasources/price_local_datasource.dart @@ -0,0 +1,127 @@ +import 'package:bb_mobile/core/exchange/domain/entity/rate.dart'; +import 'package:bb_mobile/core/storage/sqlite_database.dart'; +import 'package:drift/drift.dart'; + +class PriceLocalDatasource { + final SqliteDatabase _db; + + PriceLocalDatasource({required SqliteDatabase db}) : _db = db; + + Future savePrices(List prices) async { + if (prices.isEmpty) return; + + final companions = + prices.map((price) { + return PricesCompanion.insert( + fromCurrency: price.fromCurrency, + toCurrency: price.toCurrency, + interval: price.interval.value, + createdAt: price.createdAt.toIso8601String(), + marketPrice: Value(price.marketPrice), + price: Value(price.price), + priceCurrency: Value(price.priceCurrency), + precision: Value(price.precision), + indexPrice: Value(price.indexPrice), + userPrice: Value(price.userPrice), + ); + }).toList(); + + await _db.batch((batch) { + for (final companion in companions) { + batch.insert(_db.prices, companion, mode: InsertMode.insertOrReplace); + } + }); + } + + Future> getPriceHistory({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + DateTime? fromDate, + DateTime? toDate, + }) async { + var query = _db.select(_db.prices)..where( + (p) => + p.fromCurrency.equals(fromCurrency) & + p.toCurrency.equals(toCurrency) & + p.interval.equals(interval.value), + ); + + if (fromDate != null) { + final fromDateStr = fromDate.toUtc().toIso8601String(); + query = + query..where((p) => p.createdAt.isBiggerOrEqualValue(fromDateStr)); + } + if (toDate != null) { + final toDateStr = toDate.toUtc().toIso8601String(); + query = query..where((p) => p.createdAt.isSmallerOrEqualValue(toDateStr)); + } + + query = query..orderBy([(p) => OrderingTerm.asc(p.createdAt)]); + + final rows = await query.get(); + + return rows.map((row) { + return Rate( + fromCurrency: row.fromCurrency, + toCurrency: row.toCurrency, + interval: RateTimelineInterval.fromValue(row.interval), + createdAt: DateTime.parse(row.createdAt), + marketPrice: row.marketPrice, + price: row.price, + priceCurrency: row.priceCurrency, + precision: row.precision, + indexPrice: row.indexPrice, + userPrice: row.userPrice, + ); + }).toList(); + } + + Future cleanupOldRates({ + required String fromCurrency, + required String toCurrency, + required String interval, + required Duration maxAge, + }) async { + final cutoffDate = DateTime.now().subtract(maxAge).toUtc(); + final cutoffDateStr = cutoffDate.toIso8601String(); + + await (_db.delete(_db.prices)..where( + (p) => + p.fromCurrency.equals(fromCurrency) & + p.toCurrency.equals(toCurrency) & + p.interval.equals(interval) & + p.createdAt.isSmallerThanValue(cutoffDateStr), + )).go(); + } + + Future clearPrices({ + String? fromCurrency, + String? toCurrency, + String? interval, + }) async { + var query = _db.delete(_db.prices); + + if (fromCurrency != null && toCurrency != null && interval != null) { + query = + query..where( + (p) => + p.fromCurrency.equals(fromCurrency) & + p.toCurrency.equals(toCurrency) & + p.interval.equals(interval), + ); + } else { + if (fromCurrency != null) { + query = query..where((p) => p.fromCurrency.equals(fromCurrency)); + } + if (toCurrency != null) { + query = query..where((p) => p.toCurrency.equals(toCurrency)); + } + if (interval != null) { + query = query..where((p) => p.interval.equals(interval)); + } + } + + await query.go(); + } +} diff --git a/lib/core/exchange/data/datasources/price_remote_datasource.dart b/lib/core/exchange/data/datasources/price_remote_datasource.dart new file mode 100644 index 000000000..08c303cad --- /dev/null +++ b/lib/core/exchange/data/datasources/price_remote_datasource.dart @@ -0,0 +1,136 @@ +import 'dart:math' show pow; + +import 'package:bb_mobile/core/exchange/data/models/rate_history_request_model.dart'; +import 'package:bb_mobile/core/exchange/data/models/rate_model.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/rate.dart'; +import 'package:dio/dio.dart'; + +abstract class PriceRemoteDatasource { + Future> getPriceHistory({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + DateTime? fromDate, + DateTime? toDate, + }); +} + +class BullbitcoinPriceRemoteDatasource implements PriceRemoteDatasource { + final Dio _http; + final _pricePath = '/public/price'; + + BullbitcoinPriceRemoteDatasource({required Dio bullbitcoinApiHttpClient}) + : _http = bullbitcoinApiHttpClient; + + @override + Future> getPriceHistory({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + DateTime? fromDate, + DateTime? toDate, + }) async { + try { + final requestModel = RateHistoryRequestModel( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: interval.enumValue, + fromDate: fromDate, + toDate: toDate, + ); + + final requestParams = requestModel.toApiParams(); + + final resp = await _http.post( + _pricePath, + data: { + 'id': 1, + 'jsonrpc': '2.0', + 'method': 'getIndexRateHistory', + 'params': requestParams, + }, + ); + + if (resp.statusCode == null || + resp.statusCode == null || + resp.statusCode != 200) { + return []; + } + + final data = resp.data as Map; + + if (data.containsKey('error')) { + return []; + } + + final result = data['result'] as Map?; + if (result == null) { + return []; + } + + final element = result['element'] as Map?; + if (element == null) { + return []; + } + + final intervalStr = element['interval'] as String? ?? 'week'; + final precision = element['precision'] as int? ?? 2; + final fromCurrencyValue = + element['fromCurrency'] as String? ?? fromCurrency; + final toCurrencyValue = element['toCurrency'] as String? ?? toCurrency; + + final ratesList = element['rates'] as List?; + if (ratesList == null || ratesList.isEmpty) { + return []; + } + + final precisionDivisor = pow(10, precision).toDouble(); + + final models = []; + for (var i = 0; i < ratesList.length; i++) { + try { + final rateItem = ratesList[i]; + final rateData = rateItem as Map; + + final periodStart = rateData['periodStart'] as String?; + final createdAt = rateData['createdAt'] as String?; + final dateStr = periodStart ?? createdAt; + + if (dateStr == null) { + continue; + } + + final indexPriceValue = rateData['indexPrice']; + final indexPriceInt = indexPriceValue is int ? indexPriceValue : null; + final indexPriceDouble = indexPriceValue is double + ? indexPriceValue + : null; + final indexPrice = indexPriceInt != null + ? indexPriceInt / precisionDivisor + : indexPriceDouble; + + final model = RateModel( + fromCurrency: fromCurrencyValue, + toCurrency: toCurrencyValue, + interval: intervalStr, + createdAt: dateStr, + marketPrice: null, + price: null, + priceCurrency: null, + precision: precision, + indexPrice: indexPrice, + userPrice: null, + ); + + models.add(model); + } catch (e) { + continue; + } + } + + return models; + } catch (e) { + return []; + } + } +} diff --git a/lib/core/exchange/data/mappers/user_summary_mapper.dart b/lib/core/exchange/data/mappers/user_summary_mapper.dart index 45ff7f9d2..31288a205 100644 --- a/lib/core/exchange/data/mappers/user_summary_mapper.dart +++ b/lib/core/exchange/data/mappers/user_summary_mapper.dart @@ -5,6 +5,7 @@ class UserSummaryMapper { static UserSummary fromModelToEntity(UserSummaryModel model) { return UserSummary( userNumber: model.userNumber, + userId: model.userId, groups: model.groups, profile: _mapUserProfile(model.profile), email: model.email, diff --git a/lib/core/exchange/data/models/announcement_model.dart b/lib/core/exchange/data/models/announcement_model.dart new file mode 100644 index 000000000..124d9a37b --- /dev/null +++ b/lib/core/exchange/data/models/announcement_model.dart @@ -0,0 +1,28 @@ +import 'package:bb_mobile/core/exchange/domain/entity/announcement.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'announcement_model.freezed.dart'; +part 'announcement_model.g.dart'; + +@freezed +sealed class AnnouncementModel with _$AnnouncementModel { + const factory AnnouncementModel({ + required String title, + required String description, + required String updatedAt, + }) = _AnnouncementModel; + + factory AnnouncementModel.fromJson(Map json) => + _$AnnouncementModelFromJson(json); + + const AnnouncementModel._(); + + Announcement toEntity() { + return Announcement( + title: title, + description: description, + updatedAt: DateTime.parse(updatedAt), + ); + } +} + diff --git a/lib/core/exchange/data/models/rate_history_model.dart b/lib/core/exchange/data/models/rate_history_model.dart new file mode 100644 index 000000000..b8afa1126 --- /dev/null +++ b/lib/core/exchange/data/models/rate_history_model.dart @@ -0,0 +1,98 @@ +import 'dart:math' show pow; + +import 'package:bb_mobile/core/exchange/data/models/rate_model.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/rate.dart'; + +class RateHistoryModel { + final String fromCurrency; + final String toCurrency; + final String interval; + final int precision; + final List rates; + + RateHistoryModel({ + required this.fromCurrency, + required this.toCurrency, + required this.interval, + required this.precision, + required this.rates, + }); + + factory RateHistoryModel.fromJson(Map json) { + final intervalStr = json['interval'] as String? ?? 'week'; + final precision = json['precision'] as int? ?? 2; + final fromCurrency = json['fromCurrency'] as String? ?? 'BTC'; + final toCurrency = json['toCurrency'] as String? ?? 'CAD'; + + final elements = json['elements'] as List?; + + if (elements == null || elements.isEmpty) { + return RateHistoryModel( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: intervalStr, + precision: precision, + rates: [], + ); + } + + try { + final precisionDivisor = pow(10, precision).toDouble(); + + final parsedRates = elements.map((e) { + try { + final data = e as Map; + + final periodStart = data['periodStart'] as String?; + final createdAt = data['createdAt'] as String?; + final dateStr = periodStart ?? createdAt; + + if (dateStr == null) { + throw Exception('Missing periodStart or createdAt in rate data'); + } + + final indexPriceInt = data['indexPrice'] as int?; + final indexPriceDouble = data['indexPrice'] as double?; + final indexPrice = indexPriceInt != null + ? indexPriceInt / precisionDivisor + : indexPriceDouble; + + return RateModel( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: intervalStr, + createdAt: dateStr, + marketPrice: null, + price: null, + priceCurrency: null, + precision: precision, + indexPrice: indexPrice, + userPrice: null, + ); + } catch (e) { + rethrow; + } + }).toList(); + + return RateHistoryModel( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: intervalStr, + precision: precision, + rates: parsedRates, + ); + } catch (e) { + return RateHistoryModel( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: intervalStr, + precision: precision, + rates: [], + ); + } + } + + List toEntityList() { + return rates.map((rateModel) => rateModel.toEntity()).toList(); + } +} diff --git a/lib/core/exchange/data/models/rate_history_request_model.dart b/lib/core/exchange/data/models/rate_history_request_model.dart new file mode 100644 index 000000000..421920d72 --- /dev/null +++ b/lib/core/exchange/data/models/rate_history_request_model.dart @@ -0,0 +1,35 @@ +class RateHistoryRequestModel { + final String fromCurrency; + final String toCurrency; + final String interval; + final DateTime? fromDate; + final DateTime? toDate; + + RateHistoryRequestModel({ + required this.fromCurrency, + required this.toCurrency, + required this.interval, + this.fromDate, + this.toDate, + }); + + Map toApiParams() { + final fromDateMs = + fromDate?.millisecondsSinceEpoch ?? + DateTime.now() + .subtract(const Duration(days: 365)) + .millisecondsSinceEpoch; + final toDateMs = + toDate?.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch; + + return { + 'element': { + 'fromCurrency': fromCurrency, + 'toCurrency': toCurrency, + 'interval': interval, + 'fromDate': fromDateMs.toString(), + 'toDate': toDateMs.toString(), + }, + }; + } +} diff --git a/lib/core/exchange/data/models/rate_model.dart b/lib/core/exchange/data/models/rate_model.dart new file mode 100644 index 000000000..10c156c8e --- /dev/null +++ b/lib/core/exchange/data/models/rate_model.dart @@ -0,0 +1,65 @@ +import 'package:bb_mobile/core/exchange/domain/entity/rate.dart'; + +class RateModel { + final String fromCurrency; + final String toCurrency; + final String interval; + final String createdAt; + final double? marketPrice; + final double? price; + final String? priceCurrency; + final int? precision; + final double? indexPrice; + final double? userPrice; + + RateModel({ + required this.fromCurrency, + required this.toCurrency, + required this.interval, + required this.createdAt, + this.marketPrice, + this.price, + this.priceCurrency, + this.precision, + this.indexPrice, + this.userPrice, + }); + + factory RateModel.fromJson(Map json) { + final indexPriceValue = json['indexPrice']; + final indexPrice = indexPriceValue is int + ? indexPriceValue.toDouble() + : indexPriceValue as double?; + + return RateModel( + fromCurrency: json['fromCurrency'] as String? ?? 'BTC', + toCurrency: json['toCurrency'] as String? ?? 'CAD', + interval: json['interval'] as String? ?? 'week', + createdAt: + json['createdAt'] as String? ?? + json['periodStart'] as String? ?? + DateTime.now().toIso8601String(), + marketPrice: json['marketPrice'] as double?, + price: json['price'] as double?, + priceCurrency: json['priceCurrency'] as String?, + precision: json['precision'] as int?, + indexPrice: indexPrice, + userPrice: json['userPrice'] as double?, + ); + } + + Rate toEntity() { + return Rate( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.fromValue(interval), + createdAt: DateTime.parse(createdAt), + marketPrice: marketPrice, + price: price, + priceCurrency: priceCurrency, + precision: precision, + indexPrice: indexPrice, + userPrice: userPrice, + ); + } +} diff --git a/lib/core/exchange/data/models/support_chat_message_attachment_model.dart b/lib/core/exchange/data/models/support_chat_message_attachment_model.dart new file mode 100644 index 000000000..111be6ca9 --- /dev/null +++ b/lib/core/exchange/data/models/support_chat_message_attachment_model.dart @@ -0,0 +1,35 @@ +import 'dart:typed_data'; + +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message_attachment.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'support_chat_message_attachment_model.freezed.dart'; + +@freezed +sealed class SupportChatMessageAttachmentModel + with _$SupportChatMessageAttachmentModel { + const factory SupportChatMessageAttachmentModel({ + String? attachmentId, + String? fileName, + String? fileType, + int? fileSize, + Uint8List? fileData, + String? messageId, + DateTime? createdAt, + }) = _SupportChatMessageAttachmentModel; + + const SupportChatMessageAttachmentModel._(); + + SupportChatMessageAttachment toEntity() { + return SupportChatMessageAttachment( + attachmentId: attachmentId, + fileName: fileName, + fileType: fileType, + fileSize: fileSize, + fileData: fileData, + messageId: messageId, + createdAt: createdAt, + ); + } +} + diff --git a/lib/core/exchange/data/models/support_chat_message_model.dart b/lib/core/exchange/data/models/support_chat_message_model.dart new file mode 100644 index 000000000..68e300907 --- /dev/null +++ b/lib/core/exchange/data/models/support_chat_message_model.dart @@ -0,0 +1,93 @@ +import 'dart:typed_data'; + +import 'package:bb_mobile/core/exchange/data/models/support_chat_message_attachment_model.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'support_chat_message_model.freezed.dart'; + +@freezed +sealed class SupportChatMessageModel with _$SupportChatMessageModel { + const factory SupportChatMessageModel({ + String? messageId, + String? text, + String? fromUserId, + String? toGroupCode, + DateTime? createdAt, + DateTime? updatedAt, + bool? isAdmin, + List? attachments, + }) = _SupportChatMessageModel; + + factory SupportChatMessageModel.fromJsonWithUserId( + Map json, + String userId, + ) { + final fromUserId = json['fromUserId'] as String?; + final isAdmin = fromUserId != userId; + + return SupportChatMessageModel( + messageId: json['messageId'] as String?, + text: json['text'] as String?, + fromUserId: fromUserId, + toGroupCode: json['toGroupCode'] as String?, + createdAt: json['createdAt'] != null + ? DateTime.tryParse(json['createdAt'] as String) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.tryParse(json['updatedAt'] as String) + : null, + isAdmin: isAdmin, + attachments: (json['attachments'] as List?) + ?.where((attachmentJson) => attachmentJson != null) + .map((attachmentJson) { + final attachment = attachmentJson as Map; + Uint8List? fileDataBytes; + + if (attachment['fileData'] != null) { + if (attachment['fileData'] is Map && + attachment['fileData']['data'] != null) { + fileDataBytes = Uint8List.fromList( + List.from(attachment['fileData']['data'] as List), + ); + } else if (attachment['fileData'] is List) { + fileDataBytes = Uint8List.fromList( + List.from(attachment['fileData'] as List), + ); + } + } + + return SupportChatMessageAttachmentModel( + attachmentId: attachment['attachmentId'] as String?, + fileName: attachment['fileName'] as String?, + fileType: attachment['fileType'] as String?, + fileSize: attachment['fileSize'] as int?, + fileData: fileDataBytes, + messageId: + attachment['messageId'] as String? ?? + json['messageId'] as String?, + createdAt: attachment['createdAt'] != null + ? DateTime.tryParse(attachment['createdAt'] as String) + : null, + ); + }) + .where((att) => att.attachmentId != null || att.fileName != null) + .toList(), + ); + } + + const SupportChatMessageModel._(); + + SupportChatMessage toEntity() { + return SupportChatMessage( + messageId: messageId, + text: text, + fromUserId: fromUserId, + toGroupCode: toGroupCode, + createdAt: createdAt, + updatedAt: updatedAt, + isAdmin: isAdmin, + attachments: attachments?.map((a) => a.toEntity()).toList(), + ); + } +} diff --git a/lib/core/exchange/data/models/user_summary_model.dart b/lib/core/exchange/data/models/user_summary_model.dart index 052439d6a..cc2dc9ad8 100644 --- a/lib/core/exchange/data/models/user_summary_model.dart +++ b/lib/core/exchange/data/models/user_summary_model.dart @@ -10,6 +10,7 @@ part 'user_summary_model.g.dart'; sealed class UserSummaryModel with _$UserSummaryModel { const factory UserSummaryModel({ required int userNumber, + String? userId, required List groups, required UserProfileModel profile, required String email, @@ -28,6 +29,7 @@ sealed class UserSummaryModel with _$UserSummaryModel { UserSummary toEntity() { return UserSummary( userNumber: userNumber, + userId: userId, groups: groups, profile: profile.toEntity(), email: email, diff --git a/lib/core/exchange/data/repository/exchange_support_chat_repository_impl.dart b/lib/core/exchange/data/repository/exchange_support_chat_repository_impl.dart new file mode 100644 index 000000000..388e1edf3 --- /dev/null +++ b/lib/core/exchange/data/repository/exchange_support_chat_repository_impl.dart @@ -0,0 +1,133 @@ +import 'package:bb_mobile/core/errors/exchange_errors.dart'; +import 'package:bb_mobile/core/exchange/data/datasources/bullbitcoin_api_datasource.dart'; +import 'package:bb_mobile/core/exchange/data/datasources/bullbitcoin_api_key_datasource.dart'; +import 'package:bb_mobile/core/exchange/data/datasources/exchange_support_chat_datasource.dart'; +import 'package:bb_mobile/core/exchange/data/models/support_chat_message_attachment_model.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message_attachment.dart'; +import 'package:bb_mobile/core/exchange/domain/repositories/exchange_support_chat_repository.dart'; + +class ExchangeSupportChatRepositoryImpl + implements ExchangeSupportChatRepository { + final ExchangeSupportChatDatasource _datasource; + final BullbitcoinApiKeyDatasource _apiKeyDatasource; + final BullbitcoinApiDatasource _bullbitcoinApiDatasource; + final bool _isTestnet; + + ExchangeSupportChatRepositoryImpl({ + required ExchangeSupportChatDatasource datasource, + required BullbitcoinApiKeyDatasource apiKeyDatasource, + required BullbitcoinApiDatasource bullbitcoinApiDatasource, + required bool isTestnet, + }) : _datasource = datasource, + _apiKeyDatasource = apiKeyDatasource, + _bullbitcoinApiDatasource = bullbitcoinApiDatasource, + _isTestnet = isTestnet; + + @override + Future> getMessages({ + int? page, + int? pageSize, + }) async { + try { + final apiKey = await _apiKeyDatasource.get(isTestnet: _isTestnet); + if (apiKey == null) { + throw ApiKeyException( + 'API key not found. Please login to your Bull Bitcoin account.', + ); + } + + final userSummaryModel = await _bullbitcoinApiDatasource.getUserSummary( + apiKey.key, + ); + if (userSummaryModel == null) { + throw Exception('User summary not found'); + } + + final userId = userSummaryModel.userId; + if (userId == null) { + throw Exception('User ID not found in user summary'); + } + + final messageModels = await _datasource.listMessages( + apiKey: apiKey.key, + userId: userId, + page: page, + pageSize: pageSize, + ); + + return messageModels.map((model) => model.toEntity()).toList(); + } catch (e) { + if (e is ApiKeyException) { + rethrow; + } + throw Exception('Failed to get messages: $e'); + } + } + + @override + Future sendMessage({ + required String text, + List? attachments, + }) async { + try { + final apiKey = await _apiKeyDatasource.get(isTestnet: _isTestnet); + if (apiKey == null) { + throw ApiKeyException( + 'API key not found. Please login to your Bull Bitcoin account.', + ); + } + + final attachmentModels = attachments + ?.map( + (attachment) => SupportChatMessageAttachmentModel( + attachmentId: attachment.attachmentId, + fileName: attachment.fileName, + fileType: attachment.fileType, + fileSize: attachment.fileSize, + fileData: attachment.fileData, + messageId: attachment.messageId, + createdAt: attachment.createdAt, + ), + ) + .toList(); + + await _datasource.sendMessage( + apiKey: apiKey.key, + text: text, + attachments: attachmentModels, + ); + } catch (e) { + if (e is ApiKeyException) { + rethrow; + } + throw Exception('Failed to send message: $e'); + } + } + + @override + Future getMessageAttachment( + String attachmentId, + ) async { + try { + final apiKey = await _apiKeyDatasource.get(isTestnet: _isTestnet); + if (apiKey == null) { + throw ApiKeyException( + 'API key not found. Please login to your Bull Bitcoin account.', + ); + } + + final attachmentModel = await _datasource.getMessageAttachment( + apiKey: apiKey.key, + attachmentId: attachmentId, + ); + + return attachmentModel.toEntity(); + } catch (e) { + if (e is ApiKeyException) { + rethrow; + } + throw Exception('Failed to get message attachment: $e'); + } + } +} diff --git a/lib/core/exchange/data/repository/exchange_user_repository_impl.dart b/lib/core/exchange/data/repository/exchange_user_repository_impl.dart index 3455bc9ba..a093b804a 100644 --- a/lib/core/exchange/data/repository/exchange_user_repository_impl.dart +++ b/lib/core/exchange/data/repository/exchange_user_repository_impl.dart @@ -2,7 +2,9 @@ import 'package:bb_mobile/core/errors/exchange_errors.dart'; import 'package:bb_mobile/core/exchange/data/datasources/bullbitcoin_api_datasource.dart'; import 'package:bb_mobile/core/exchange/data/datasources/bullbitcoin_api_key_datasource.dart'; import 'package:bb_mobile/core/exchange/data/mappers/user_summary_mapper.dart'; +import 'package:bb_mobile/core/exchange/data/models/announcement_model.dart'; import 'package:bb_mobile/core/exchange/data/models/user_preference_payload_model.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/announcement.dart'; import 'package:bb_mobile/core/exchange/domain/entity/user_summary.dart'; import 'package:bb_mobile/core/exchange/domain/repositories/exchange_user_repository.dart'; @@ -90,4 +92,36 @@ class ExchangeUserRepositoryImpl implements ExchangeUserRepository { } } } + + @override + Future> listAnnouncements() async { + try { + final apiKey = await _bullbitcoinApiKeyDatasource.get( + isTestnet: _isTestnet, + ); + if (apiKey == null) { + throw ApiKeyException( + 'API key not found. Please login to your Bull Bitcoin account.', + ); + } + try { + final announcementDataList = + await _bullbitcoinApiDatasource.listAnnouncements( + apiKey: apiKey.key, + ); + final announcements = announcementDataList + .map((json) => AnnouncementModel.fromJson(json).toEntity()) + .toList(); + return announcements; + } catch (e) { + throw Exception('Failed to fetch announcements: $e'); + } + } catch (e) { + if (e is ApiKeyException) { + rethrow; + } else { + throw Exception('Failed to fetch announcements: $e'); + } + } + } } diff --git a/lib/core/exchange/data/repository/price_repository_impl.dart b/lib/core/exchange/data/repository/price_repository_impl.dart new file mode 100644 index 000000000..579682c2c --- /dev/null +++ b/lib/core/exchange/data/repository/price_repository_impl.dart @@ -0,0 +1,182 @@ +import 'package:bb_mobile/core/exchange/data/datasources/price_local_datasource.dart'; +import 'package:bb_mobile/core/exchange/data/datasources/price_remote_datasource.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/rate.dart'; +import 'package:bb_mobile/core/exchange/domain/repositories/price_repository.dart'; + +class PriceRepositoryImpl implements PriceRepository { + final PriceRemoteDatasource _remoteDatasource; + final PriceLocalDatasource _localDatasource; + + PriceRepositoryImpl({ + required PriceRemoteDatasource remoteDatasource, + required PriceLocalDatasource localDatasource, + }) : _remoteDatasource = remoteDatasource, + _localDatasource = localDatasource; + + @override + Future> getPriceHistory({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + DateTime? fromDate, + DateTime? toDate, + }) async { + final now = toDate ?? DateTime.now().toUtc(); + + DateTime? effectiveFromDate = fromDate; + effectiveFromDate ??= switch (interval) { + RateTimelineInterval.week => now.subtract(const Duration(days: 90)), + RateTimelineInterval.fifteen => now.subtract(const Duration(minutes: 15)), + RateTimelineInterval.hour => now.subtract(const Duration(days: 30)), + RateTimelineInterval.day => now.subtract(const Duration(days: 90)), + }; + + final localPrices = await _localDatasource.getPriceHistory( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: interval, + fromDate: effectiveFromDate, + toDate: now, + ); + + return localPrices; + } + + @override + Future> refreshPriceHistory({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + DateTime? fromDate, + DateTime? toDate, + }) async { + final now = toDate ?? DateTime.now().toUtc(); + + DateTime? effectiveFromDate = fromDate; + effectiveFromDate ??= switch (interval) { + RateTimelineInterval.week => now.subtract(const Duration(days: 90)), + RateTimelineInterval.fifteen => now.subtract(const Duration(minutes: 15)), + RateTimelineInterval.hour => now.subtract(const Duration(days: 30)), + RateTimelineInterval.day => now.subtract(const Duration(days: 90)), + }; + + final remotePriceModels = await _remoteDatasource.getPriceHistory( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: interval, + fromDate: effectiveFromDate, + toDate: now, + ); + + if (remotePriceModels.isNotEmpty) { + final remotePrices = remotePriceModels + .map((model) => model.toEntity()) + .toList(); + + await _localDatasource.clearPrices( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: interval.value, + ); + + await _localDatasource.savePrices(remotePrices); + + if (interval == RateTimelineInterval.fifteen) { + final dayFromDate = now.subtract(const Duration(days: 90)); + final localDay = await _localDatasource.getPriceHistory( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.day, + fromDate: dayFromDate, + toDate: now, + ); + + if (localDay.isEmpty) { + final dayPrices = await _remoteDatasource.getPriceHistory( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.day, + fromDate: dayFromDate, + toDate: now, + ); + if (dayPrices.isNotEmpty) { + final dayPricesEntities = dayPrices + .map((model) => model.toEntity()) + .toList(); + await _localDatasource.clearPrices( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.day.value, + ); + await _localDatasource.savePrices(dayPricesEntities); + await _localDatasource.cleanupOldRates( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.day.value, + maxAge: const Duration(days: 90), + ); + } + } + + await _localDatasource.cleanupOldRates( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.fifteen.value, + maxAge: const Duration(minutes: 15), + ); + } else if (interval == RateTimelineInterval.day) { + await _localDatasource.cleanupOldRates( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.day.value, + maxAge: const Duration(days: 90), + ); + + final fifteenFromDate = now.subtract(const Duration(minutes: 15)); + final localFifteen = await _localDatasource.getPriceHistory( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.fifteen, + fromDate: fifteenFromDate, + toDate: now, + ); + + if (localFifteen.isEmpty) { + final fifteenPrices = await _remoteDatasource.getPriceHistory( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.fifteen, + fromDate: fifteenFromDate, + toDate: now, + ); + if (fifteenPrices.isNotEmpty) { + final fifteenPricesEntities = fifteenPrices + .map((model) => model.toEntity()) + .toList(); + await _localDatasource.clearPrices( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.fifteen.value, + ); + await _localDatasource.savePrices(fifteenPricesEntities); + await _localDatasource.cleanupOldRates( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: RateTimelineInterval.fifteen.value, + maxAge: const Duration(minutes: 15), + ); + } + } + } + + return remotePrices; + } + + return []; + } + + @override + Future savePriceHistory(List prices) async { + await _localDatasource.savePrices(prices); + } +} diff --git a/lib/core/exchange/domain/entity/announcement.dart b/lib/core/exchange/domain/entity/announcement.dart new file mode 100644 index 000000000..659981993 --- /dev/null +++ b/lib/core/exchange/domain/entity/announcement.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'announcement.freezed.dart'; + +@freezed +sealed class Announcement with _$Announcement { + const factory Announcement({ + required String title, + required String description, + required DateTime updatedAt, + }) = _Announcement; + + const Announcement._(); +} + diff --git a/lib/core/exchange/domain/entity/rate.dart b/lib/core/exchange/domain/entity/rate.dart new file mode 100644 index 000000000..1a773c45b --- /dev/null +++ b/lib/core/exchange/domain/entity/rate.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'rate.freezed.dart'; + +enum RateTimelineInterval { + fifteen('fifteen'), + hour('hour'), + day('day'), + week('week'); + + final String _interval; + const RateTimelineInterval(this._interval); + + String get enumValue => _interval; + String get value => _interval; + + static RateTimelineInterval fromValue(String value) { + return RateTimelineInterval.values.firstWhere( + (e) => e.value == value, + orElse: () => RateTimelineInterval.day, + ); + } +} + +@freezed +sealed class Rate with _$Rate { + const factory Rate({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + required DateTime createdAt, + double? marketPrice, + double? price, + String? priceCurrency, + int? precision, + double? indexPrice, + double? userPrice, + }) = _Rate; + const Rate._(); +} diff --git a/lib/core/exchange/domain/entity/support_chat_message.dart b/lib/core/exchange/domain/entity/support_chat_message.dart new file mode 100644 index 000000000..71ec72622 --- /dev/null +++ b/lib/core/exchange/domain/entity/support_chat_message.dart @@ -0,0 +1,18 @@ +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message_attachment.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'support_chat_message.freezed.dart'; + +@freezed +sealed class SupportChatMessage with _$SupportChatMessage { + const factory SupportChatMessage({ + String? messageId, + String? text, + String? fromUserId, + String? toGroupCode, + DateTime? createdAt, + DateTime? updatedAt, + bool? isAdmin, + List? attachments, + }) = _SupportChatMessage; +} diff --git a/lib/core/exchange/domain/entity/support_chat_message_attachment.dart b/lib/core/exchange/domain/entity/support_chat_message_attachment.dart new file mode 100644 index 000000000..32abd5b0e --- /dev/null +++ b/lib/core/exchange/domain/entity/support_chat_message_attachment.dart @@ -0,0 +1,17 @@ +import 'dart:typed_data'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'support_chat_message_attachment.freezed.dart'; + +@freezed +sealed class SupportChatMessageAttachment with _$SupportChatMessageAttachment { + const factory SupportChatMessageAttachment({ + String? attachmentId, + String? fileName, + String? fileType, + int? fileSize, + Uint8List? fileData, + String? messageId, + DateTime? createdAt, + }) = _SupportChatMessageAttachment; +} diff --git a/lib/core/exchange/domain/entity/user_summary.dart b/lib/core/exchange/domain/entity/user_summary.dart index f30783c0e..6ace7c6f0 100644 --- a/lib/core/exchange/domain/entity/user_summary.dart +++ b/lib/core/exchange/domain/entity/user_summary.dart @@ -94,6 +94,7 @@ sealed class UserAutoBuy with _$UserAutoBuy { sealed class UserSummary with _$UserSummary { const factory UserSummary({ required int userNumber, + String? userId, required List groups, required UserProfile profile, required String email, diff --git a/lib/core/exchange/domain/errors/buy_error.dart b/lib/core/exchange/domain/errors/buy_error.dart index ee0060f6a..55ce7e22b 100644 --- a/lib/core/exchange/domain/errors/buy_error.dart +++ b/lib/core/exchange/domain/errors/buy_error.dart @@ -1,3 +1,5 @@ +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'buy_error.freezed.dart'; @@ -15,4 +17,17 @@ sealed class BuyError with _$BuyError { OrderAlreadyConfirmedBuyError; const factory BuyError.unexpected({required String message}) = UnexpectedBuyError; + + const BuyError._(); + + /// Returns the localized error message. + String toTranslated(BuildContext context) => when( + unauthenticated: () => context.loc.buyUnauthenticatedError, + belowMinAmount: (_) => context.loc.buyBelowMinAmountError, + aboveMaxAmount: (_) => context.loc.buyAboveMaxAmountError, + insufficientFunds: () => context.loc.buyInsufficientFundsError, + orderNotFound: () => context.loc.buyOrderNotFoundError, + orderAlreadyConfirmed: () => context.loc.buyOrderAlreadyConfirmedError, + unexpected: (message) => message, + ); } diff --git a/lib/core/exchange/domain/errors/pay_error.dart b/lib/core/exchange/domain/errors/pay_error.dart index 774b9e1b9..93696f85b 100644 --- a/lib/core/exchange/domain/errors/pay_error.dart +++ b/lib/core/exchange/domain/errors/pay_error.dart @@ -1,3 +1,5 @@ +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'pay_error.freezed.dart'; @@ -15,4 +17,17 @@ sealed class PayError with _$PayError { OrderAlreadyConfirmedPayError; const factory PayError.unexpected({required String message}) = UnexpectedPayError; + + const PayError._(); + + /// Returns the localized error message. + String toTranslated(BuildContext context) => when( + unauthenticated: () => context.loc.payNotAuthenticated, + belowMinAmount: (_) => context.loc.payBelowMinAmount, + aboveMaxAmount: (_) => context.loc.payAboveMaxAmount, + insufficientBalance: () => context.loc.payInsufficientBalance, + orderNotFound: () => context.loc.payOrderNotFound, + orderAlreadyConfirmed: () => context.loc.payOrderAlreadyConfirmed, + unexpected: (message) => message, + ); } diff --git a/lib/core/exchange/domain/errors/sell_error.dart b/lib/core/exchange/domain/errors/sell_error.dart index 6211ebb80..d36de740a 100644 --- a/lib/core/exchange/domain/errors/sell_error.dart +++ b/lib/core/exchange/domain/errors/sell_error.dart @@ -1,3 +1,5 @@ +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'sell_error.freezed.dart'; @@ -17,4 +19,17 @@ sealed class SellError with _$SellError { const factory SellError.insufficientBalance({ required int requiredAmountSat, }) = InsufficientBalanceSellError; + + const SellError._(); + + /// Returns the localized error message. + String toTranslated(BuildContext context) => when( + unauthenticated: () => context.loc.sellUnauthenticatedError, + belowMinAmount: (_) => context.loc.sellBelowMinAmountError, + aboveMaxAmount: (_) => context.loc.sellAboveMaxAmountError, + orderNotFound: () => context.loc.sellOrderNotFoundError, + orderAlreadyConfirmed: () => context.loc.sellOrderAlreadyConfirmedError, + unexpected: (message) => message, + insufficientBalance: (_) => context.loc.sellInsufficientBalanceError, + ); } diff --git a/lib/core/exchange/domain/errors/withdraw_error.dart b/lib/core/exchange/domain/errors/withdraw_error.dart index 4ce05876e..21d07bd59 100644 --- a/lib/core/exchange/domain/errors/withdraw_error.dart +++ b/lib/core/exchange/domain/errors/withdraw_error.dart @@ -1,3 +1,5 @@ +import 'package:bb_mobile/core/utils/build_context_x.dart'; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'withdraw_error.freezed.dart'; @@ -14,4 +16,16 @@ sealed class WithdrawError with _$WithdrawError { OrderAlreadyConfirmedWithdrawError; const factory WithdrawError.unexpected({required String message}) = UnexpectedWithdrawError; + + const WithdrawError._(); + + /// Returns the localized error message. + String toTranslated(BuildContext context) => when( + unauthenticated: () => context.loc.withdrawUnauthenticatedError, + belowMinAmount: (_) => context.loc.withdrawBelowMinAmountError, + aboveMaxAmount: (_) => context.loc.withdrawAboveMaxAmountError, + orderNotFound: () => context.loc.withdrawOrderNotFoundError, + orderAlreadyConfirmed: () => context.loc.withdrawOrderAlreadyConfirmedError, + unexpected: (message) => message, + ); } diff --git a/lib/core/exchange/domain/repositories/exchange_support_chat_repository.dart b/lib/core/exchange/domain/repositories/exchange_support_chat_repository.dart new file mode 100644 index 000000000..fe137c65a --- /dev/null +++ b/lib/core/exchange/domain/repositories/exchange_support_chat_repository.dart @@ -0,0 +1,15 @@ +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message_attachment.dart'; + +abstract class ExchangeSupportChatRepository { + Future> getMessages({int? page, int? pageSize}); + + Future sendMessage({ + required String text, + List? attachments, + }); + + Future getMessageAttachment( + String attachmentId, + ); +} diff --git a/lib/core/exchange/domain/repositories/exchange_user_repository.dart b/lib/core/exchange/domain/repositories/exchange_user_repository.dart index d84a56911..f15367ff5 100644 --- a/lib/core/exchange/domain/repositories/exchange_user_repository.dart +++ b/lib/core/exchange/domain/repositories/exchange_user_repository.dart @@ -1,3 +1,4 @@ +import 'package:bb_mobile/core/exchange/domain/entity/announcement.dart'; import 'package:bb_mobile/core/exchange/domain/entity/user_summary.dart'; abstract class ExchangeUserRepository { @@ -8,4 +9,5 @@ abstract class ExchangeUserRepository { bool? dcaEnabled, String? autoBuyEnabled, }); + Future> listAnnouncements(); } diff --git a/lib/core/exchange/domain/repositories/price_repository.dart b/lib/core/exchange/domain/repositories/price_repository.dart new file mode 100644 index 000000000..7183c209a --- /dev/null +++ b/lib/core/exchange/domain/repositories/price_repository.dart @@ -0,0 +1,21 @@ +import 'package:bb_mobile/core/exchange/domain/entity/rate.dart'; + +abstract class PriceRepository { + Future> getPriceHistory({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + DateTime? fromDate, + DateTime? toDate, + }); + + Future> refreshPriceHistory({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + DateTime? fromDate, + DateTime? toDate, + }); + + Future savePriceHistory(List prices); +} diff --git a/lib/core/exchange/domain/usecases/create_log_attachment_usecase.dart b/lib/core/exchange/domain/usecases/create_log_attachment_usecase.dart new file mode 100644 index 000000000..a9b498d3e --- /dev/null +++ b/lib/core/exchange/domain/usecases/create_log_attachment_usecase.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:bb_mobile/core/errors/bull_exception.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message_attachment.dart'; +import 'package:bb_mobile/core/utils/logger.dart'; +import 'package:intl/intl.dart'; + +class CreateLogAttachmentUsecase { + Future execute() async { + try { + List logs; + try { + logs = await log.readLogs(); + } catch (e) { + logs = [ + 'timestamp\tlevel\tmessage\terror\ttrace', + '${DateTime.now().toIso8601String()}\tINFO\tNo logs file found or logs could not be read: $e\t\t', + ]; + } + + if (logs.isEmpty) { + logs = [ + 'timestamp\tlevel\tmessage\terror\ttrace', + '${DateTime.now().toIso8601String()}\tINFO\tNo logs have been recorded yet\t\t', + ]; + } + + final logContent = logs.join('\n'); + final bytes = Uint8List.fromList(utf8.encode(logContent)); + + final random = Random(); + final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + final randomAlphanumeric = String.fromCharCodes( + Iterable.generate( + 8, + (_) => chars.codeUnitAt(random.nextInt(chars.length)), + ), + ); + + final fileName = '$timestamp.BullLog.$randomAlphanumeric.txt'; + + return SupportChatMessageAttachment( + attachmentId: 'temp_logs_${DateTime.now().millisecondsSinceEpoch}', + fileName: fileName, + fileType: 'text/plain', + fileSize: bytes.length, + fileData: bytes, + createdAt: DateTime.now(), + ); + } catch (e) { + throw CreateLogAttachmentException('$e'); + } + } +} + +class CreateLogAttachmentException extends BullException { + CreateLogAttachmentException(super.message); +} diff --git a/lib/core/exchange/domain/usecases/get_announcements_usecase.dart b/lib/core/exchange/domain/usecases/get_announcements_usecase.dart new file mode 100644 index 000000000..1874347a7 --- /dev/null +++ b/lib/core/exchange/domain/usecases/get_announcements_usecase.dart @@ -0,0 +1,42 @@ +import 'package:bb_mobile/core/errors/bull_exception.dart'; +import 'package:bb_mobile/core/errors/exchange_errors.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/announcement.dart'; +import 'package:bb_mobile/core/exchange/domain/repositories/exchange_user_repository.dart'; +import 'package:bb_mobile/core/settings/data/settings_repository.dart'; + +class GetAnnouncementsUsecase { + final ExchangeUserRepository _mainnetExchangeUserRepository; + final ExchangeUserRepository _testnetExchangeUserRepository; + final SettingsRepository _settingsRepository; + + GetAnnouncementsUsecase({ + required ExchangeUserRepository mainnetExchangeUserRepository, + required ExchangeUserRepository testnetExchangeUserRepository, + required SettingsRepository settingsRepository, + }) : _mainnetExchangeUserRepository = mainnetExchangeUserRepository, + _testnetExchangeUserRepository = testnetExchangeUserRepository, + _settingsRepository = settingsRepository; + + Future> execute() async { + try { + final settings = await _settingsRepository.fetch(); + final isTestnet = settings.environment.isTestnet; + final repo = + isTestnet + ? _testnetExchangeUserRepository + : _mainnetExchangeUserRepository; + final announcements = await repo.listAnnouncements(); + return announcements; + } catch (e) { + if (e is ApiKeyException) { + rethrow; + } + throw GetAnnouncementsException('$e'); + } + } +} + +class GetAnnouncementsException extends BullException { + GetAnnouncementsException(super.message); +} + diff --git a/lib/core/exchange/domain/usecases/get_price_history_usecase.dart b/lib/core/exchange/domain/usecases/get_price_history_usecase.dart new file mode 100644 index 000000000..757bb883d --- /dev/null +++ b/lib/core/exchange/domain/usecases/get_price_history_usecase.dart @@ -0,0 +1,25 @@ +import 'package:bb_mobile/core/exchange/domain/entity/rate.dart'; +import 'package:bb_mobile/core/exchange/domain/repositories/price_repository.dart'; + +class GetPriceHistoryUsecase { + final PriceRepository _priceRepository; + + GetPriceHistoryUsecase({required PriceRepository priceRepository}) + : _priceRepository = priceRepository; + + Future> execute({ + required String fromCurrency, + required String toCurrency, + required RateTimelineInterval interval, + DateTime? fromDate, + DateTime? toDate, + }) async { + return await _priceRepository.getPriceHistory( + fromCurrency: fromCurrency, + toCurrency: toCurrency, + interval: interval, + fromDate: fromDate, + toDate: toDate, + ); + } +} diff --git a/lib/core/exchange/domain/usecases/get_support_chat_message_attachment_usecase.dart b/lib/core/exchange/domain/usecases/get_support_chat_message_attachment_usecase.dart new file mode 100644 index 000000000..c1b220188 --- /dev/null +++ b/lib/core/exchange/domain/usecases/get_support_chat_message_attachment_usecase.dart @@ -0,0 +1,36 @@ +import 'package:bb_mobile/core/errors/bull_exception.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message_attachment.dart'; +import 'package:bb_mobile/core/exchange/domain/repositories/exchange_support_chat_repository.dart'; +import 'package:bb_mobile/core/settings/data/settings_repository.dart'; + +class GetSupportChatMessageAttachmentUsecase { + final ExchangeSupportChatRepository _mainnetRepository; + final ExchangeSupportChatRepository _testnetRepository; + final SettingsRepository _settingsRepository; + + GetSupportChatMessageAttachmentUsecase({ + required ExchangeSupportChatRepository mainnetRepository, + required ExchangeSupportChatRepository testnetRepository, + required SettingsRepository settingsRepository, + }) : _mainnetRepository = mainnetRepository, + _testnetRepository = testnetRepository, + _settingsRepository = settingsRepository; + + Future execute(String attachmentId) async { + try { + final settings = await _settingsRepository.fetch(); + final isTestnet = settings.environment.isTestnet; + final repo = + isTestnet ? _testnetRepository : _mainnetRepository; + + return await repo.getMessageAttachment(attachmentId); + } catch (e) { + throw GetSupportChatMessageAttachmentException('$e'); + } + } +} + +class GetSupportChatMessageAttachmentException extends BullException { + GetSupportChatMessageAttachmentException(super.message); +} + diff --git a/lib/core/exchange/domain/usecases/get_support_chat_messages_usecase.dart b/lib/core/exchange/domain/usecases/get_support_chat_messages_usecase.dart new file mode 100644 index 000000000..e860731ea --- /dev/null +++ b/lib/core/exchange/domain/usecases/get_support_chat_messages_usecase.dart @@ -0,0 +1,42 @@ +import 'package:bb_mobile/core/errors/bull_exception.dart'; +import 'package:bb_mobile/core/exchange/domain/entity/support_chat_message.dart'; +import 'package:bb_mobile/core/exchange/domain/repositories/exchange_support_chat_repository.dart'; +import 'package:bb_mobile/core/settings/data/settings_repository.dart'; + +class GetSupportChatMessagesUsecase { + final ExchangeSupportChatRepository _mainnetRepository; + final ExchangeSupportChatRepository _testnetRepository; + final SettingsRepository _settingsRepository; + + GetSupportChatMessagesUsecase({ + required ExchangeSupportChatRepository mainnetRepository, + required ExchangeSupportChatRepository testnetRepository, + required SettingsRepository settingsRepository, + }) : _mainnetRepository = mainnetRepository, + _testnetRepository = testnetRepository, + _settingsRepository = settingsRepository; + + Future> execute({ + int? page, + int? pageSize, + }) async { + try { + final settings = await _settingsRepository.fetch(); + final isTestnet = settings.environment.isTestnet; + final repo = + isTestnet ? _testnetRepository : _mainnetRepository; + + return await repo.getMessages( + page: page, + pageSize: pageSize, + ); + } catch (e) { + throw GetSupportChatMessagesException('$e'); + } + } +} + +class GetSupportChatMessagesException extends BullException { + GetSupportChatMessagesException(super.message); +} + diff --git a/lib/core/exchange/domain/usecases/label_exchange_orders_usecase.dart b/lib/core/exchange/domain/usecases/label_exchange_orders_usecase.dart new file mode 100644 index 000000000..ef9cd8a76 --- /dev/null +++ b/lib/core/exchange/domain/usecases/label_exchange_orders_usecase.dart @@ -0,0 +1,85 @@ +import 'package:bb_mobile/core/exchange/domain/entity/order.dart'; +import 'package:bb_mobile/core/exchange/domain/usecases/list_all_orders_usecase.dart'; +import 'package:bb_mobile/core/labels/data/label_datasource.dart'; +import 'package:bb_mobile/core/labels/domain/batch_labels_usecase.dart'; +import 'package:bb_mobile/core/labels/domain/label.dart'; +import 'package:bb_mobile/core/labels/label_system.dart'; +import 'package:bb_mobile/core/utils/logger.dart'; + +class LabelExchangeOrdersUsecase { + final LabelDatasource _labelDatasource; + final BatchLabelsUsecase _batchLabelsUsecase; + final ListAllOrdersUsecase _listAllOrdersUsecase; + + LabelExchangeOrdersUsecase({ + required LabelDatasource labelDatasource, + required BatchLabelsUsecase batchLabelsUsecase, + required ListAllOrdersUsecase listAllOrdersUsecase, + }) : _labelDatasource = labelDatasource, + _batchLabelsUsecase = batchLabelsUsecase, + _listAllOrdersUsecase = listAllOrdersUsecase; + + Future execute() async { + try { + final hasExchangeLabels = await _hasExistingExchangeSystemLabels(); + if (hasExchangeLabels) return; // orders likely already labeled + + final orders = await _listAllOrdersUsecase.execute(); + if (orders.isEmpty) return; // no orders to label + + log.config('$LabelExchangeOrdersUsecase is labeling exchange orders'); + + final labels =