From 2db1775d1f0268af8aea80480fe337669e184c03 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 20 Dec 2025 02:14:01 +0100 Subject: [PATCH] starting with in app purchases --- ios/Podfile.lock | 115 ++-- ios/Runner.xcodeproj/project.pbxproj | 4 + lib/app.dart | 7 +- lib/main.dart | 10 +- lib/src/constants/subscription.keys.dart | 6 + lib/src/localization/app_de.arb | 20 +- lib/src/localization/app_en.arb | 14 +- .../generated/app_localizations.dart | 26 +- .../generated/app_localizations_de.dart | 26 +- .../generated/app_localizations_en.dart | 20 +- lib/src/model/json/userdata.dart | 5 + lib/src/model/json/userdata.g.dart | 4 + .../api/websocket/client_to_server.pb.dart | 172 +++++- .../websocket/client_to_server.pbjson.dart | 92 ++- .../api/websocket/server_to_client.pb.dart | 79 ++- .../websocket/server_to_client.pbjson.dart | 36 +- .../model/purchases/purchasable_product.dart | 13 + lib/src/providers/connection.provider.dart | 7 - lib/src/providers/purchases.provider.dart | 170 ++++++ lib/src/services/api.service.dart | 86 ++- lib/src/services/api/server_messages.dart | 14 +- lib/src/services/fcm.service.dart | 61 +- lib/src/utils/storage.dart | 6 +- lib/src/views/chats/chat_list.view.dart | 3 +- .../views/components/media_view_sizing.dart | 3 + lib/src/views/settings/account.view.dart | 4 +- .../settings/account/refund_credits.view.dart | 2 +- .../subscription/additional_users.view.dart | 2 +- .../subscription/subscription.view.dart | 508 +++++----------- .../checkout.view.dart | 30 +- .../manage_subscription.view.dart | 7 +- .../select_payment.view.dart | 30 +- .../subscription.view.dart | 570 ++++++++++++++++++ .../transaction.view.dart | 0 .../voucher.view.dart | 0 pubspec.lock | 24 +- pubspec.yaml | 6 +- 37 files changed, 1585 insertions(+), 597 deletions(-) create mode 100644 lib/src/constants/subscription.keys.dart create mode 100644 lib/src/model/purchases/purchasable_product.dart create mode 100644 lib/src/providers/purchases.provider.dart rename lib/src/views/settings/{subscription => subscription_custom}/checkout.view.dart (77%) rename lib/src/views/settings/{subscription => subscription_custom}/manage_subscription.view.dart (93%) rename lib/src/views/settings/{subscription => subscription_custom}/select_payment.view.dart (89%) create mode 100644 lib/src/views/settings/subscription_custom/subscription.view.dart rename lib/src/views/settings/{subscription => subscription_custom}/transaction.view.dart (100%) rename lib/src/views/settings/{subscription => subscription_custom}/voucher.view.dart (100%) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 26c39fa..a6414db 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -54,55 +54,55 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter - - Firebase (12.4.0): - - Firebase/Core (= 12.4.0) - - Firebase/Core (12.4.0): + - Firebase (12.6.0): + - Firebase/Core (= 12.6.0) + - Firebase/Core (12.6.0): - Firebase/CoreOnly - - FirebaseAnalytics (~> 12.4.0) - - Firebase/CoreOnly (12.4.0): - - FirebaseCore (~> 12.4.0) - - Firebase/Messaging (12.4.0): + - FirebaseAnalytics (~> 12.6.0) + - Firebase/CoreOnly (12.6.0): + - FirebaseCore (~> 12.6.0) + - Firebase/Messaging (12.6.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 12.4.0) - - firebase_core (4.2.1): - - Firebase/CoreOnly (= 12.4.0) + - FirebaseMessaging (~> 12.6.0) + - firebase_core (4.3.0): + - Firebase/CoreOnly (= 12.6.0) - Flutter - - firebase_messaging (16.0.4): - - Firebase/Messaging (= 12.4.0) + - firebase_messaging (16.1.0): + - Firebase/Messaging (= 12.6.0) - firebase_core - Flutter - - FirebaseAnalytics (12.4.0): - - FirebaseAnalytics/Default (= 12.4.0) - - FirebaseCore (~> 12.4.0) - - FirebaseInstallations (~> 12.4.0) + - FirebaseAnalytics (12.6.0): + - FirebaseAnalytics/Default (= 12.6.0) + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseAnalytics/Default (12.4.0): - - FirebaseCore (~> 12.4.0) - - FirebaseInstallations (~> 12.4.0) - - GoogleAppMeasurement/Default (= 12.4.0) + - FirebaseAnalytics/Default (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) + - GoogleAppMeasurement/Default (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - FirebaseCore (12.4.0): - - FirebaseCoreInternal (~> 12.4.0) + - FirebaseCore (12.6.0): + - FirebaseCoreInternal (~> 12.6.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - - FirebaseCoreInternal (12.4.0): + - FirebaseCoreInternal (12.6.0): - "GoogleUtilities/NSData+zlib (~> 8.1)" - - FirebaseInstallations (12.4.0): - - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (12.6.0): + - FirebaseCore (~> 12.6.0) - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1) - PromisesObjC (~> 2.4) - - FirebaseMessaging (12.4.0): - - FirebaseCore (~> 12.4.0) - - FirebaseInstallations (~> 12.4.0) + - FirebaseMessaging (12.6.0): + - FirebaseCore (~> 12.6.0) + - FirebaseInstallations (~> 12.6.0) - GoogleDataTransport (~> 10.1) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/Environment (~> 8.1) @@ -134,28 +134,28 @@ PODS: - google_mlkit_commons (0.11.0): - Flutter - MLKitVision - - GoogleAdsOnDeviceConversion (3.1.0): + - GoogleAdsOnDeviceConversion (3.2.0): - GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Network (~> 8.1) - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Core (12.4.0): + - GoogleAppMeasurement/Core (12.6.0): - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/Default (12.4.0): - - GoogleAdsOnDeviceConversion (~> 3.1.0) - - GoogleAppMeasurement/Core (= 12.4.0) - - GoogleAppMeasurement/IdentitySupport (= 12.4.0) + - GoogleAppMeasurement/Default (12.6.0): + - GoogleAdsOnDeviceConversion (~> 3.2.0) + - GoogleAppMeasurement/Core (= 12.6.0) + - GoogleAppMeasurement/IdentitySupport (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) - "GoogleUtilities/NSData+zlib (~> 8.1)" - nanopb (~> 3.30910.0) - - GoogleAppMeasurement/IdentitySupport (12.4.0): - - GoogleAppMeasurement/Core (= 12.4.0) + - GoogleAppMeasurement/IdentitySupport (12.6.0): + - GoogleAppMeasurement/Core (= 12.6.0) - GoogleUtilities/AppDelegateSwizzler (~> 8.1) - GoogleUtilities/MethodSwizzler (~> 8.1) - GoogleUtilities/Network (~> 8.1) @@ -217,6 +217,9 @@ PODS: - GTMSessionFetcher/Core (3.5.0) - image_picker_ios (0.0.1): - Flutter + - in_app_purchase_storekit (0.0.1): + - Flutter + - FlutterMacOS - libwebp (1.5.0): - libwebp/demux (= 1.5.0) - libwebp/mux (= 1.5.0) @@ -273,10 +276,10 @@ PODS: - restart_app (0.0.1): - Flutter - ScreenProtectorKit (1.3.1) - - SDWebImage (5.21.3): - - SDWebImage/Core (= 5.21.3) - - SDWebImage/Core (5.21.3) - - SDWebImageWebPCoder (0.14.6): + - SDWebImage (5.21.5): + - SDWebImage/Core (= 5.21.5) + - SDWebImage/Core (5.21.5) + - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - Sentry/HybridSDK (8.56.2) @@ -317,7 +320,7 @@ PODS: - sqlite3/perf-threadsafe - sqlite3/rtree - sqlite3/session - - SwiftProtobuf (1.33.1) + - SwiftProtobuf (1.33.3) - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter @@ -353,6 +356,7 @@ DEPENDENCIES: - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) - GoogleUtilities - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - no_screenshot (from `.symlinks/plugins/no_screenshot/ios`) - objective_c (from `.symlinks/plugins/objective_c/ios`) @@ -447,6 +451,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_mlkit_commons/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + in_app_purchase_storekit: + :path: ".symlinks/plugins/in_app_purchase_storekit/darwin" local_auth_darwin: :path: ".symlinks/plugins/local_auth_darwin/darwin" no_screenshot: @@ -489,14 +495,14 @@ SPEC CHECKSUMS: emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc ffmpeg_kit_flutter_new: 12426a19f10ac81186c67c6ebc4717f8f4364b7f file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e - firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594 - firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde - FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f - FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3 - FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6 - FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2 - FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5 + Firebase: a451a7b61536298fd5cbfe3a746fd40443a50679 + firebase_core: ba00a168e719694f38960502ceb560285603d073 + firebase_messaging: bf0e29321927edc02a563c984dbfa5b063864b15 + FirebaseAnalytics: d0a97a0db6425e5a5d966340b87f92ca7b13a557 + FirebaseCore: 0e38ad5d62d980a47a64b8e9301ffa311457be04 + FirebaseCoreInternal: 69bf1306a05b8ac43004f6cc1f804bb7b05b229e + FirebaseInstallations: 631b38da2e11a83daa4bfb482f79d286a5dfa7ad + FirebaseMessaging: a61bc42dcab3f7a346d94bbb54dab2c9435b18b2 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 flutter_keyboard_visibility_temp_fork: 95b2d534bacf6ac62e7fcbe5c2a9e2c2a17ce06f @@ -506,14 +512,15 @@ SPEC CHECKSUMS: gal: baecd024ebfd13c441269ca7404792a7152fde89 google_mlkit_barcode_scanning: 8f5987f244a43fe1167689c548342a5174108159 google_mlkit_commons: 2abe6a70e1824e431d16a51085cb475b672c8aab - GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1 - GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d + GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f + GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318 GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d @@ -530,8 +537,8 @@ SPEC CHECKSUMS: PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 restart_app: 9cda5378aacc5000e3f66ee76a9201534e7d3ecf ScreenProtectorKit: 83a6281b02c7a5902ee6eac4f5045f674e902ae4 - SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a - SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 + SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 + SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7 sentry_flutter: 4c33648b7e83310aa1fdb1b10c5491027d9643f0 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a @@ -539,7 +546,7 @@ SPEC CHECKSUMS: sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: 73513155ec6979715d3904ef53a8d68892d4032b sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 - SwiftProtobuf: 533a18409c3ca3a6156b2b1e46afd0f69e751aba + SwiftProtobuf: e1b437c8e31a4c5577b643249a0bb62ed4f02153 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 2997989..fb716f3 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; CA4FDF5DD8F229C30DE512AF /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EE2CCFEE4ABECF33852F7735 /* Pods_Runner.framework */; }; D21FCEAB2D9F2B750088701D /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D21FCEA42D9F2B750088701D /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D25D4D1D2EF626E30029F805 /* StoreKit.framework */; }; F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; }; /* End PBXBuildFile section */ @@ -89,6 +90,7 @@ B3B27B7FBEEA31DB7793A0C2 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; D21FCEA42D9F2B750088701D /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D2265DD42D920142000D99BB /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + D25D4D1D2EF626E30029F805 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; DC1EE71614E1B4F84D6FDC2D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E96A5ACA32A7118204F050A5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; EE2CCFEE4ABECF33852F7735 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -130,6 +132,7 @@ buildActionMask = 2147483647; files = ( CA4FDF5DD8F229C30DE512AF /* Pods_Runner.framework in Frameworks */, + D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -206,6 +209,7 @@ E5079CCEE4804DB65AA3F23F /* Frameworks */ = { isa = PBXGroup; children = ( + D25D4D1D2EF626E30029F805 /* StoreKit.framework */, A198C9B5D90584C4F96206B2 /* Pods_NotificationService.framework */, EE2CCFEE4ABECF33852F7735 /* Pods_Runner.framework */, DC1EE71614E1B4F84D6FDC2D /* Pods_RunnerTests.framework */, diff --git a/lib/app.dart b/lib/app.dart index 2a2757f..4fd9ef9 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; import 'package:twonly/src/providers/connection.provider.dart'; +import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/settings.provider.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/utils/log.dart'; @@ -39,8 +40,8 @@ class _AppState extends State with WidgetsBindingObserver { await setUserPlan(); }; - globalCallbackUpdatePlan = (SubscriptionPlan plan) async { - await context.read().updatePlan(plan); + globalCallbackUpdatePlan = (SubscriptionPlan plan) { + context.read().updatePlan(plan); }; unawaited(initAsync()); @@ -50,7 +51,7 @@ class _AppState extends State with WidgetsBindingObserver { final user = await getUser(); if (user != null && mounted) { if (mounted) { - await context.read().updatePlan( + context.read().updatePlan( planFromString(user.subscriptionPlan), ); } diff --git a/lib/main.dart b/lib/main.dart index 4bd72cd..2140a8d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/providers/connection.provider.dart'; import 'package:twonly/src/providers/image_editor.provider.dart'; +import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/settings.provider.dart'; import 'package:twonly/src/services/api.service.dart'; import 'package:twonly/src/services/api/mediafiles/media_background.service.dart'; @@ -25,6 +26,8 @@ import 'package:twonly/src/utils/storage.dart'; void main() async { SentryWidgetsFlutterBinding.ensureInitialized(); + await initFCMService(); + final user = await getUser(); if (user != null) { gUser = user; @@ -43,8 +46,6 @@ void main() async { unawaited(performTwonlySafeBackup()); } - await initFCMService(); - globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path; globalApplicationSupportDirectory = (await getApplicationSupportDirectory()).path; @@ -54,9 +55,7 @@ void main() async { final settingsController = SettingsChangeProvider(); await settingsController.loadSettings(); - await SystemChrome.setPreferredOrientations( - [DeviceOrientation.portraitUp], - ); + await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); unawaited(setupPushNotification()); @@ -79,6 +78,7 @@ void main() async { ChangeNotifierProvider(create: (_) => settingsController), ChangeNotifierProvider(create: (_) => CustomChangeProvider()), ChangeNotifierProvider(create: (_) => ImageEditorProvider()), + ChangeNotifierProvider(create: (_) => PurchasesProvider()), ], child: const App(), ), diff --git a/lib/src/constants/subscription.keys.dart b/lib/src/constants/subscription.keys.dart new file mode 100644 index 0000000..eb7c850 --- /dev/null +++ b/lib/src/constants/subscription.keys.dart @@ -0,0 +1,6 @@ +class SubscriptionKeys { + static const String proMonthly = 'pro_monthly'; + static const String proYearly = 'pro_yearly'; + // static const String familyMonthly = 'family_monthly'; + static const String familyYearly = 'family_yearly'; +} diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 013022c..d06e5f2 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -197,7 +197,7 @@ "errorSessionNotAuthenticated": "Deine Sitzung ist nicht authentifiziert. Bitte melde dich an, um fortzufahren.", "errorOnlyOneSessionAllowed": "Es ist nur eine aktive Sitzung pro Benutzer erlaubt. Bitte melde dich von anderen Geräten ab, um fortzufahren.", "upgradeToPaidPlan": "Upgrade auf einen kostenpflichtigen Plan.", - "upgradeToPaidPlanButton": "Auf {planId} upgraden", + "upgradeToPaidPlanButton": "Auf {planId} upgraden{sufix}", "partOfPaidPlanOf": "Du bist Teil des bezahlten Plans von {username}!", "errorNotEnoughCredit": "Du hast nicht genügend twonly-Guthaben.", "errorPlanLimitReached": "Du hast das Limit deines Plans erreicht. Bitte upgrade deinen Plan.", @@ -205,17 +205,19 @@ "errorVoucherInvalid": "Der eingegebene Gutschein-Code ist nicht gültig.", "errorPlanUpgradeNotYearly": "Das Upgrade des Plans muss jährlich bezahlt werden, da der aktuelle Plan ebenfalls jährlich abgerechnet wird.", "proFeature1": "✓ Unbegrenzte Medien-Datei-Uploads", - "proFeature2": "1 zusätzlicher Plus Benutzer", - "proFeature3": "Flammen wiederherstellen", - "proFeature4": "Cloud-Backup verschlüsselt (coming-soon)", - "year": "year", - "month": "month", + "proFeature2": "✓ 1 zusätzlicher Plus Benutzer", + "proFeature3": "✓ Flammen wiederherstellen", + "proFeature4": "✓ Cloud-Backup verschlüsselt (coming-soon)", + "year": "Jahr", + "month": "Monat", + "yearly": "Jährlich", + "monthly": "Monatlich", "familyFeature1": "✓ Alles von Pro", - "familyFeature2": "4 zusätzliche Plus Benutzer", + "familyFeature2": "✓ 4 zusätzliche Plus Benutzer", "redeemUserInviteCode": "Oder löse einen twonly-Code ein.", - "freeFeature1": "10 Medien-Datei-Uploads pro Tag", + "freeFeature1": "✓ 10 Medien-Datei-Uploads pro Tag", "plusFeature1": "✓ Unbegrenzte Medien-Datei-Uploads", - "plusFeature2": "Zusatzfunktionen (coming-soon)", + "plusFeature2": "✓ Zusatzfunktionen (coming-soon)", "transactionHistory": "Transaktionshistorie", "currentBalance": "Dein Guthaben", "manageAdditionalUsers": "Zusätzliche Benutzer verwalten", diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index ac7128d..fcd8e6c 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -232,22 +232,24 @@ "errorPlanNotAllowed": "This feature is not available in your current plan.", "errorPlanUpgradeNotYearly": "The plan upgrade must be paid for annually, as the current plan is also billed annually.", "upgradeToPaidPlan": "Upgrade to a paid plan.", - "upgradeToPaidPlanButton": "Upgrade subscription to {planId}", + "upgradeToPaidPlanButton": "Upgrade to {planId}{sufix}", "partOfPaidPlanOf": "You are part of the paid plan of {username}!", "year": "year", + "yearly": "Yearly", "month": "month", + "monthly": "Monthly", "proFeature1": "✓ Unlimited media file uploads", - "proFeature2": "1 additional Plus user", - "proFeature3": "Cloud-Backup encrypted (coming-soon)", + "proFeature2": "✓ 1 additional Plus user", + "proFeature3": "✓ Cloud-Backup encrypted (coming-soon)", "proFeature4": "Additional features (coming-soon)", "familyFeature1": "✓ All from Pro", - "familyFeature2": "4 additional Plus users", + "familyFeature2": "✓ 4 additional Plus users", "redeemUserInviteCode": "Or redeem a twonly-Code.", "redeemUserInviteCodeTitle": "Redeem twonly-Code", "redeemUserInviteCodeSuccess": "Your plan has been successfully adjusted.", - "freeFeature1": "10 Media file uploads per day", + "freeFeature1": "✓ 10 Media file uploads per day", "plusFeature1": "✓ Unlimited media file uploads", - "plusFeature2": "Additional features (coming-soon)", + "plusFeature2": "✓ Additional features (coming-soon)", "transactionHistory": "Your transaction history", "manageSubscription": "Manage your subscription", "nextPayment": "Next payment", diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 9aaf1e0..eb37a94 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1313,8 +1313,8 @@ abstract class AppLocalizations { /// No description provided for @upgradeToPaidPlanButton. /// /// In en, this message translates to: - /// **'Upgrade subscription to {planId}'** - String upgradeToPaidPlanButton(Object planId); + /// **'Upgrade to {planId}{sufix}'** + String upgradeToPaidPlanButton(Object planId, Object sufix); /// No description provided for @partOfPaidPlanOf. /// @@ -1328,12 +1328,24 @@ abstract class AppLocalizations { /// **'year'** String get year; + /// No description provided for @yearly. + /// + /// In en, this message translates to: + /// **'Yearly'** + String get yearly; + /// No description provided for @month. /// /// In en, this message translates to: /// **'month'** String get month; + /// No description provided for @monthly. + /// + /// In en, this message translates to: + /// **'Monthly'** + String get monthly; + /// No description provided for @proFeature1. /// /// In en, this message translates to: @@ -1343,13 +1355,13 @@ abstract class AppLocalizations { /// No description provided for @proFeature2. /// /// In en, this message translates to: - /// **'1 additional Plus user'** + /// **'✓ 1 additional Plus user'** String get proFeature2; /// No description provided for @proFeature3. /// /// In en, this message translates to: - /// **'Cloud-Backup encrypted (coming-soon)'** + /// **'✓ Cloud-Backup encrypted (coming-soon)'** String get proFeature3; /// No description provided for @proFeature4. @@ -1367,7 +1379,7 @@ abstract class AppLocalizations { /// No description provided for @familyFeature2. /// /// In en, this message translates to: - /// **'4 additional Plus users'** + /// **'✓ 4 additional Plus users'** String get familyFeature2; /// No description provided for @redeemUserInviteCode. @@ -1391,7 +1403,7 @@ abstract class AppLocalizations { /// No description provided for @freeFeature1. /// /// In en, this message translates to: - /// **'10 Media file uploads per day'** + /// **'✓ 10 Media file uploads per day'** String get freeFeature1; /// No description provided for @plusFeature1. @@ -1403,7 +1415,7 @@ abstract class AppLocalizations { /// No description provided for @plusFeature2. /// /// In en, this message translates to: - /// **'Additional features (coming-soon)'** + /// **'✓ Additional features (coming-soon)'** String get plusFeature2; /// No description provided for @transactionHistory. diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 075b646..20236e0 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -685,8 +685,8 @@ class AppLocalizationsDe extends AppLocalizations { String get upgradeToPaidPlan => 'Upgrade auf einen kostenpflichtigen Plan.'; @override - String upgradeToPaidPlanButton(Object planId) { - return 'Auf $planId upgraden'; + String upgradeToPaidPlanButton(Object planId, Object sufix) { + return 'Auf $planId upgraden$sufix'; } @override @@ -695,28 +695,34 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get year => 'year'; + String get year => 'Jahr'; @override - String get month => 'month'; + String get yearly => 'Jährlich'; + + @override + String get month => 'Monat'; + + @override + String get monthly => 'Monatlich'; @override String get proFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads'; @override - String get proFeature2 => '1 zusätzlicher Plus Benutzer'; + String get proFeature2 => '✓ 1 zusätzlicher Plus Benutzer'; @override - String get proFeature3 => 'Flammen wiederherstellen'; + String get proFeature3 => '✓ Flammen wiederherstellen'; @override - String get proFeature4 => 'Cloud-Backup verschlüsselt (coming-soon)'; + String get proFeature4 => '✓ Cloud-Backup verschlüsselt (coming-soon)'; @override String get familyFeature1 => '✓ Alles von Pro'; @override - String get familyFeature2 => '4 zusätzliche Plus Benutzer'; + String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer'; @override String get redeemUserInviteCode => 'Oder löse einen twonly-Code ein.'; @@ -729,13 +735,13 @@ class AppLocalizationsDe extends AppLocalizations { 'Dein Plan wurde erfolgreich angepasst.'; @override - String get freeFeature1 => '10 Medien-Datei-Uploads pro Tag'; + String get freeFeature1 => '✓ 10 Medien-Datei-Uploads pro Tag'; @override String get plusFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads'; @override - String get plusFeature2 => 'Zusatzfunktionen (coming-soon)'; + String get plusFeature2 => '✓ Zusatzfunktionen (coming-soon)'; @override String get transactionHistory => 'Transaktionshistorie'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index d465133..2137aba 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -679,8 +679,8 @@ class AppLocalizationsEn extends AppLocalizations { String get upgradeToPaidPlan => 'Upgrade to a paid plan.'; @override - String upgradeToPaidPlanButton(Object planId) { - return 'Upgrade subscription to $planId'; + String upgradeToPaidPlanButton(Object planId, Object sufix) { + return 'Upgrade to $planId$sufix'; } @override @@ -691,17 +691,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get year => 'year'; + @override + String get yearly => 'Yearly'; + @override String get month => 'month'; + @override + String get monthly => 'Monthly'; + @override String get proFeature1 => '✓ Unlimited media file uploads'; @override - String get proFeature2 => '1 additional Plus user'; + String get proFeature2 => '✓ 1 additional Plus user'; @override - String get proFeature3 => 'Cloud-Backup encrypted (coming-soon)'; + String get proFeature3 => '✓ Cloud-Backup encrypted (coming-soon)'; @override String get proFeature4 => 'Additional features (coming-soon)'; @@ -710,7 +716,7 @@ class AppLocalizationsEn extends AppLocalizations { String get familyFeature1 => '✓ All from Pro'; @override - String get familyFeature2 => '4 additional Plus users'; + String get familyFeature2 => '✓ 4 additional Plus users'; @override String get redeemUserInviteCode => 'Or redeem a twonly-Code.'; @@ -723,13 +729,13 @@ class AppLocalizationsEn extends AppLocalizations { 'Your plan has been successfully adjusted.'; @override - String get freeFeature1 => '10 Media file uploads per day'; + String get freeFeature1 => '✓ 10 Media file uploads per day'; @override String get plusFeature1 => '✓ Unlimited media file uploads'; @override - String get plusFeature2 => 'Additional features (coming-soon)'; + String get plusFeature2 => '✓ Additional features (coming-soon)'; @override String get transactionHistory => 'Your transaction history'; diff --git a/lib/src/model/json/userdata.dart b/lib/src/model/json/userdata.dart index 8d6c406..94308f3 100644 --- a/lib/src/model/json/userdata.dart +++ b/lib/src/model/json/userdata.dart @@ -41,6 +41,8 @@ class UserData { @JsonKey(defaultValue: 'Free') String subscriptionPlan; + + String? subscriptionPlanIdStore; DateTime? lastImageSend; int? todaysImageCounter; @@ -89,6 +91,9 @@ class UserData { @JsonKey(defaultValue: false) bool hideChangeLog = false; + @JsonKey(defaultValue: true) + bool updateFCMToken = true; + // --- BACKUP --- DateTime? nextTimeToShowBackupNotice; diff --git a/lib/src/model/json/userdata.g.dart b/lib/src/model/json/userdata.g.dart index 1cceece..4816031 100644 --- a/lib/src/model/json/userdata.g.dart +++ b/lib/src/model/json/userdata.g.dart @@ -20,6 +20,7 @@ UserData _$UserDataFromJson(Map json) => UserData( ..disableVideoCompression = json['disableVideoCompression'] as bool? ?? false ..deviceId = (json['deviceId'] as num?)?.toInt() ?? 0 + ..subscriptionPlanIdStore = json['subscriptionPlanIdStore'] as String? ..lastImageSend = json['lastImageSend'] == null ? null : DateTime.parse(json['lastImageSend'] as String) @@ -61,6 +62,7 @@ UserData _$UserDataFromJson(Map json) => UserData( ?.map((e) => (e as num).toInt()) .toList() ..hideChangeLog = json['hideChangeLog'] as bool? ?? false + ..updateFCMToken = json['updateFCMToken'] as bool? ?? true ..nextTimeToShowBackupNotice = json['nextTimeToShowBackupNotice'] == null ? null : DateTime.parse(json['nextTimeToShowBackupNotice'] as String) @@ -84,6 +86,7 @@ Map _$UserDataToJson(UserData instance) => { 'disableVideoCompression': instance.disableVideoCompression, 'deviceId': instance.deviceId, 'subscriptionPlan': instance.subscriptionPlan, + 'subscriptionPlanIdStore': instance.subscriptionPlanIdStore, 'lastImageSend': instance.lastImageSend?.toIso8601String(), 'todaysImageCounter': instance.todaysImageCounter, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, @@ -104,6 +107,7 @@ Map _$UserDataToJson(UserData instance) => { 'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart, 'lastChangeLogHash': instance.lastChangeLogHash, 'hideChangeLog': instance.hideChangeLog, + 'updateFCMToken': instance.updateFCMToken, 'nextTimeToShowBackupNotice': instance.nextTimeToShowBackupNotice?.toIso8601String(), 'backupServer': instance.backupServer, diff --git a/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart b/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart index 3e3aca4..610971e 100644 --- a/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart +++ b/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart @@ -2085,6 +2085,136 @@ class ApplicationData_ReportUser extends $pb.GeneratedMessage { void clearReason() => $_clearField(2); } +class ApplicationData_IPAPurchase extends $pb.GeneratedMessage { + factory ApplicationData_IPAPurchase({ + $core.String? productId, + $core.String? source, + $core.String? verificationData, + }) { + final result = create(); + if (productId != null) result.productId = productId; + if (source != null) result.source = source; + if (verificationData != null) result.verificationData = verificationData; + return result; + } + + ApplicationData_IPAPurchase._(); + + factory ApplicationData_IPAPurchase.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ApplicationData_IPAPurchase.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ApplicationData.IPAPurchase', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'client_to_server'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'productId') + ..aOS(2, _omitFieldNames ? '' : 'source') + ..aOS(3, _omitFieldNames ? '' : 'verificationData') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ApplicationData_IPAPurchase clone() => + ApplicationData_IPAPurchase()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ApplicationData_IPAPurchase copyWith( + void Function(ApplicationData_IPAPurchase) updates) => + super.copyWith( + (message) => updates(message as ApplicationData_IPAPurchase)) + as ApplicationData_IPAPurchase; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ApplicationData_IPAPurchase create() => + ApplicationData_IPAPurchase._(); + @$core.override + ApplicationData_IPAPurchase createEmptyInstance() => create(); + static $pb.PbList createRepeated() => + $pb.PbList(); + @$core.pragma('dart2js:noInline') + static ApplicationData_IPAPurchase getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static ApplicationData_IPAPurchase? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get productId => $_getSZ(0); + @$pb.TagNumber(1) + set productId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasProductId() => $_has(0); + @$pb.TagNumber(1) + void clearProductId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get source => $_getSZ(1); + @$pb.TagNumber(2) + set source($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasSource() => $_has(1); + @$pb.TagNumber(2) + void clearSource() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get verificationData => $_getSZ(2); + @$pb.TagNumber(3) + set verificationData($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasVerificationData() => $_has(2); + @$pb.TagNumber(3) + void clearVerificationData() => $_clearField(3); +} + +class ApplicationData_IPAForceCheck extends $pb.GeneratedMessage { + factory ApplicationData_IPAForceCheck() => create(); + + ApplicationData_IPAForceCheck._(); + + factory ApplicationData_IPAForceCheck.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory ApplicationData_IPAForceCheck.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'ApplicationData.IPAForceCheck', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'client_to_server'), + createEmptyInstance: create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ApplicationData_IPAForceCheck clone() => + ApplicationData_IPAForceCheck()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + ApplicationData_IPAForceCheck copyWith( + void Function(ApplicationData_IPAForceCheck) updates) => + super.copyWith( + (message) => updates(message as ApplicationData_IPAForceCheck)) + as ApplicationData_IPAForceCheck; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static ApplicationData_IPAForceCheck create() => + ApplicationData_IPAForceCheck._(); + @$core.override + ApplicationData_IPAForceCheck createEmptyInstance() => create(); + static $pb.PbList createRepeated() => + $pb.PbList(); + @$core.pragma('dart2js:noInline') + static ApplicationData_IPAForceCheck getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static ApplicationData_IPAForceCheck? _defaultInstance; +} + class ApplicationData_DeleteAccount extends $pb.GeneratedMessage { factory ApplicationData_DeleteAccount() => create(); @@ -2153,6 +2283,8 @@ enum ApplicationData_ApplicationData { deleteAccount, reportUser, changeUsername, + ipaPurchase, + ipaForceCheck, notSet } @@ -2180,6 +2312,8 @@ class ApplicationData extends $pb.GeneratedMessage { ApplicationData_DeleteAccount? deleteAccount, ApplicationData_ReportUser? reportUser, ApplicationData_ChangeUsername? changeUsername, + ApplicationData_IPAPurchase? ipaPurchase, + ApplicationData_IPAForceCheck? ipaForceCheck, }) { final result = create(); if (textMessage != null) result.textMessage = textMessage; @@ -2212,6 +2346,8 @@ class ApplicationData extends $pb.GeneratedMessage { if (deleteAccount != null) result.deleteAccount = deleteAccount; if (reportUser != null) result.reportUser = reportUser; if (changeUsername != null) result.changeUsername = changeUsername; + if (ipaPurchase != null) result.ipaPurchase = ipaPurchase; + if (ipaForceCheck != null) result.ipaForceCheck = ipaForceCheck; return result; } @@ -2248,6 +2384,8 @@ class ApplicationData extends $pb.GeneratedMessage { 24: ApplicationData_ApplicationData.deleteAccount, 25: ApplicationData_ApplicationData.reportUser, 26: ApplicationData_ApplicationData.changeUsername, + 27: ApplicationData_ApplicationData.ipaPurchase, + 28: ApplicationData_ApplicationData.ipaForceCheck, 0: ApplicationData_ApplicationData.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -2277,7 +2415,9 @@ class ApplicationData extends $pb.GeneratedMessage { 23, 24, 25, - 26 + 26, + 27, + 28 ]) ..aOM(1, _omitFieldNames ? '' : 'textMessage', protoName: 'textMessage', @@ -2361,6 +2501,13 @@ class ApplicationData extends $pb.GeneratedMessage { 26, _omitFieldNames ? '' : 'changeUsername', protoName: 'changeUsername', subBuilder: ApplicationData_ChangeUsername.create) + ..aOM(27, _omitFieldNames ? '' : 'ipaPurchase', + protoName: 'ipaPurchase', + subBuilder: ApplicationData_IPAPurchase.create) + ..aOM( + 28, _omitFieldNames ? '' : 'ipaForceCheck', + protoName: 'ipaForceCheck', + subBuilder: ApplicationData_IPAForceCheck.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -2652,6 +2799,29 @@ class ApplicationData extends $pb.GeneratedMessage { void clearChangeUsername() => $_clearField(26); @$pb.TagNumber(26) ApplicationData_ChangeUsername ensureChangeUsername() => $_ensure(21); + + @$pb.TagNumber(27) + ApplicationData_IPAPurchase get ipaPurchase => $_getN(22); + @$pb.TagNumber(27) + set ipaPurchase(ApplicationData_IPAPurchase value) => $_setField(27, value); + @$pb.TagNumber(27) + $core.bool hasIpaPurchase() => $_has(22); + @$pb.TagNumber(27) + void clearIpaPurchase() => $_clearField(27); + @$pb.TagNumber(27) + ApplicationData_IPAPurchase ensureIpaPurchase() => $_ensure(22); + + @$pb.TagNumber(28) + ApplicationData_IPAForceCheck get ipaForceCheck => $_getN(23); + @$pb.TagNumber(28) + set ipaForceCheck(ApplicationData_IPAForceCheck value) => + $_setField(28, value); + @$pb.TagNumber(28) + $core.bool hasIpaForceCheck() => $_has(23); + @$pb.TagNumber(28) + void clearIpaForceCheck() => $_clearField(28); + @$pb.TagNumber(28) + ApplicationData_IPAForceCheck ensureIpaForceCheck() => $_ensure(23); } class Response_PreKey extends $pb.GeneratedMessage { diff --git a/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart b/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart index 63581df..d3353af 100644 --- a/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart +++ b/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart @@ -461,6 +461,24 @@ const ApplicationData$json = { '9': 0, '10': 'changeUsername' }, + { + '1': 'ipaPurchase', + '3': 27, + '4': 1, + '5': 11, + '6': '.client_to_server.ApplicationData.IPAPurchase', + '9': 0, + '10': 'ipaPurchase' + }, + { + '1': 'ipaForceCheck', + '3': 28, + '4': 1, + '5': 11, + '6': '.client_to_server.ApplicationData.IPAForceCheck', + '9': 0, + '10': 'ipaForceCheck' + }, ], '3': [ ApplicationData_TextMessage$json, @@ -484,6 +502,8 @@ const ApplicationData$json = { ApplicationData_UpdateSignedPreKey$json, ApplicationData_DownloadDone$json, ApplicationData_ReportUser$json, + ApplicationData_IPAPurchase$json, + ApplicationData_IPAForceCheck$json, ApplicationData_DeleteAccount$json ], '8': [ @@ -668,6 +688,27 @@ const ApplicationData_ReportUser$json = { ], }; +@$core.Deprecated('Use applicationDataDescriptor instead') +const ApplicationData_IPAPurchase$json = { + '1': 'IPAPurchase', + '2': [ + {'1': 'product_id', '3': 1, '4': 1, '5': 9, '10': 'productId'}, + {'1': 'source', '3': 2, '4': 1, '5': 9, '10': 'source'}, + { + '1': 'verification_data', + '3': 3, + '4': 1, + '5': 9, + '10': 'verificationData' + }, + ], +}; + +@$core.Deprecated('Use applicationDataDescriptor instead') +const ApplicationData_IPAForceCheck$json = { + '1': 'IPAForceCheck', +}; + @$core.Deprecated('Use applicationDataDescriptor instead') const ApplicationData_DeleteAccount$json = { '1': 'DeleteAccount', @@ -713,29 +754,34 @@ final $typed_data.Uint8List applicationDataDescriptor = $convert.base64Decode( 'NhdGlvbkRhdGEuRGVsZXRlQWNjb3VudEgAUg1kZWxldGVBY2NvdW50Ek4KCnJlcG9ydFVzZXIY' 'GSABKAsyLC5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5SZXBvcnRVc2VySABSCn' 'JlcG9ydFVzZXISWgoOY2hhbmdlVXNlcm5hbWUYGiABKAsyMC5jbGllbnRfdG9fc2VydmVyLkFw' - 'cGxpY2F0aW9uRGF0YS5DaGFuZ2VVc2VybmFtZUgAUg5jaGFuZ2VVc2VybmFtZRpqCgtUZXh0TW' - 'Vzc2FnZRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSEgoEYm9keRgDIAEoDFIEYm9keRIgCglw' - 'dXNoX2RhdGEYBCABKAxIAFIIcHVzaERhdGGIAQFCDAoKX3B1c2hfZGF0YRovChFHZXRVc2VyQn' - 'lVc2VybmFtZRIaCgh1c2VybmFtZRgBIAEoCVIIdXNlcm5hbWUaLAoOQ2hhbmdlVXNlcm5hbWUS' - 'GgoIdXNlcm5hbWUYASABKAlSCHVzZXJuYW1lGjUKFFVwZGF0ZUdvb2dsZUZjbVRva2VuEh0KCm' - 'dvb2dsZV9mY20YASABKAlSCWdvb2dsZUZjbRomCgtHZXRVc2VyQnlJZBIXCgd1c2VyX2lkGAEg' - 'ASgDUgZ1c2VySWQaKQoNUmVkZWVtVm91Y2hlchIYCgd2b3VjaGVyGAEgASgJUgd2b3VjaGVyGn' - 'AKEVN3aXRjaFRvUGF5ZWRQbGFuEhcKB3BsYW5faWQYASABKAlSBnBsYW5JZBIfCgtwYXlfbW9u' - 'dGhseRgCIAEoCFIKcGF5TW9udGhseRIhCgxhdXRvX3JlbmV3YWwYAyABKAhSC2F1dG9SZW5ld2' - 'FsGjYKEVVwZGF0ZVBsYW5PcHRpb25zEiEKDGF1dG9fcmVuZXdhbBgBIAEoCFILYXV0b1JlbmV3' - 'YWwaMAoNQ3JlYXRlVm91Y2hlchIfCgt2YWx1ZV9jZW50cxgBIAEoDVIKdmFsdWVDZW50cxoNCg' - 'tHZXRMb2NhdGlvbhoNCgtHZXRWb3VjaGVycxoTChFHZXRBdmFpbGFibGVQbGFucxoXChVHZXRB' - 'ZGRBY2NvdW50c0ludml0ZXMaFQoTR2V0Q3VycmVudFBsYW5JbmZvcxo3ChRSZWRlZW1BZGRpdG' - 'lvbmFsQ29kZRIfCgtpbnZpdGVfY29kZRgCIAEoCVIKaW52aXRlQ29kZRovChRSZW1vdmVBZGRp' - 'dGlvbmFsVXNlchIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQaLQoSR2V0UHJla2V5c0J5VXNlck' - 'lkEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBoyChdHZXRTaWduZWRQcmVLZXlCeVVzZXJJZBIX' - 'Cgd1c2VyX2lkGAEgASgDUgZ1c2VySWQamwEKElVwZGF0ZVNpZ25lZFByZUtleRIoChBzaWduZW' - 'RfcHJla2V5X2lkGAEgASgDUg5zaWduZWRQcmVrZXlJZBIjCg1zaWduZWRfcHJla2V5GAIgASgM' - 'UgxzaWduZWRQcmVrZXkSNgoXc2lnbmVkX3ByZWtleV9zaWduYXR1cmUYAyABKAxSFXNpZ25lZF' - 'ByZWtleVNpZ25hdHVyZRo1CgxEb3dubG9hZERvbmUSJQoOZG93bmxvYWRfdG9rZW4YASABKAxS' - 'DWRvd25sb2FkVG9rZW4aTgoKUmVwb3J0VXNlchIoChByZXBvcnRlZF91c2VyX2lkGAEgASgDUg' - '5yZXBvcnRlZFVzZXJJZBIWCgZyZWFzb24YAiABKAlSBnJlYXNvbhoPCg1EZWxldGVBY2NvdW50' - 'QhEKD0FwcGxpY2F0aW9uRGF0YQ=='); + 'cGxpY2F0aW9uRGF0YS5DaGFuZ2VVc2VybmFtZUgAUg5jaGFuZ2VVc2VybmFtZRJRCgtpcGFQdX' + 'JjaGFzZRgbIAEoCzItLmNsaWVudF90b19zZXJ2ZXIuQXBwbGljYXRpb25EYXRhLklQQVB1cmNo' + 'YXNlSABSC2lwYVB1cmNoYXNlElcKDWlwYUZvcmNlQ2hlY2sYHCABKAsyLy5jbGllbnRfdG9fc2' + 'VydmVyLkFwcGxpY2F0aW9uRGF0YS5JUEFGb3JjZUNoZWNrSABSDWlwYUZvcmNlQ2hlY2saagoL' + 'VGV4dE1lc3NhZ2USFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkEhIKBGJvZHkYAyABKAxSBGJvZH' + 'kSIAoJcHVzaF9kYXRhGAQgASgMSABSCHB1c2hEYXRhiAEBQgwKCl9wdXNoX2RhdGEaLwoRR2V0' + 'VXNlckJ5VXNlcm5hbWUSGgoIdXNlcm5hbWUYASABKAlSCHVzZXJuYW1lGiwKDkNoYW5nZVVzZX' + 'JuYW1lEhoKCHVzZXJuYW1lGAEgASgJUgh1c2VybmFtZRo1ChRVcGRhdGVHb29nbGVGY21Ub2tl' + 'bhIdCgpnb29nbGVfZmNtGAEgASgJUglnb29nbGVGY20aJgoLR2V0VXNlckJ5SWQSFwoHdXNlcl' + '9pZBgBIAEoA1IGdXNlcklkGikKDVJlZGVlbVZvdWNoZXISGAoHdm91Y2hlchgBIAEoCVIHdm91' + 'Y2hlchpwChFTd2l0Y2hUb1BheWVkUGxhbhIXCgdwbGFuX2lkGAEgASgJUgZwbGFuSWQSHwoLcG' + 'F5X21vbnRobHkYAiABKAhSCnBheU1vbnRobHkSIQoMYXV0b19yZW5ld2FsGAMgASgIUgthdXRv' + 'UmVuZXdhbBo2ChFVcGRhdGVQbGFuT3B0aW9ucxIhCgxhdXRvX3JlbmV3YWwYASABKAhSC2F1dG' + '9SZW5ld2FsGjAKDUNyZWF0ZVZvdWNoZXISHwoLdmFsdWVfY2VudHMYASABKA1SCnZhbHVlQ2Vu' + 'dHMaDQoLR2V0TG9jYXRpb24aDQoLR2V0Vm91Y2hlcnMaEwoRR2V0QXZhaWxhYmxlUGxhbnMaFw' + 'oVR2V0QWRkQWNjb3VudHNJbnZpdGVzGhUKE0dldEN1cnJlbnRQbGFuSW5mb3MaNwoUUmVkZWVt' + 'QWRkaXRpb25hbENvZGUSHwoLaW52aXRlX2NvZGUYAiABKAlSCmludml0ZUNvZGUaLwoUUmVtb3' + 'ZlQWRkaXRpb25hbFVzZXISFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkGi0KEkdldFByZWtleXNC' + 'eVVzZXJJZBIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQaMgoXR2V0U2lnbmVkUHJlS2V5QnlVc2' + 'VySWQSFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkGpsBChJVcGRhdGVTaWduZWRQcmVLZXkSKAoQ' + 'c2lnbmVkX3ByZWtleV9pZBgBIAEoA1IOc2lnbmVkUHJla2V5SWQSIwoNc2lnbmVkX3ByZWtleR' + 'gCIAEoDFIMc2lnbmVkUHJla2V5EjYKF3NpZ25lZF9wcmVrZXlfc2lnbmF0dXJlGAMgASgMUhVz' + 'aWduZWRQcmVrZXlTaWduYXR1cmUaNQoMRG93bmxvYWREb25lEiUKDmRvd25sb2FkX3Rva2VuGA' + 'EgASgMUg1kb3dubG9hZFRva2VuGk4KClJlcG9ydFVzZXISKAoQcmVwb3J0ZWRfdXNlcl9pZBgB' + 'IAEoA1IOcmVwb3J0ZWRVc2VySWQSFgoGcmVhc29uGAIgASgJUgZyZWFzb24acQoLSVBBUHVyY2' + 'hhc2USHQoKcHJvZHVjdF9pZBgBIAEoCVIJcHJvZHVjdElkEhYKBnNvdXJjZRgCIAEoCVIGc291' + 'cmNlEisKEXZlcmlmaWNhdGlvbl9kYXRhGAMgASgJUhB2ZXJpZmljYXRpb25EYXRhGg8KDUlQQU' + 'ZvcmNlQ2hlY2saDwoNRGVsZXRlQWNjb3VudEIRCg9BcHBsaWNhdGlvbkRhdGE='); @$core.Deprecated('Use responseDescriptor instead') const Response$json = { diff --git a/lib/src/model/protobuf/api/websocket/server_to_client.pb.dart b/lib/src/model/protobuf/api/websocket/server_to_client.pb.dart index c6930fd..3d411cb 100644 --- a/lib/src/model/protobuf/api/websocket/server_to_client.pb.dart +++ b/lib/src/model/protobuf/api/websocket/server_to_client.pb.dart @@ -92,7 +92,14 @@ class ServerToClient extends $pb.GeneratedMessage { V0 ensureV0() => $_ensure(0); } -enum V0_Kind { response, newMessage, requestNewPreKeys, error, notSet } +enum V0_Kind { + response, + newMessage, + requestNewPreKeys, + error, + newMessages, + notSet +} class V0 extends $pb.GeneratedMessage { factory V0({ @@ -101,6 +108,7 @@ class V0 extends $pb.GeneratedMessage { NewMessage? newMessage, $core.bool? requestNewPreKeys, $0.ErrorCode? error, + NewMessages? newMessages, }) { final result = create(); if (seq != null) result.seq = seq; @@ -108,6 +116,7 @@ class V0 extends $pb.GeneratedMessage { if (newMessage != null) result.newMessage = newMessage; if (requestNewPreKeys != null) result.requestNewPreKeys = requestNewPreKeys; if (error != null) result.error = error; + if (newMessages != null) result.newMessages = newMessages; return result; } @@ -125,6 +134,7 @@ class V0 extends $pb.GeneratedMessage { 3: V0_Kind.newMessage, 4: V0_Kind.requestNewPreKeys, 6: V0_Kind.error, + 7: V0_Kind.newMessages, 0: V0_Kind.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -132,7 +142,7 @@ class V0 extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'), createEmptyInstance: create) - ..oo(0, [2, 3, 4, 6]) + ..oo(0, [2, 3, 4, 6, 7]) ..a<$fixnum.Int64>(1, _omitFieldNames ? '' : 'seq', $pb.PbFieldType.OU6, defaultOrMaker: $fixnum.Int64.ZERO) ..aOM(2, _omitFieldNames ? '' : 'response', @@ -145,6 +155,8 @@ class V0 extends $pb.GeneratedMessage { defaultOrMaker: $0.ErrorCode.Unknown, valueOf: $0.ErrorCode.valueOf, enumValues: $0.ErrorCode.values) + ..aOM(7, _omitFieldNames ? '' : 'newMessages', + protoName: 'newMessages', subBuilder: NewMessages.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -217,6 +229,17 @@ class V0 extends $pb.GeneratedMessage { $core.bool hasError() => $_has(4); @$pb.TagNumber(6) void clearError() => $_clearField(6); + + @$pb.TagNumber(7) + NewMessages get newMessages => $_getN(5); + @$pb.TagNumber(7) + set newMessages(NewMessages value) => $_setField(7, value); + @$pb.TagNumber(7) + $core.bool hasNewMessages() => $_has(5); + @$pb.TagNumber(7) + void clearNewMessages() => $_clearField(7); + @$pb.TagNumber(7) + NewMessages ensureNewMessages() => $_ensure(5); } class NewMessage extends $pb.GeneratedMessage { @@ -287,6 +310,58 @@ class NewMessage extends $pb.GeneratedMessage { void clearFromUserId() => $_clearField(2); } +class NewMessages extends $pb.GeneratedMessage { + factory NewMessages({ + $core.Iterable? newMessages, + }) { + final result = create(); + if (newMessages != null) result.newMessages.addAll(newMessages); + return result; + } + + NewMessages._(); + + factory NewMessages.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory NewMessages.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'NewMessages', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'), + createEmptyInstance: create) + ..pc( + 1, _omitFieldNames ? '' : 'newMessages', $pb.PbFieldType.PM, + protoName: 'newMessages', subBuilder: NewMessage.create) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + NewMessages clone() => NewMessages()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + NewMessages copyWith(void Function(NewMessages) updates) => + super.copyWith((message) => updates(message as NewMessages)) + as NewMessages; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static NewMessages create() => NewMessages._(); + @$core.override + NewMessages createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static NewMessages getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static NewMessages? _defaultInstance; + + @$pb.TagNumber(1) + $pb.PbList get newMessages => $_getList(0); +} + class Response_Authenticated extends $pb.GeneratedMessage { factory Response_Authenticated({ $core.String? plan, diff --git a/lib/src/model/protobuf/api/websocket/server_to_client.pbjson.dart b/lib/src/model/protobuf/api/websocket/server_to_client.pbjson.dart index de1acb1..567af49 100644 --- a/lib/src/model/protobuf/api/websocket/server_to_client.pbjson.dart +++ b/lib/src/model/protobuf/api/websocket/server_to_client.pbjson.dart @@ -61,6 +61,15 @@ const V0$json = { '9': 0, '10': 'newMessage' }, + { + '1': 'newMessages', + '3': 7, + '4': 1, + '5': 11, + '6': '.server_to_client.NewMessages', + '9': 0, + '10': 'newMessages' + }, { '1': 'RequestNewPreKeys', '3': 4, @@ -88,9 +97,10 @@ const V0$json = { final $typed_data.Uint8List v0Descriptor = $convert.base64Decode( 'CgJWMBIQCgNzZXEYASABKARSA3NlcRI4CghyZXNwb25zZRgCIAEoCzIaLnNlcnZlcl90b19jbG' 'llbnQuUmVzcG9uc2VIAFIIcmVzcG9uc2USPgoKbmV3TWVzc2FnZRgDIAEoCzIcLnNlcnZlcl90' - 'b19jbGllbnQuTmV3TWVzc2FnZUgAUgpuZXdNZXNzYWdlEi4KEVJlcXVlc3ROZXdQcmVLZXlzGA' - 'QgASgISABSEVJlcXVlc3ROZXdQcmVLZXlzEigKBWVycm9yGAYgASgOMhAuZXJyb3IuRXJyb3JD' - 'b2RlSABSBWVycm9yQgYKBEtpbmQ='); + 'b19jbGllbnQuTmV3TWVzc2FnZUgAUgpuZXdNZXNzYWdlEkEKC25ld01lc3NhZ2VzGAcgASgLMh' + '0uc2VydmVyX3RvX2NsaWVudC5OZXdNZXNzYWdlc0gAUgtuZXdNZXNzYWdlcxIuChFSZXF1ZXN0' + 'TmV3UHJlS2V5cxgEIAEoCEgAUhFSZXF1ZXN0TmV3UHJlS2V5cxIoCgVlcnJvchgGIAEoDjIQLm' + 'Vycm9yLkVycm9yQ29kZUgAUgVlcnJvckIGCgRLaW5k'); @$core.Deprecated('Use newMessageDescriptor instead') const NewMessage$json = { @@ -106,6 +116,26 @@ final $typed_data.Uint8List newMessageDescriptor = $convert.base64Decode( 'CgpOZXdNZXNzYWdlEiAKDGZyb21fdXNlcl9pZBgCIAEoA1IKZnJvbVVzZXJJZBISCgRib2R5GA' 'EgASgMUgRib2R5'); +@$core.Deprecated('Use newMessagesDescriptor instead') +const NewMessages$json = { + '1': 'NewMessages', + '2': [ + { + '1': 'newMessages', + '3': 1, + '4': 3, + '5': 11, + '6': '.server_to_client.NewMessage', + '10': 'newMessages' + }, + ], +}; + +/// Descriptor for `NewMessages`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List newMessagesDescriptor = $convert.base64Decode( + 'CgtOZXdNZXNzYWdlcxI+CgtuZXdNZXNzYWdlcxgBIAMoCzIcLnNlcnZlcl90b19jbGllbnQuTm' + 'V3TWVzc2FnZVILbmV3TWVzc2FnZXM='); + @$core.Deprecated('Use responseDescriptor instead') const Response$json = { '1': 'Response', diff --git a/lib/src/model/purchases/purchasable_product.dart b/lib/src/model/purchases/purchasable_product.dart new file mode 100644 index 0000000..715c824 --- /dev/null +++ b/lib/src/model/purchases/purchasable_product.dart @@ -0,0 +1,13 @@ +import 'package:in_app_purchase/in_app_purchase.dart'; + +enum ProductStatus { purchasable, purchased, pending } + +class PurchasableProduct { + PurchasableProduct(this.productDetails) : status = ProductStatus.purchasable; + String get id => productDetails.id; + String get title => productDetails.title; + String get description => productDetails.description; + String get price => productDetails.price; + ProductStatus status; + ProductDetails productDetails; +} diff --git a/lib/src/providers/connection.provider.dart b/lib/src/providers/connection.provider.dart index f09472c..1b004ae 100644 --- a/lib/src/providers/connection.provider.dart +++ b/lib/src/providers/connection.provider.dart @@ -1,17 +1,10 @@ import 'package:flutter/foundation.dart'; -import 'package:twonly/src/services/subscription.service.dart'; class CustomChangeProvider with ChangeNotifier, DiagnosticableTreeMixin { bool _isConnected = false; bool get isConnected => _isConnected; - SubscriptionPlan plan = SubscriptionPlan.Free; Future updateConnectionState(bool update) async { _isConnected = update; notifyListeners(); } - - Future updatePlan(SubscriptionPlan newPlan) async { - plan = newPlan; - notifyListeners(); - } } diff --git a/lib/src/providers/purchases.provider.dart b/lib/src/providers/purchases.provider.dart new file mode 100644 index 0000000..453a2f9 --- /dev/null +++ b/lib/src/providers/purchases.provider.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/constants/subscription.keys.dart'; +import 'package:twonly/src/model/purchases/purchasable_product.dart'; +import 'package:twonly/src/services/subscription.service.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/storage.dart'; + +// Gives the option to override in tests. +class IAPConnection { + static InAppPurchase? _instance; + static set instance(InAppPurchase value) { + _instance = value; + } + + static InAppPurchase get instance { + _instance ??= InAppPurchase.instance; + return _instance!; + } +} + +enum StoreState { loading, available, notAvailable } + +class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin { + PurchasesProvider() { + final purchaseUpdated = iapConnection.purchaseStream; + _subscription = purchaseUpdated.listen( + _onPurchaseUpdate, + onDone: _updateStreamOnDone, + onError: _updateStreamOnError, + ); + + forceIpaCheck = Timer(const Duration(seconds: 10), () { + Log.warn('Force Ipa check was not stopped. Requesting forced check...'); + apiService.forceIpaCheck(); + }); + loadPurchases(); + } + + late Timer forceIpaCheck; + + SubscriptionPlan plan = SubscriptionPlan.Free; + StoreState storeState = StoreState.loading; + List products = []; + + late StreamSubscription> _subscription; + final InAppPurchase iapConnection = IAPConnection.instance; + + void updatePlan(SubscriptionPlan newPlan) { + plan = newPlan; + notifyListeners(); + } + + Future loadPurchases() async { + final available = await iapConnection.isAvailable(); + if (!available) { + storeState = StoreState.notAvailable; + Log.error('Store is not available'); + notifyListeners(); + return; + } + const ids = { + SubscriptionKeys.proMonthly, + SubscriptionKeys.proYearly, + SubscriptionKeys.familyYearly, + }; + final response = await iapConnection.queryProductDetails(ids); + if (response.notFoundIDs.isNotEmpty) { + Log.error(response.notFoundIDs); + } + products = response.productDetails.map(PurchasableProduct.new).toList(); + if (products.isEmpty) { + Log.error('Could not load any products from the store!'); + } + storeState = StoreState.available; + notifyListeners(); + + await iapConnection.restorePurchases(); + } + + Future buy(PurchasableProduct product) async { + Log.info('User wants to buy ${product.id}'); + final purchaseParam = PurchaseParam(productDetails: product.productDetails); + switch (product.id) { + // case storeKeyConsumable: + // await iapConnection.buyConsumable(purchaseParam: purchaseParam); + case SubscriptionKeys.proMonthly: + case SubscriptionKeys.proYearly: + case SubscriptionKeys.familyYearly: + await iapConnection.buyNonConsumable(purchaseParam: purchaseParam); + default: + throw ArgumentError.value( + product.productDetails, + '${product.id} is not a known product', + ); + } + } + + Future _onPurchaseUpdate( + List purchaseDetailsList, + ) async { + for (final purchaseDetails in purchaseDetailsList) { + await _handlePurchase(purchaseDetails); + } + notifyListeners(); + } + + Future _verifyPurchase(PurchaseDetails purchaseDetails) async { + Log.info(purchaseDetails.productID); + Log.info(purchaseDetails.verificationData.serverVerificationData); + Log.info(purchaseDetails.verificationData.source); + final res = await apiService.ipaPurchase( + purchaseDetails.productID, + purchaseDetails.verificationData.source, + purchaseDetails.verificationData.serverVerificationData, + ); + return res.isSuccess; + } + + Future _handlePurchase(PurchaseDetails purchaseDetails) async { + var validPurchase = false; + if (purchaseDetails.status == PurchaseStatus.purchased) { + Log.info('purchased: ${purchaseDetails.productID}'); + validPurchase = await _verifyPurchase(purchaseDetails); + if (validPurchase) { + var plan = SubscriptionPlan.Pro; + if (purchaseDetails.productID.contains('family')) { + plan = SubscriptionPlan.Family; + } + await updateUserdata((u) { + u + ..subscriptionPlan = plan.name + ..subscriptionPlanIdStore = purchaseDetails.productID; + return u; + }); + updatePlan(plan); + } + } + if (purchaseDetails.status == PurchaseStatus.restored) { + // there is a + forceIpaCheck.cancel(); + + if (gUser.subscriptionPlan != SubscriptionPlan.Family.name || + gUser.subscriptionPlan != SubscriptionPlan.Pro.name) { + // app was installed on some one other... + // subscription is handled on the server, so on a new device the subscription comes from the server again... + } + } + + if (purchaseDetails.pendingCompletePurchase) { + await iapConnection.completePurchase(purchaseDetails); + } + } + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } + + void _updateStreamOnDone() { + _subscription.cancel(); + } + + void _updateStreamOnError(dynamic error) { + // Handle error here + } +} diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index dea88dc..76ecf65 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -98,12 +98,13 @@ class ApiService { unawaited(signalHandleNewServerConnection()); unawaited(fetchGroupStatesForUnjoinedGroups()); unawaited(fetchMissingGroupPublicKey()); + unawaited(checkForDeletedUsernames()); } } Future onConnected() async { await authenticate(); - _reconnectionDelay = 5; + _reconnectionDelay = 1; globalCallbackConnectionState(isConnected: true); } @@ -112,6 +113,7 @@ class ApiService { isAuthenticated = false; globalCallbackConnectionState(isConnected: false); await twonlyDB.mediaFilesDao.resetPendingDownloadState(); + await startReconnectionTimer(); } Future startReconnectionTimer() async { @@ -121,7 +123,7 @@ class ApiService { reconnectionTimer = null; await connect(force: true); }); - _reconnectionDelay += 5; + _reconnectionDelay += 2; } Future close(Function callback) async { @@ -300,6 +302,17 @@ class ApiService { _channel!.sink.add(requestBytes); final res = asResult(await _waitForResponse(seq)); + if (res.isSuccess) { + final ok = res.value as server.Response_Ok; + if (ok.hasAuthenticated()) { + final authenticated = ok.authenticated; + await updateUserdata((user) { + user.subscriptionPlan = authenticated.plan; + return user; + }); + globalCallbackUpdatePlan(planFromString(authenticated.plan)); + } + } if (res.isError) { Log.warn('Got error from server: ${res.error}'); if (res.error == ErrorCode.AppVersionOutdated) { @@ -375,15 +388,6 @@ class ApiService { final result = await sendRequestSync(req, authenticated: false); if (result.isSuccess) { - final ok = result.value as server.Response_Ok; - if (ok.hasAuthenticated()) { - final authenticated = ok.authenticated; - await updateUserdata((user) { - user.subscriptionPlan = authenticated.plan; - return user; - }); - globalCallbackUpdatePlan(planFromString(authenticated.plan)); - } Log.info('websocket is authenticated'); unawaited(onAuthenticated()); return true; @@ -491,6 +495,19 @@ class ApiService { return sendRequestSync(req); } + Future checkForDeletedUsernames() async { + final users = await twonlyDB.contactsDao.getContactsByUsername('[deleted]'); + for (final user in users) { + final userData = await getUserById(user.userId); + if (userData != null) { + await twonlyDB.contactsDao.updateContact( + user.userId, + ContactsCompanion(username: Value(utf8.decode(userData.username))), + ); + } + } + } + Future getUserById(int userId) async { final get = ApplicationData_GetUserById()..userId = Int64(userId); final appData = ApplicationData()..getUserById = get; @@ -662,6 +679,22 @@ class ApiService { return sendRequestSync(req); } + Future ipaPurchase( + String productId, + String source, + String verificationData, + ) async { + final appData = ApplicationData( + ipaPurchase: ApplicationData_IPAPurchase( + productId: productId, + source: source, + verificationData: verificationData, + ), + ); + final req = createClientToServerFromApplicationData(appData); + return sendRequestSync(req); + } + Future changeUsername(String username) async { final get = ApplicationData_ChangeUsername()..username = username; final appData = ApplicationData()..changeUsername = get; @@ -669,6 +702,15 @@ class ApiService { return sendRequestSync(req); } + Future forceIpaCheck() async { + final req = createClientToServerFromApplicationData( + ApplicationData( + ipaForceCheck: ApplicationData_IPAForceCheck(), + ), + ); + return sendRequestSync(req); + } + Future updateSignedPreKey( int signedPreKeyId, Uint8List signedPreKey, @@ -722,6 +764,28 @@ class ApiService { return null; } + Future loadPlanBalance({bool useCache = true}) async { + final ballance = await getPlanBallance(); + if (ballance != null) { + await updateUserdata((u) { + u.lastPlanBallance = ballance.writeToJson(); + return u; + }); + return ballance; + } + final user = await getUser(); + if (user != null && user.lastPlanBallance != null && useCache) { + try { + return Response_PlanBallance.fromJson( + user.lastPlanBallance!, + ); + } catch (e) { + Log.error('from json: $e'); + } + } + return ballance; + } + Future sendTextMessage( int target, Uint8List msg, diff --git a/lib/src/services/api/server_messages.dart b/lib/src/services/api/server_messages.dart index d395f80..d10781d 100644 --- a/lib/src/services/api/server_messages.dart +++ b/lib/src/services/api/server_messages.dart @@ -10,6 +10,7 @@ import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart' as server; +import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/services/api/client2client/contact.c2c.dart'; import 'package:twonly/src/services/api/client2client/groups.c2c.dart'; @@ -35,9 +36,11 @@ Future handleServerMessage(server.ServerToClient msg) async { if (msg.v0.hasRequestNewPreKeys()) { response = await handleRequestNewPreKey(); } else if (msg.v0.hasNewMessage()) { - final body = Uint8List.fromList(msg.v0.newMessage.body); - final fromUserId = msg.v0.newMessage.fromUserId.toInt(); - await handleClient2ClientMessage(fromUserId, body); + await handleClient2ClientMessage(msg.v0.newMessage); + } else if (msg.v0.hasNewMessages()) { + for (final newMessage in msg.v0.newMessages.newMessages) { + await handleClient2ClientMessage(newMessage); + } } else { Log.error('Unknown server message: $msg'); } @@ -56,7 +59,10 @@ DateTime lastPushKeyRequest = DateTime.now().subtract(const Duration(hours: 1)); Mutex protectReceiptCheck = Mutex(); -Future handleClient2ClientMessage(int fromUserId, Uint8List body) async { +Future handleClient2ClientMessage(NewMessage newMessage) async { + final body = Uint8List.fromList(newMessage.body); + final fromUserId = newMessage.fromUserId.toInt(); + final message = Message.fromBuffer(body); final receiptId = message.receiptId; diff --git a/lib/src/services/fcm.service.dart b/lib/src/services/fcm.service.dart index df3ebc5..433dbc8 100644 --- a/lib/src/services/fcm.service.dart +++ b/lib/src/services/fcm.service.dart @@ -1,5 +1,6 @@ // ignore_for_file: unreachable_from_main +import 'dart:async'; import 'dart:io' show Platform; import 'package:firebase_core/firebase_core.dart'; @@ -9,39 +10,50 @@ import 'package:twonly/globals.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/storage.dart'; import '../../firebase_options.dart'; // see more here: https://firebase.google.com/docs/cloud-messaging/flutter/receive?hl=de -Future initFCMAfterAuthenticated() async { - if (globalIsAppInBackground) return; - +Future checkForTokenUpdates() async { const storage = FlutterSecureStorage(); final storedToken = await storage.read(key: SecureStorageKeys.googleFcm); try { if (Platform.isIOS) { - final apnsToken = await FirebaseMessaging.instance.getAPNSToken(); + var apnsToken = await FirebaseMessaging.instance.getAPNSToken(); + for (var i = 0; i < 20; i++) { + if (apnsToken != null) break; + await Future.delayed(const Duration(seconds: 1)); + apnsToken = await FirebaseMessaging.instance.getAPNSToken(); + } if (apnsToken == null) { - Log.error('Error getting apnsToken'); + Log.error('Could not get APNS token even after 20s...'); return; } } + final fcmToken = await FirebaseMessaging.instance.getToken(); if (fcmToken == null) { - Log.error('Error getting fcmToken'); + Log.error('Could not get fcm token'); return; } - + Log.info('Loaded fcm token'); if (storedToken == null || fcmToken != storedToken) { - await apiService.updateFCMToken(fcmToken); + await updateUserdata((u) { + u.updateFCMToken = true; + return u; + }); await storage.write(key: SecureStorageKeys.googleFcm, value: fcmToken); } FirebaseMessaging.instance.onTokenRefresh.listen((fcmToken) async { - await apiService.updateFCMToken(fcmToken); + await updateUserdata((u) { + u.updateFCMToken = true; + return u; + }); await storage.write(key: SecureStorageKeys.googleFcm, value: fcmToken); }).onError((err) { Log.error('could not listen on token refresh'); @@ -51,11 +63,30 @@ Future initFCMAfterAuthenticated() async { } } +Future initFCMAfterAuthenticated() async { + if (gUser.updateFCMToken) { + const storage = FlutterSecureStorage(); + final storedToken = await storage.read(key: SecureStorageKeys.googleFcm); + if (storedToken != null) { + final res = await apiService.updateFCMToken(storedToken); + if (res.isSuccess) { + Log.info('Uploaded new fmt token!'); + await updateUserdata((u) { + u.updateFCMToken = false; + return u; + }); + } + } + } +} + Future initFCMService() async { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); + unawaited(checkForTokenUpdates()); + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); // You may set the permission requests to "provisional" which allows the user to choose what type @@ -65,12 +96,12 @@ Future initFCMService() async { await FirebaseMessaging.instance.requestPermission(); // For apple platforms, ensure the APNS token is available before making any FCM plugin API calls - if (Platform.isIOS) { - final apnsToken = await FirebaseMessaging.instance.getAPNSToken(); - if (apnsToken == null) { - return; - } - } + // if (Platform.isIOS) { + // final apnsToken = await FirebaseMessaging.instance.getAPNSToken(); + // if (apnsToken == null) { + // return; + // } + // } FirebaseMessaging.onMessage.listen(handleRemoteMessage); } diff --git a/lib/src/utils/storage.dart b/lib/src/utils/storage.dart index db474dc..34bf7bd 100644 --- a/lib/src/utils/storage.dart +++ b/lib/src/utils/storage.dart @@ -7,7 +7,7 @@ import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/model/json/userdata.dart'; -import 'package:twonly/src/providers/connection.provider.dart'; +import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/utils/log.dart'; @@ -40,7 +40,7 @@ Future updateUsersPlan( BuildContext context, SubscriptionPlan plan, ) async { - context.read().plan = plan; + context.read().plan = plan; await updateUserdata((user) { user.subscriptionPlan = plan.name; @@ -48,7 +48,7 @@ Future updateUsersPlan( }); if (!context.mounted) return; - await context.read().updatePlan(plan); + context.read().updatePlan(plan); } Mutex updateProtection = Mutex(); diff --git a/lib/src/views/chats/chat_list.view.dart b/lib/src/views/chats/chat_list.view.dart index 182b68f..6cc05b0 100644 --- a/lib/src/views/chats/chat_list.view.dart +++ b/lib/src/views/chats/chat_list.view.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/providers/connection.provider.dart'; +import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; @@ -93,7 +94,7 @@ class _ChatListViewState extends State { @override Widget build(BuildContext context) { final isConnected = context.watch().isConnected; - final plan = context.watch().plan; + final plan = context.watch().plan; return Scaffold( appBar: AppBar( title: Row( diff --git a/lib/src/views/components/media_view_sizing.dart b/lib/src/views/components/media_view_sizing.dart index 0ebc55d..01bf89a 100644 --- a/lib/src/views/components/media_view_sizing.dart +++ b/lib/src/views/components/media_view_sizing.dart @@ -37,6 +37,9 @@ class _MediaViewSizingState extends State { final aspectRatioWidth = availableWidth; final aspectRatioHeight = (aspectRatioWidth * 16) / 9; + if (aspectRatioHeight > availableHeight) { + needToDownSizeImage = true; + } if (widget.requiredHeight != null) { if (aspectRatioHeight < availableHeight) { if ((screenSize.height - widget.requiredHeight!) < aspectRatioHeight) { diff --git a/lib/src/views/settings/account.view.dart b/lib/src/views/settings/account.view.dart index d56e734..73e7809 100644 --- a/lib/src/views/settings/account.view.dart +++ b/lib/src/views/settings/account.view.dart @@ -1,5 +1,4 @@ import 'dart:async'; - import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -11,7 +10,6 @@ import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/settings/account/refund_credits.view.dart'; -import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; class AccountView extends StatefulWidget { const AccountView({super.key}); @@ -31,7 +29,7 @@ class _AccountViewState extends State { } Future initAsync() async { - final ballance = await loadPlanBalance(useCache: false); + final ballance = await apiService.loadPlanBalance(useCache: false); if (ballance == null || !mounted) return; var ballanceInCents = ballance.transactions .where( diff --git a/lib/src/views/settings/account/refund_credits.view.dart b/lib/src/views/settings/account/refund_credits.view.dart index 1e997bf..2dcd6a5 100644 --- a/lib/src/views/settings/account/refund_credits.view.dart +++ b/lib/src/views/settings/account/refund_credits.view.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; // import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:twonly/src/views/settings/subscription/voucher.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/voucher.view.dart'; // import 'package:url_launcher/url_launcher.dart'; class RefundCreditsView extends StatefulWidget { diff --git a/lib/src/views/settings/subscription/additional_users.view.dart b/lib/src/views/settings/subscription/additional_users.view.dart index 1bb21dd..fc8331c 100644 --- a/lib/src/views/settings/subscription/additional_users.view.dart +++ b/lib/src/views/settings/subscription/additional_users.view.dart @@ -11,7 +11,7 @@ import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; -import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/subscription.view.dart'; Future?> loadAdditionalUserInvites() async { final ballance = await apiService.getAdditionalUserInvites(); diff --git a/lib/src/views/settings/subscription/subscription.view.dart b/lib/src/views/settings/subscription/subscription.view.dart index d87434a..d021a17 100644 --- a/lib/src/views/settings/subscription/subscription.view.dart +++ b/lib/src/views/settings/subscription/subscription.view.dart @@ -1,111 +1,24 @@ // ignore_for_file: inference_failure_on_instance_creation import 'dart:async'; +import 'dart:io'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'; -import 'package:twonly/src/providers/connection.provider.dart'; +import 'package:twonly/src/model/purchases/purchasable_product.dart'; +import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/subscription.service.dart'; -import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/components/better_list_title.dart'; import 'package:twonly/src/views/settings/subscription/additional_users.view.dart'; -import 'package:twonly/src/views/settings/subscription/checkout.view.dart'; -import 'package:twonly/src/views/settings/subscription/manage_subscription.view.dart'; -import 'package:twonly/src/views/settings/subscription/transaction.view.dart'; -import 'package:twonly/src/views/settings/subscription/voucher.view.dart'; - -String localePrizing(BuildContext context, int cents) { - final myLocale = Localizations.localeOf(context); - final euros = cents / 100; - - if (euros == euros.toInt()) { - return '${euros.toInt()}€'; - } - - return NumberFormat.currency( - locale: myLocale.toString(), - symbol: '€', - decimalDigits: 2, - ).format(cents / 100); -} - -Future loadPlanBalance({bool useCache = true}) async { - final ballance = await apiService.getPlanBallance(); - if (ballance != null) { - await updateUserdata((u) { - u.lastPlanBallance = ballance.writeToJson(); - return u; - }); - return ballance; - } - final user = await getUser(); - if (user != null && user.lastPlanBallance != null && useCache) { - try { - return Response_PlanBallance.fromJson( - user.lastPlanBallance!, - ); - } catch (e) { - Log.error('from json: $e'); - } - } - return ballance; -} - -// ignore: constant_identifier_names -const int MONTHLY_PAYMENT_DAYS = 30; -// ignore: constant_identifier_names -const int YEARLY_PAYMENT_DAYS = 365; - -int calculateRefund(Response_PlanBallance current) { - var refund = getPlanPrice(SubscriptionPlan.Pro, paidMonthly: true); - - if (current.paymentPeriodDays == YEARLY_PAYMENT_DAYS) { - final elapsedDays = DateTime.now() - .difference( - DateTime.fromMillisecondsSinceEpoch( - current.lastPaymentDoneUnixTimestamp.toInt() * 1000, - ), - ) - .inDays; - if (elapsedDays < current.paymentPeriodDays.toInt()) { - // User has yearly plan with 10€ - // used it half a year and wants now to upgrade => gets 5€ discount... - // math.ceil(((365-(365/2))/365)*10) - // => 5€ - - refund = (((YEARLY_PAYMENT_DAYS - elapsedDays) / YEARLY_PAYMENT_DAYS) * - getPlanPrice(SubscriptionPlan.Pro, paidMonthly: false) / - 100) - .ceil() * - 100; - } - } else { - final elapsedDays = DateTime.now() - .difference( - DateTime.fromMillisecondsSinceEpoch( - current.lastPaymentDoneUnixTimestamp.toInt() * 1000, - ), - ) - .inDays; - if (elapsedDays > 14) { - refund = 0; - } - } - return refund; -} +import 'package:url_launcher/url_launcher.dart'; class SubscriptionView extends StatefulWidget { - const SubscriptionView({super.key, this.redirectError}); - - final ErrorCode? redirectError; + const SubscriptionView({super.key}); @override State createState() => _SubscriptionViewState(); @@ -124,7 +37,7 @@ class _SubscriptionViewState extends State { } Future initAsync() async { - ballance = await loadPlanBalance(); + ballance = await apiService.loadPlanBalance(); if (ballance != null && ballance!.hasAdditionalAccountOwnerId()) { final ownerId = ballance!.additionalAccountOwnerId.toInt(); final contact = await twonlyDB.contactsDao @@ -141,57 +54,13 @@ class _SubscriptionViewState extends State { @override Widget build(BuildContext context) { - final myLocale = Localizations.localeOf(context); - String? formattedBalance; - DateTime? nextPayment; - final currentPlan = context.read().plan; - - if (ballance != null) { - final lastPaymentDateTime = DateTime.fromMillisecondsSinceEpoch( - ballance!.lastPaymentDoneUnixTimestamp.toInt() * 1000, - ); - if (isPayingUser(currentPlan)) { - nextPayment = lastPaymentDateTime - .add(Duration(days: ballance!.paymentPeriodDays.toInt())); - } - final ballanceInCents = - ballance!.transactions.map((a) => a.depositCents.toInt()).sum; - formattedBalance = NumberFormat.currency( - locale: myLocale.toString(), - symbol: '€', - decimalDigits: 2, - ).format(ballanceInCents / 100); - } - - var refund = 0; - if (currentPlan == SubscriptionPlan.Pro && ballance != null) { - refund = calculateRefund(ballance!); - } - + final currentPlan = context.watch().plan; return Scaffold( appBar: AppBar( title: Text(context.lang.settingsSubscription), ), body: ListView( children: [ - if (widget.redirectError != null) - Center( - child: Container( - padding: const EdgeInsets.all(16), - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.orangeAccent, - borderRadius: BorderRadius.circular(15), - ), - child: Text( - (widget.redirectError == ErrorCode.PlanLimitReached) - ? context.lang.planLimitReached - : context.lang.planNotAllowed, - style: const TextStyle(color: Colors.black), - textAlign: TextAlign.center, - ), - ), - ), Padding( padding: const EdgeInsets.all(32), child: Center( @@ -220,7 +89,11 @@ class _SubscriptionViewState extends State { style: const TextStyle(color: Colors.orange), ), ), - if (!isPayingUser(currentPlan)) + if (isPayingUser(currentPlan)) + PlanCard( + plan: currentPlan, + ), + if (!isPayingUser(currentPlan)) ...[ Center( child: Padding( padding: const EdgeInsets.all(18), @@ -231,48 +104,14 @@ class _SubscriptionViewState extends State { ), ), ), - if (!isPayingUser(currentPlan) || - currentPlan == SubscriptionPlan.Tester) PlanCard( plan: SubscriptionPlan.Pro, - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const CheckoutView( - plan: SubscriptionPlan.Pro, - ); - }, - ), - ); - await initAsync(); - }, + onPurchase: initAsync, ), - if (currentPlan != SubscriptionPlan.Family) PlanCard( plan: SubscriptionPlan.Family, - refund: refund, - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return CheckoutView( - plan: SubscriptionPlan.Family, - refund: (refund > 0) ? refund : null, - disableMonthlyOption: - currentPlan == SubscriptionPlan.Pro && - ballance!.paymentPeriodDays.toInt() == - YEARLY_PAYMENT_DAYS, - ); - }, - ), - ); - await initAsync(); - }, + onPurchase: initAsync, ), - if (!isPayingUser(currentPlan)) ...[ const SizedBox(height: 10), Center( child: Padding( @@ -287,58 +126,11 @@ class _SubscriptionViewState extends State { const SizedBox(height: 10), PlanCard( plan: SubscriptionPlan.Plus, - onTap: () async { - await redeemUserInviteCode(context, SubscriptionPlan.Plus.name); - await initAsync(); - }, + onPurchase: initAsync, ), ], const SizedBox(height: 10), if (currentPlan != SubscriptionPlan.Family) const Divider(), - BetterListTile( - icon: FontAwesomeIcons.gears, - text: context.lang.manageSubscription, - subtitle: (nextPayment != null) - ? Text( - '${context.lang.nextPayment}: ${DateFormat.yMMMMd(myLocale.toString()).format(nextPayment)}', - ) - : null, - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return ManageSubscriptionView( - ballance: ballance, - nextPayment: nextPayment, - ); - }, - ), - ); - await initAsync(); - }, - ), - BetterListTile( - icon: FontAwesomeIcons.moneyBillTransfer, - text: context.lang.transactionHistory, - subtitle: (formattedBalance != null) - ? Text('${context.lang.currentBalance}: $formattedBalance') - : null, - onTap: () async { - if (formattedBalance == null) return; - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return TransactionView( - transactions: ballance?.transactions, - formattedBalance: formattedBalance!, - ); - }, - ), - ); - }, - ), if (isPayingUser(currentPlan) || currentPlan == SubscriptionPlan.Tester) BetterListTile( @@ -359,182 +151,206 @@ class _SubscriptionViewState extends State { await initAsync(); }, ), - BetterListTile( - icon: FontAwesomeIcons.ticket, - text: context.lang.createOrRedeemVoucher, - onTap: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return const VoucherView(); - }, - ), - ); - await initAsync(); - }, - ), - const SizedBox(height: 30), ], ), ); } } -int getPlanPrice(SubscriptionPlan plan, {required bool paidMonthly}) { - switch (plan) { - case SubscriptionPlan.Pro: - return paidMonthly ? 100 : 1000; - case SubscriptionPlan.Family: - return paidMonthly ? 200 : 2000; - // ignore: no_default_cases - default: - return 0; - } -} - -class PlanCard extends StatelessWidget { +class PlanCard extends StatefulWidget { const PlanCard({ required this.plan, super.key, - this.refund, - this.onTap, + this.onPurchase, this.paidMonthly, }); final SubscriptionPlan plan; - final void Function()? onTap; - final int? refund; + final void Function()? onPurchase; final bool? paidMonthly; + @override + State createState() => _PlanCardState(); +} + +class _PlanCardState extends State { + Future onButtonPressed(PurchasableProduct? product) async { + if (widget.onPurchase == null) return; + if (widget.plan == SubscriptionPlan.Free || + widget.plan == SubscriptionPlan.Plus) { + await redeemUserInviteCode(context, SubscriptionPlan.Plus.name); + widget.onPurchase!(); + return; + } + if (product == null) return; + await context.read().buy(product); + widget.onPurchase!(); + } + @override Widget build(BuildContext context) { - final yearlyPrice = getPlanPrice(plan, paidMonthly: false); - final monthlyPrice = getPlanPrice(plan, paidMonthly: true); + final products = context.watch().products; + final currentPlan = context.watch().plan; + PurchasableProduct? yearlyProduct; + PurchasableProduct? monthlyProduct; + + for (final product in products) { + if (product.id.toLowerCase().startsWith(widget.plan.name.toLowerCase())) { + if (product.id.toLowerCase().contains('monthly')) { + monthlyProduct = product; + } else if (product.id.toLowerCase().contains('yearly')) { + yearlyProduct = product; + } + } + } + var features = []; - switch (plan.name) { + switch (widget.plan.name) { case 'Free': features = [context.lang.freeFeature1]; case 'Plus': - features = [context.lang.plusFeature1, context.lang.plusFeature2]; + features = [context.lang.plusFeature1]; //, context.lang.plusFeature2]; case 'Tester': case 'Pro': features = [ context.lang.proFeature1, context.lang.proFeature2, context.lang.proFeature3, - context.lang.proFeature4, + // context.lang.proFeature4, ]; case 'Family': features = [ context.lang.proFeature1, context.lang.familyFeature2, context.lang.proFeature3, - context.lang.proFeature4, + // context.lang.proFeature4, ]; default: } return Padding( padding: const EdgeInsets.only(left: 16, right: 16), - child: GestureDetector( - onTap: onTap, - child: Card( - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - color: context.color.surfaceContainer, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ + child: Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + color: context.color.surfaceContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + widget.plan.name, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (yearlyProduct != null && currentPlan != widget.plan) + const SizedBox(height: 10), + if (yearlyProduct != null && + widget.paidMonthly == null && + currentPlan != widget.plan) + Column( + children: [ + Text( + '${yearlyProduct.price}/${context.lang.year}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + if (monthlyProduct != null) + Text( + '${monthlyProduct.price}/${context.lang.month}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + if (widget.paidMonthly != null) Text( - plan.name, + (widget.paidMonthly!) + ? '${monthlyProduct?.price}/${context.lang.month}' + : '${yearlyProduct?.price}/${context.lang.year}', textAlign: TextAlign.center, style: const TextStyle( - fontSize: 24, + fontSize: 20, fontWeight: FontWeight.bold, ), ), - if (yearlyPrice != 0) const SizedBox(height: 10), - if (yearlyPrice != 0 && paidMonthly == null) - Column( - children: [ - if (paidMonthly == null || paidMonthly!) - Text( - '${localePrizing(context, yearlyPrice)}/${context.lang.year}', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - if (paidMonthly == null || !paidMonthly!) - Text( - '${localePrizing(context, monthlyPrice)}/${context.lang.month}', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - color: Colors.grey, - ), - ), - ], - ), - if (paidMonthly != null) - Text( - (paidMonthly!) - ? '${localePrizing(context, monthlyPrice)}/${context.lang.month}' - : '${localePrizing(context, yearlyPrice)}/${context.lang.year}', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 10), - ...features.map( - (feature) => Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text( - feature, - textAlign: TextAlign.center, - ), + ], + ), + const SizedBox(height: 10), + ...features.map( + (feature) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + feature, + textAlign: TextAlign.center, ), ), - if (refund != null && refund! > 0) - Padding( - padding: const EdgeInsets.only(top: 7), - child: Text( - context.lang - .subscriptionRefund(localePrizing(context, refund!)), - textAlign: TextAlign.center, - style: TextStyle( - color: context.color.primary, - fontSize: 12, + ), + const SizedBox(height: 10), + if (currentPlan == widget.plan) + FilledButton.icon( + onPressed: () async { + var url = 'https://apps.apple.com/account/subscriptions'; + if (Platform.isAndroid) { + url = + 'https://play.google.com/store/account/subscriptions?sku=${gUser.subscriptionPlanIdStore}&package=eu.twonly'; + } + await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + }, + label: const Text('Manage subscription'), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.onPurchase != null && monthlyProduct != null) + Padding( + padding: const EdgeInsets.only(right: 10), + child: OutlinedButton.icon( + onPressed: () => onButtonPressed(monthlyProduct), + label: (widget.plan == SubscriptionPlan.Free || + widget.plan == SubscriptionPlan.Plus) + ? Text(context.lang.redeemUserInviteCodeTitle) + : Text( + context.lang.upgradeToPaidPlanButton( + widget.plan.name, + ' (${context.lang.monthly})', + ), + ), ), ), - ), - if (onTap != null) - Padding( - padding: const EdgeInsets.only(top: 10), - child: FilledButton.icon( - onPressed: onTap, - label: (plan == SubscriptionPlan.Free || - plan == SubscriptionPlan.Plus) + if (widget.onPurchase != null && yearlyProduct != null) + FilledButton.icon( + onPressed: () => onButtonPressed(yearlyProduct), + label: (widget.plan == SubscriptionPlan.Free || + widget.plan == SubscriptionPlan.Plus) ? Text(context.lang.redeemUserInviteCodeTitle) : Text( - context.lang.upgradeToPaidPlanButton(plan.name), + context.lang.upgradeToPaidPlanButton( + widget.plan.name, + ' (${context.lang.yearly})', + ), ), ), - ), - ], - ), + ], + ), + ], ), ), ), diff --git a/lib/src/views/settings/subscription/checkout.view.dart b/lib/src/views/settings/subscription_custom/checkout.view.dart similarity index 77% rename from lib/src/views/settings/subscription/checkout.view.dart rename to lib/src/views/settings/subscription_custom/checkout.view.dart index 3d6797c..1709640 100644 --- a/lib/src/views/settings/subscription/checkout.view.dart +++ b/lib/src/views/settings/subscription_custom/checkout.view.dart @@ -1,19 +1,17 @@ import 'package:flutter/material.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/views/settings/subscription/select_payment.view.dart'; -import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/select_payment.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/subscription.view.dart'; class CheckoutView extends StatefulWidget { const CheckoutView({ required this.plan, super.key, - this.refund, this.disableMonthlyOption, }); final SubscriptionPlan plan; - final int? refund; final bool? disableMonthlyOption; @override @@ -76,29 +74,6 @@ class _CheckoutViewState extends State { ], ), ), - if (widget.refund != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - context.lang.refund, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text( - '+${localePrizing(context, widget.refund!)}', - textAlign: TextAlign.end, - style: TextStyle(color: context.color.primary), - ), - ], - ), - ), - ), - ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Card( @@ -133,7 +108,6 @@ class _CheckoutViewState extends State { return SelectPaymentView( plan: widget.plan, payMonthly: paidMonthly, - refund: widget.refund, ); }, ), diff --git a/lib/src/views/settings/subscription/manage_subscription.view.dart b/lib/src/views/settings/subscription_custom/manage_subscription.view.dart similarity index 93% rename from lib/src/views/settings/subscription/manage_subscription.view.dart rename to lib/src/views/settings/subscription_custom/manage_subscription.view.dart index 6c887d4..5e15e9a 100644 --- a/lib/src/views/settings/subscription/manage_subscription.view.dart +++ b/lib/src/views/settings/subscription_custom/manage_subscription.view.dart @@ -1,15 +1,14 @@ import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'; -import 'package:twonly/src/providers/connection.provider.dart'; +import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/subscription.view.dart'; class ManageSubscriptionView extends StatefulWidget { const ManageSubscriptionView({ @@ -65,7 +64,7 @@ class _ManageSubscriptionViewState extends State { @override Widget build(BuildContext context) { - final plan = context.read().plan; + final plan = context.watch().plan; final myLocale = Localizations.localeOf(context); final paidMonthly = ballance?.paymentPeriodDays == MONTHLY_PAYMENT_DAYS; return Scaffold( diff --git a/lib/src/views/settings/subscription/select_payment.view.dart b/lib/src/views/settings/subscription_custom/select_payment.view.dart similarity index 89% rename from lib/src/views/settings/subscription/select_payment.view.dart rename to lib/src/views/settings/subscription_custom/select_payment.view.dart index f9d3b8f..15afb5c 100644 --- a/lib/src/views/settings/subscription/select_payment.view.dart +++ b/lib/src/views/settings/subscription_custom/select_payment.view.dart @@ -7,8 +7,8 @@ import 'package:twonly/src/model/protobuf/api/websocket/error.pbserver.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; -import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; -import 'package:twonly/src/views/settings/subscription/voucher.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/subscription.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/voucher.view.dart'; import 'package:url_launcher/url_launcher.dart'; class SelectPaymentView extends StatefulWidget { @@ -17,13 +17,11 @@ class SelectPaymentView extends StatefulWidget { this.plan, this.payMonthly, this.valueInCents, - this.refund, }); final SubscriptionPlan? plan; final bool? payMonthly; final int? valueInCents; - final int? refund; @override State createState() => _SelectPaymentViewState(); @@ -190,30 +188,6 @@ class _SelectPaymentViewState extends State { ), ), ), - if (widget.refund != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Card( - color: context.color.surfaceContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - context.lang.refund, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - Text( - '+${localePrizing(context, widget.refund!)}', - textAlign: TextAlign.end, - style: TextStyle(color: context.color.primary), - ), - ], - ), - ), - ), - ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Card( diff --git a/lib/src/views/settings/subscription_custom/subscription.view.dart b/lib/src/views/settings/subscription_custom/subscription.view.dart new file mode 100644 index 0000000..96b5012 --- /dev/null +++ b/lib/src/views/settings/subscription_custom/subscription.view.dart @@ -0,0 +1,570 @@ +// ignore_for_file: inference_failure_on_instance_creation +import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; +import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'; +import 'package:twonly/src/providers/purchases.provider.dart'; +import 'package:twonly/src/services/subscription.service.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/utils/storage.dart'; +import 'package:twonly/src/views/components/better_list_title.dart'; +import 'package:twonly/src/views/settings/subscription/additional_users.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/checkout.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/manage_subscription.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/transaction.view.dart'; +import 'package:twonly/src/views/settings/subscription_custom/voucher.view.dart'; + +String localePrizing(BuildContext context, int cents) { + final myLocale = Localizations.localeOf(context); + final euros = cents / 100; + + if (euros == euros.toInt()) { + return '${euros.toInt()}€'; + } + + return NumberFormat.currency( + locale: myLocale.toString(), + symbol: '€', + decimalDigits: 2, + ).format(cents / 100); +} + +Future loadPlanBalance({bool useCache = true}) async { + final ballance = await apiService.getPlanBallance(); + if (ballance != null) { + await updateUserdata((u) { + u.lastPlanBallance = ballance.writeToJson(); + return u; + }); + return ballance; + } + final user = await getUser(); + if (user != null && user.lastPlanBallance != null && useCache) { + try { + return Response_PlanBallance.fromJson( + user.lastPlanBallance!, + ); + } catch (e) { + Log.error('from json: $e'); + } + } + return ballance; +} + +// ignore: constant_identifier_names +const int MONTHLY_PAYMENT_DAYS = 30; +// ignore: constant_identifier_names +const int YEARLY_PAYMENT_DAYS = 365; + +class SubscriptionCustomView extends StatefulWidget { + const SubscriptionCustomView({super.key, this.redirectError}); + + final ErrorCode? redirectError; + + @override + State createState() => _SubscriptionCustomViewState(); +} + +class _SubscriptionCustomViewState extends State { + bool loaded = false; + bool testerRequested = true; + Response_PlanBallance? ballance; + String? additionalOwnerName; + + @override + void initState() { + super.initState(); + unawaited(initAsync()); + } + + Future initAsync() async { + ballance = await loadPlanBalance(); + if (ballance != null && ballance!.hasAdditionalAccountOwnerId()) { + final ownerId = ballance!.additionalAccountOwnerId.toInt(); + final contact = await twonlyDB.contactsDao + .getContactByUserId(ownerId) + .getSingleOrNull(); + if (contact != null) { + additionalOwnerName = getContactDisplayName(contact); + } else { + additionalOwnerName = ownerId.toString(); + } + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final myLocale = Localizations.localeOf(context); + String? formattedBalance; + DateTime? nextPayment; + final currentPlan = context.watch().plan; + + if (ballance != null) { + final lastPaymentDateTime = DateTime.fromMillisecondsSinceEpoch( + ballance!.lastPaymentDoneUnixTimestamp.toInt() * 1000, + ); + if (isPayingUser(currentPlan)) { + nextPayment = lastPaymentDateTime + .add(Duration(days: ballance!.paymentPeriodDays.toInt())); + } + final ballanceInCents = + ballance!.transactions.map((a) => a.depositCents.toInt()).sum; + formattedBalance = NumberFormat.currency( + locale: myLocale.toString(), + symbol: '€', + decimalDigits: 2, + ).format(ballanceInCents / 100); + } + + return Scaffold( + appBar: AppBar( + title: Text(context.lang.settingsSubscription), + ), + body: ListView( + children: [ + if (widget.redirectError != null) + Center( + child: Container( + padding: const EdgeInsets.all(16), + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orangeAccent, + borderRadius: BorderRadius.circular(15), + ), + child: Text( + (widget.redirectError == ErrorCode.PlanLimitReached) + ? context.lang.planLimitReached + : context.lang.planNotAllowed, + style: const TextStyle(color: Colors.black), + textAlign: TextAlign.center, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Container( + decoration: BoxDecoration( + color: context.color.primary, + borderRadius: BorderRadius.circular(15), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: Text( + currentPlan.name, + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: isDarkMode(context) ? Colors.black : Colors.white, + ), + ), + ), + ), + ), + if (additionalOwnerName != null) + Center( + child: Text( + context.lang.partOfPaidPlanOf(additionalOwnerName!), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.orange), + ), + ), + if (!isPayingUser(currentPlan)) + Center( + child: Padding( + padding: const EdgeInsets.all(18), + child: Text( + context.lang.upgradeToPaidPlan, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18), + ), + ), + ), + if (!isPayingUser(currentPlan) || + currentPlan == SubscriptionPlan.Tester) + PlanCard( + plan: SubscriptionPlan.Pro, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return const CheckoutView( + plan: SubscriptionPlan.Pro, + ); + }, + ), + ); + await initAsync(); + }, + ), + if (currentPlan != SubscriptionPlan.Family) + PlanCard( + plan: SubscriptionPlan.Family, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return CheckoutView( + plan: SubscriptionPlan.Family, + disableMonthlyOption: + currentPlan == SubscriptionPlan.Pro && + ballance!.paymentPeriodDays.toInt() == + YEARLY_PAYMENT_DAYS, + ); + }, + ), + ); + await initAsync(); + }, + ), + if (!isPayingUser(currentPlan)) ...[ + const SizedBox(height: 10), + Center( + child: Padding( + padding: const EdgeInsets.all(14), + child: Text( + context.lang.redeemUserInviteCode, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18), + ), + ), + ), + const SizedBox(height: 10), + PlanCard( + plan: SubscriptionPlan.Plus, + onTap: () async { + await redeemUserInviteCode(context, SubscriptionPlan.Plus.name); + await initAsync(); + }, + ), + ], + const SizedBox(height: 10), + if (currentPlan != SubscriptionPlan.Family) const Divider(), + BetterListTile( + icon: FontAwesomeIcons.gears, + text: context.lang.manageSubscription, + subtitle: (nextPayment != null) + ? Text( + '${context.lang.nextPayment}: ${DateFormat.yMMMMd(myLocale.toString()).format(nextPayment)}', + ) + : null, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return ManageSubscriptionView( + ballance: ballance, + nextPayment: nextPayment, + ); + }, + ), + ); + await initAsync(); + }, + ), + BetterListTile( + icon: FontAwesomeIcons.moneyBillTransfer, + text: context.lang.transactionHistory, + subtitle: (formattedBalance != null) + ? Text('${context.lang.currentBalance}: $formattedBalance') + : null, + onTap: () async { + if (formattedBalance == null) return; + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return TransactionView( + transactions: ballance?.transactions, + formattedBalance: formattedBalance!, + ); + }, + ), + ); + }, + ), + if (isPayingUser(currentPlan) || + currentPlan == SubscriptionPlan.Tester) + BetterListTile( + icon: FontAwesomeIcons.userPlus, + text: context.lang.manageAdditionalUsers, + subtitle: loaded ? Text('${context.lang.open}: 3') : null, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return AdditionalUsersView( + ballance: ballance, + ); + }, + ), + ); + await initAsync(); + }, + ), + BetterListTile( + icon: FontAwesomeIcons.ticket, + text: context.lang.createOrRedeemVoucher, + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return const VoucherView(); + }, + ), + ); + await initAsync(); + }, + ), + const SizedBox(height: 30), + ], + ), + ); + } +} + +int getPlanPrice(SubscriptionPlan plan, {required bool paidMonthly}) { + switch (plan) { + case SubscriptionPlan.Pro: + return paidMonthly ? 100 : 1000; + case SubscriptionPlan.Family: + return paidMonthly ? 200 : 2000; + // ignore: no_default_cases + default: + return 0; + } +} + +class PlanCard extends StatelessWidget { + const PlanCard({ + required this.plan, + super.key, + this.refund, + this.onTap, + this.paidMonthly, + }); + final SubscriptionPlan plan; + final void Function()? onTap; + final int? refund; + final bool? paidMonthly; + + @override + Widget build(BuildContext context) { + final yearlyPrice = getPlanPrice(plan, paidMonthly: false); + final monthlyPrice = getPlanPrice(plan, paidMonthly: true); + var features = []; + + switch (plan.name) { + case 'Free': + features = [context.lang.freeFeature1]; + case 'Plus': + features = [context.lang.plusFeature1, context.lang.plusFeature2]; + case 'Tester': + case 'Pro': + features = [ + context.lang.proFeature1, + context.lang.proFeature2, + context.lang.proFeature3, + context.lang.proFeature4, + ]; + case 'Family': + features = [ + context.lang.proFeature1, + context.lang.familyFeature2, + context.lang.proFeature3, + context.lang.proFeature4, + ]; + default: + } + + return Padding( + padding: const EdgeInsets.only(left: 16, right: 16), + child: GestureDetector( + onTap: onTap, + child: Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + color: context.color.surfaceContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text( + plan.name, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (yearlyPrice != 0) const SizedBox(height: 10), + if (yearlyPrice != 0 && paidMonthly == null) + Column( + children: [ + if (paidMonthly == null || paidMonthly!) + Text( + '${localePrizing(context, yearlyPrice)}/${context.lang.year}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + if (paidMonthly == null || !paidMonthly!) + Text( + '${localePrizing(context, monthlyPrice)}/${context.lang.month}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + if (paidMonthly != null) + Text( + (paidMonthly!) + ? '${localePrizing(context, monthlyPrice)}/${context.lang.month}' + : '${localePrizing(context, yearlyPrice)}/${context.lang.year}', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 10), + ...features.map( + (feature) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + feature, + textAlign: TextAlign.center, + ), + ), + ), + if (refund != null && refund! > 0) + Padding( + padding: const EdgeInsets.only(top: 7), + child: Text( + context.lang + .subscriptionRefund(localePrizing(context, refund!)), + textAlign: TextAlign.center, + style: TextStyle( + color: context.color.primary, + fontSize: 12, + ), + ), + ), + if (onTap != null) + Padding( + padding: const EdgeInsets.only(top: 10), + child: FilledButton.icon( + onPressed: onTap, + label: (plan == SubscriptionPlan.Free || + plan == SubscriptionPlan.Plus) + ? Text(context.lang.redeemUserInviteCodeTitle) + : Text( + context.lang + .upgradeToPaidPlanButton(plan.name, ''), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +Future redeemUserInviteCode(BuildContext context, String newPlan) async { + var inviteCode = ''; + // ignore: inference_failure_on_function_invocation + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(context.lang.redeemUserInviteCodeTitle), + content: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: TextField( + onChanged: (value) => setState(() { + inviteCode = value.toUpperCase(); + }), + decoration: InputDecoration( + labelText: context.lang.registerTwonlyCodeLabel, + border: const OutlineInputBorder(), + ), + textCapitalization: TextCapitalization.characters, + ), + ), + ], + ), + ); + }, + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(context.lang.cancel), + ), + TextButton( + onPressed: () async { + final res = await apiService.redeemUserInviteCode(inviteCode); + if (!context.mounted) return; + if (res.isSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.lang.redeemUserInviteCodeSuccess), + ), + ); + // reconnect to load new plan. + await apiService.close(() {}); + await apiService.connect(force: true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + errorCodeToText(context, res.error as ErrorCode), + ), + ), + ); + } + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + child: Text(context.lang.ok), + ), + ], + ); + }, + ); +} diff --git a/lib/src/views/settings/subscription/transaction.view.dart b/lib/src/views/settings/subscription_custom/transaction.view.dart similarity index 100% rename from lib/src/views/settings/subscription/transaction.view.dart rename to lib/src/views/settings/subscription_custom/transaction.view.dart diff --git a/lib/src/views/settings/subscription/voucher.view.dart b/lib/src/views/settings/subscription_custom/voucher.view.dart similarity index 100% rename from lib/src/views/settings/subscription/voucher.view.dart rename to lib/src/views/settings/subscription_custom/voucher.view.dart diff --git a/pubspec.lock b/pubspec.lock index 573f7b3..183f931 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "8a1f5f3020ef2a74fb93f7ab3ef127a8feea33a7a2276279113660784ee7516a" + sha256: e4a1b612fd2955908e26116075b3a4baf10c353418ca645b4deae231c82bf144 url: "https://pub.dev" source: hosted - version: "1.3.64" + version: "1.3.65" adaptive_number: dependency: "direct overridden" description: @@ -516,10 +516,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "1f2dfd9f535d81f8b06d7a50ecda6eac1e6922191ed42e09ca2c84bd2288927c" + sha256: "29cfa93c771d8105484acac340b5ea0835be371672c91405a300303986f4eba9" url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.3.0" firebase_core_platform_interface: dependency: transitive description: @@ -532,34 +532,34 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: ff18fabb0ad0ed3595d2f2c85007ecc794aadecdff5b3bb1460b7ee47cded398 + sha256: a631bbfbfa26963d68046aed949df80b228964020e9155b086eff94f462bbf1f url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.3.1" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "22086f857d2340f5d973776cfd542d3fb30cf98e1c643c3aa4a7520bb12745bb" + sha256: "1ad663fbb6758acec09d7e84a2e6478265f0a517f40ef77c573efd5e0089f400" url: "https://pub.dev" source: hosted - version: "16.0.4" + version: "16.1.0" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: a59920cbf2eb7c83d34a5f354331210ffec116b216dc72d864d8b8eb983ca398 + sha256: ea620e841fbcec62a96984295fc628f53ef5a8da4f53238159719ed0af7db834 url: "https://pub.dev" source: hosted - version: "4.7.4" + version: "4.7.5" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "1183e40e6fd2a279a628951cc3b639fcf5ffe7589902632db645011eb70ebefb" + sha256: "7d0fb6256202515bba8489a3d69c6bc9d52d69a4999bad789053b486c8e7323e" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.1.1" fixnum: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 0d18329..c41e77b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec publish_to: 'none' -version: 0.0.75+75 +version: 0.0.76+76 environment: sdk: ^3.6.0 @@ -47,8 +47,8 @@ dependencies: # Trustworthy publishers - firebase_core: ^4.2.0 # firebase.google.com - firebase_messaging: ^16.0.3 # firebase.google.com + firebase_core: ^4.3.0 # firebase.google.com + firebase_messaging: ^16.1.0 # firebase.google.com json_annotation: ^4.9.0 # google.dev protobuf: ^4.0.0 # google.dev scrollable_positioned_list: ^0.3.8 # google.dev