From 9e98536e4f9ac2af2991578eacefdc2e6120ea5f Mon Sep 17 00:00:00 2001 From: Joaoaraujo97 Date: Wed, 19 Jun 2024 15:28:17 -0300 Subject: [PATCH] Initial implementation of purchase collection. --- assets/collections/0_chatGPT_premium.json | 44 ++++++++++ assets/collections/bdd_fundamentos_01.json | 5 +- assets/collections/comecando_com_git.json | 5 +- .../collections/ecossistema_do_flutter.json | 6 +- assets/collections/fundamentos_scrum.json | 5 +- assets/collections/guia_scrum.json | 5 +- assets/collections/kotlin_fundamentos_01.json | 5 +- assets/collections/manifesto_agil.json | 4 +- assets/collections/swift_fundamentos_01.json | 5 +- assets/images/icons/2.0x/lock.png | Bin 0 -> 446 bytes assets/images/icons/3.0x/lock.png | Bin 0 -> 658 bytes assets/images/icons/lock.png | Bin 0 -> 262 bytes ios/Runner.xcodeproj/project.pbxproj | 13 +-- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- lib/application/app.dart | 1 + lib/application/constants/dimensions.dart | 2 + .../constants/exception_strings.dart | 3 + lib/application/constants/images.dart | 4 + lib/application/constants/strings.dart | 4 + .../pages/execution/execution_terminal.dart | 1 - .../collections/collections_list_view.dart | 81 ++++++++++++++---- lib/application/view-models/app_vm.dart | 22 +++++ .../view-models/home/collections_vm.dart | 43 +++++++++- .../home/update_collection_vm.dart | 1 - .../view-models/item_metadata.dart | 32 ++++++- .../widgets/theme/collection_card.dart | 31 ++++++- .../widgets/theme/item_collection_card.dart | 2 + .../widgets/theme/rich_text_field.dart | 1 - lib/core/env.dart | 14 +++ .../faults/exceptions/base_exception.dart | 4 + .../faults/exceptions/purchase_exception.dart | 9 ++ lib/data/gateways/purchase_gateway.dart | 76 ++++++++++++++++ lib/data/gateways/sembast_database.dart | 3 +- .../collection_purchase_repository.dart | 55 ++++++++++++ .../repositories/collection_repository.dart | 2 + .../collection_memos_serializer.dart | 12 +++ .../serializers/collection_serializer.dart | 10 +++ lib/domain/models/collection.dart | 17 ++++ .../collection_purchase_services.dart | 70 +++++++++++++++ lib/domain/services/collection_services.dart | 50 +++++++++-- lib/domain/transients/collection_memos.dart | 14 +++ lib/domain/transients/collection_status.dart | 4 +- pubspec.yaml | 1 + .../widgets/theme/custom_button_test.dart | 2 +- .../collection_memos_serializer_test.dart | 4 +- .../collection_serializer_test.dart | 4 + test/domain/models/collection_test.dart | 2 + .../transients/collection_memos_test.dart | 2 + test/fixtures/collection.json | 7 +- test/fixtures/collection_memos.json | 8 +- 50 files changed, 638 insertions(+), 59 deletions(-) create mode 100644 assets/collections/0_chatGPT_premium.json create mode 100644 assets/images/icons/2.0x/lock.png create mode 100644 assets/images/icons/3.0x/lock.png create mode 100644 assets/images/icons/lock.png create mode 100644 lib/core/faults/exceptions/purchase_exception.dart create mode 100644 lib/data/gateways/purchase_gateway.dart create mode 100644 lib/data/repositories/collection_purchase_repository.dart create mode 100644 lib/domain/services/collection_purchase_services.dart diff --git a/assets/collections/0_chatGPT_premium.json b/assets/collections/0_chatGPT_premium.json new file mode 100644 index 00000000..05f357c5 --- /dev/null +++ b/assets/collections/0_chatGPT_premium.json @@ -0,0 +1,44 @@ +{ + "id": "0_chatGPT_premium", + "name": "teste", + "description": "teste", + "category": "Premium", + "tags": [ + "ChatGPT" + ], + "isPremium": true, + "appStoreId": "com.olmps.memo_099_in_app_purchase_deck", + "playStoreId": null, + "contributors": [ + { + "name": "chatGPT", + "url": "https://www.linkedin.com/", + "imageUrl": "https://media-exp1.licdn.com/dms/image/C4E03AQFYmdJJeE9gmA/profile-displayphoto-shrink_400_400/0/1517688960443?e=1631750400&v=beta&t=bpxRB_CL0-3CaT4nzadf1PKpSblm2I_Z7yjm6gEdaBM" + } + ], + "memos": [ + { + "uniqueId": "03c9a8d5-9e27-4ec2-8a3c-7bf20431b890", + "question": [ + { + "insert": "Testes e mais testes\n" + } + ], + "answer": [ + { + "insert": "Isso é mais conhecido como " + }, + { + "insert": "testes", + "attributes": { + "bold": true, + "underline": true + } + }, + { + "insert": ".\n" + } + ] + } + ] +} \ No newline at end of file diff --git a/assets/collections/bdd_fundamentos_01.json b/assets/collections/bdd_fundamentos_01.json index 9a1f50f6..837f6fe6 100644 --- a/assets/collections/bdd_fundamentos_01.json +++ b/assets/collections/bdd_fundamentos_01.json @@ -3,7 +3,10 @@ "name": "BDD - Fundamentos", "description": "Existem diversos paradigmas de desenvolvimento de software. Dentre estes, está o desenvolvimento orientado a comportamento (BDD). Neste deck, vamos conhecer um pouco sobre o BDD, uma das melhores formas de otimizar tanto seu processo de desenvolvimento quanto o produto final gerado.", "category": "Testes", - "tags": ["tests", "bdd"], + "tags": [ + "tests", + "bdd" + ], "contributors": [ { "name": "Nicolas Nascimento", diff --git a/assets/collections/comecando_com_git.json b/assets/collections/comecando_com_git.json index 50bb78ae..52ee6fd2 100644 --- a/assets/collections/comecando_com_git.json +++ b/assets/collections/comecando_com_git.json @@ -3,7 +3,10 @@ "name": "Começando com Git", "description": "\"Git é um sistema de controle de versões distribuído, usado principalmente no desenvolvimento de software, mas pode ser usado para registrar o histórico de edições de qualquer tipo de arquivo. O Git foi inicialmente projetado e desenvolvido por Linus Torvalds para o desenvolvimento do kernel Linux, mas foi adotado por muitos outros projetos.\" - Wikipedia, 2021.", "category": "Versionamento", - "tags": ["git", "versionamento"], + "tags": [ + "git", + "versionamento" + ], "contributors": [ { "name": "@matuella", diff --git a/assets/collections/ecossistema_do_flutter.json b/assets/collections/ecossistema_do_flutter.json index 7e8a0cf1..c95da844 100644 --- a/assets/collections/ecossistema_do_flutter.json +++ b/assets/collections/ecossistema_do_flutter.json @@ -3,7 +3,11 @@ "name": "Ecossistema do Flutter - Fundamentos", "description": "\"Flutter é um kit de desenvolvimento de interface de usuário (UI toolkit), de código aberto, criado pelo Google, que possibilita a criação de aplicativos compilados nativamente. Atualmente pode compilar para Android, iOS, Windows, Mac, Linux, Google Fuchsia e Web.\" - Wikipedia, 2021.", "category": "Flutter", - "tags": ["flutter", "framework", "cross-platform"], + "tags": [ + "flutter", + "framework", + "cross-platform" + ], "contributors": [ { "name": "@matuella", diff --git a/assets/collections/fundamentos_scrum.json b/assets/collections/fundamentos_scrum.json index b01a87b7..1fd2dd10 100644 --- a/assets/collections/fundamentos_scrum.json +++ b/assets/collections/fundamentos_scrum.json @@ -3,7 +3,10 @@ "name": "Fundamentos do Scrum", "description": "O Scrum é um framework de gerenciamento de projetos, da organização ao desenvolvimento ágil de produtos complexos e adaptativos com o mais alto valor possível, através de várias técnicas, utilizado desde o início de 1990 e que atualmente é utilizado em mais de 60% dos projetos ágeis em todo o mundo. - Wikipedia, 2021", "category": "Scrum", - "tags": ["agile", "scrum"], + "tags": [ + "agile", + "scrum" + ], "contributors": [ { "name": "Olympus", diff --git a/assets/collections/guia_scrum.json b/assets/collections/guia_scrum.json index 9c687274..59351432 100644 --- a/assets/collections/guia_scrum.json +++ b/assets/collections/guia_scrum.json @@ -3,7 +3,10 @@ "name": "Guia do Scrum", "description": "O guia do scrum é um documento pequeno, que descreve tudo o que existe no Scrum. Muitas pessoas que dizem conhecer o Scrum, nunca leram o documento. Não pode ser pela quantidade de páginas, que são menos de 20. O guia do scrum estabelece pilares, valores, artefatos, papéis e responsabilidades para que uma equipe possa organizar o seu fluxo de trabalho, encontrando formas de priorizar o trabalho a ser realizado, acompanhar dificuldades e progresso, revisar o trabalho feito e ainda garantir ações de melhoria ao longo do tempo.", "category": "Scrum", - "tags": ["agile", "scrum"], + "tags": [ + "agile", + "scrum" + ], "contributors": [ { "name": "Daniel Wildt", diff --git a/assets/collections/kotlin_fundamentos_01.json b/assets/collections/kotlin_fundamentos_01.json index afbdb937..91237957 100644 --- a/assets/collections/kotlin_fundamentos_01.json +++ b/assets/collections/kotlin_fundamentos_01.json @@ -3,7 +3,10 @@ "name": "Kotlin - Fundamentos", "description": "Nessa coleção de memórias você vai ser introduzido na linguagem de programação Kotlin. Embora ler a documentação do Kotlin seja fácil e agradável, aprender por exemplos é um tipo diferente de diversão (confira os links abaixo).", "category": "Kotlin", - "tags": ["kotlin", "linguagem de programação"], + "tags": [ + "kotlin", + "linguagem de programação" + ], "contributors": [ { "name": "Lucas Montano", diff --git a/assets/collections/manifesto_agil.json b/assets/collections/manifesto_agil.json index 0e470997..28d888e0 100644 --- a/assets/collections/manifesto_agil.json +++ b/assets/collections/manifesto_agil.json @@ -3,7 +3,9 @@ "name": "Manifesto Ágil", "description": "Em Fevereiro de 2001, no Snowbird ski resort em Utah, 17 pessoas se apresentam para falar, se divertir e encontrar caminhos comuns nas práticas de engenharia e organização de projetos que vinham testando, validando e aprendendo. O que emerge deste encontro de 2 dias foi o Agile ‘Software Development’ Manifesto. Representantes de diferentes metodologias estavam presentes nesta data: Extreme Programming, SCRUM, DSDM, Adaptive Software Development, Crystal, Feature-Driven Development, Pragmatic Programming, e outras pessoas que eram simpáticas a necessidade de alternativas para projetos direcionados por documentação, normalmente direcionados por processos de desenvolvimento de software pesados. Todos signatários terminam o encontro com o termo Agile, termo que aparece em cena por indicação de Martin Fowler, que já aparecia na cena de eXtreme Programming anos anteriores.", "category": "Metodologia Ágil", - "tags": ["agile"], + "tags": [ + "agile" + ], "contributors": [ { "name": "Daniel Wildt", diff --git a/assets/collections/swift_fundamentos_01.json b/assets/collections/swift_fundamentos_01.json index bcd10d28..a128adf4 100644 --- a/assets/collections/swift_fundamentos_01.json +++ b/assets/collections/swift_fundamentos_01.json @@ -3,7 +3,10 @@ "name": "Fundamentos de Swift I", "description": "\"Swift é uma linguagem de programação desenvolvida pela Apple para desenvolvimento no iOS, macOS, watchOS, tvOS e Linux. O compilador usa a infraestrutura do LLVM e é distribuído junto do Xcode desde a versão 6.\" - Wikipedia, 2021", "category": "Swift", - "tags": ["swift", "linguagem de programação"], + "tags": [ + "swift", + "linguagem de programação" + ], "contributors": [ { "name": "@matuella", diff --git a/assets/images/icons/2.0x/lock.png b/assets/images/icons/2.0x/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..3734573cc6cdfd8b4fb45c4b37dbf1a8f0d53f53 GIT binary patch literal 446 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?2=RS;(M3{v?36l5$8 za(7}_cTVOdki(Mh=F{Fa=?Tp^Q!v;L9@;(7(CpZ$DH+Y&a zkT#jyAb5(cCtwBxQ0yh!qo+xI*QVUyJK_~xTfQqUTKm%Gr~4V&5+%f&J9IL%Z*i}k zm@oDHwoq~FN!v?NoR`n|aR}e?vhtt79IIqBPuFDPzMPs}MyoQPI_{qIO=r1x4lA$3 zjtP%Dwcb@vd~vxmO2M3GmyhSa1M4TuSDsieB2n^OHBkM>wR!LPc3NKSPHoD6I9<@K zsCi;ZV85Kpgah$^_wP75Gnr}2PC3Ro#Z#lLqU)J<{h$2f=>Aeetv~8D528M8?mMZw zqr`LnzWvVa?CaL8cjis<(paUgrX*pV##J^wfuM9A_89ZJ6T-G@yGywpsg0TAl literal 0 HcmV?d00001 diff --git a/assets/images/icons/3.0x/lock.png b/assets/images/icons/3.0x/lock.png new file mode 100644 index 0000000000000000000000000000000000000000..27ecd175faed3b236457e701e733441ec40fcc5a GIT binary patch literal 658 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#S9FJ79h;%I?XTvD9BhG zjkW98v+=k7$X=<9IP7nuI=5K+{WMXlut=#-~BUvd=n<@*=eo!pFs%-7TH$q z5D?sX-M)5h`r5{@rKR)l-OJG3Dz)}~-1_gW|CLx4n_h}K{V^;mc`es=pYjj7#xrUi z_ddS1B+Pi)XG!lji~cPw5G$(dp3UJ|o}_hm<{{(G=8dV#OsC9cjQIaPdvC^7cb)ge zK3vv0Zz5hO{Z+oc*=obQtrdStCm(Ju>h*EU{?(vfwBxHzfJah!PPS_uTTW-P*KDrV zo(I;F-R~~+KA(0y+cqa7KttC$v+_4%oX77y=?%iIrCZiiv+6JTTkz;fq0^%y#q&+A zE}XnrbK_R+`fPE_)89+?{)>(6bu@2gl$4mz2qpaZG<@$IvYdal%&F+X=JdiNzpiJ_ zZrdT!dEC`_XPx2-uGTKKu$SEF9n-UHGPcN-%I$Pqc1h2yX?{VI*XkEva>}?TRUEKv z_V>Tl^~{oc%9aJ;Kc?<|tI9a@`NZ`%7-o9ynsH#`9pySd5ni4X2O4;YA_9a0nD=%?g%;l?pb*H<{@#WT^1>3WBB=2kD=Y14V%s@(7 z;wZ<&fO6X@UZ>^%zAI{fQJs02VWx;c;NgqKK3kN|vuP$Q?E6#F;kasxQ&8pUz?%l= z@_4pGB<{86%)tikRc}pIp5NBoy=TqJ!sBY|o?Q6w z=!nC86Ty@_-``hH;Qg0q;Bda+cf0jT|8|{>NygP_a$b&Y7h}!71D(m>>FVdQ&MBb@ E04tASkpKVy literal 0 HcmV?d00001 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e061390a..d206708f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -165,7 +165,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -212,10 +212,12 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -266,6 +268,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -379,7 +382,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -461,7 +464,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -512,7 +515,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c87d15a3..5e31d3d3 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ readCoordinator(ref).navigateToCollectionDetails(item.id), - ).withOnlyPadding(context, bottom: Spacing.medium); - } - - throw InconsistentStateError.layout('Unsupported subtype (${item.runtimeType}) of `CollectionItemMetadata`'); - }, + final vm = ref.watch(collectionsVM.notifier); + + ref.listen(collectionsVM, (_, state) { + if (state is PurchaseCollectionFailed) { + Navigator.of(context).pop(); + showExceptionSnackBar(ref, state.exception); + } + if (state is PurchaseCollectionSuccess) { + Navigator.of(context).pop(); + showSnackBar( + ref, + const SnackBar(content: Text(strings.collectionSuccessPurchase)), + ); + } + }); + + return RefreshIndicator( + onRefresh: vm.onRefresh, + child: ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + // Builds the respective widget based on the item's `ItemMetadata` subtype. + final item = items[index]; + + if (item is CollectionsCategoryMetadata) { + return _CollectionsSectionHeader(title: item.name) + .withOnlyPadding(context, top: Spacing.xLarge, bottom: Spacing.small); + } else if (item is CollectionItem) { + return buildCollectionCardFromItem( + item, + padding: context.symmetricInsets(vertical: Spacing.large, horizontal: Spacing.small), + onTap: item.isVisible + ? () async => collectionPurchaseBottomSheet(context, () => vm.purchaseCollection(item.id)) + : () => readCoordinator(ref).navigateToCollectionDetails(item.id), + isVisible: item.isVisible, + ).withOnlyPadding(context, bottom: Spacing.medium); + } + + throw InconsistentStateError.layout('Unsupported subtype (${item.runtimeType}) of `CollectionItemMetadata`'); + }, + ), ); } } @@ -50,3 +77,19 @@ class _CollectionsSectionHeader extends ConsumerWidget { return Text(title, style: sectionTitleStyle); } } + +/// This Modal Bottom Sheet representing the option to purchase a specific `Collection`. +Future collectionPurchaseBottomSheet(BuildContext context, VoidCallback? onPressed) => + showSnappableDraggableModalBottomSheet( + context, + title: strings.collectionPurchase, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + context.verticalBox(Spacing.xLarge), + PrimaryElevatedButton(text: strings.purchase, onPressed: onPressed), + context.verticalBox(Spacing.medium), + SecondaryElevatedButton(text: strings.cancel, onPressed: Navigator.of(context).pop), + ], + ).withAllPadding(context, Spacing.medium), + ); diff --git a/lib/application/view-models/app_vm.dart b/lib/application/view-models/app_vm.dart index 5c092678..b88f9fb2 100644 --- a/lib/application/view-models/app_vm.dart +++ b/lib/application/view-models/app_vm.dart @@ -1,9 +1,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:memo/core/env.dart'; import 'package:memo/data/gateways/application_bundle.dart'; +import 'package:memo/data/gateways/purchase_gateway.dart'; import 'package:memo/data/gateways/sembast.dart' as sembast; import 'package:memo/data/gateways/sembast_database.dart'; +import 'package:memo/data/repositories/collection_purchase_repository.dart'; import 'package:memo/data/repositories/collection_repository.dart'; import 'package:memo/data/repositories/memo_execution_repository.dart'; import 'package:memo/data/repositories/memo_repository.dart'; @@ -12,6 +15,7 @@ import 'package:memo/data/repositories/transaction_handler.dart'; import 'package:memo/data/repositories/user_repository.dart'; import 'package:memo/data/repositories/version_repository.dart'; import 'package:memo/domain/isolated_services/memory_recall_services.dart'; +import 'package:memo/domain/services/collection_purchase_services.dart'; import 'package:memo/domain/services/collection_services.dart'; import 'package:memo/domain/services/execution_services.dart'; import 'package:memo/domain/services/progress_services.dart'; @@ -53,6 +57,8 @@ class AppVMImpl extends AppVM { } _hasRequestedLoading = true; + final env = envMetadata(); + // Set a minimum (reasonable) duration for this first load, as it may simply flick a splash screen if too fast. final splashMinDuration = Future.delayed(const Duration(milliseconds: 500)); @@ -64,6 +70,7 @@ class AppVMImpl extends AppVM { // Gateways final dbRepo = SembastDatabaseImpl(firstClassDependencies[0] as Database); final appBundle = ApplicationBundleImpl(assetBundle); + final purchaseGateway = PurchaseGatewayImpl(env); // Repositories final collectionRepo = CollectionRepositoryImpl(dbRepo, appBundle); @@ -72,6 +79,7 @@ class AppVMImpl extends AppVM { final userRepo = UserRepositoryImpl(dbRepo); final versionRepo = VersionRepositoryImpl(dbRepo); final resourceRepo = ResourceRepositoryImpl(dbRepo, appBundle); + final collectionPurchaseRepo = CollectionPurchaseRepositoryImpl(dbRepo, purchaseGateway, collectionRepo); final transactionHandler = TransactionHandlerImpl(dbRepo); @@ -79,10 +87,18 @@ class AppVMImpl extends AppVM { final memoryServices = MemoryRecallServicesImpl(); // Services + final collectionPurchaseServices = CollectionPurchaseServicesImpl( + env: env, + collectionPurchaseRepo: collectionPurchaseRepo, + collectionRepo: collectionRepo, + ); + final collectionServices = CollectionServicesImpl( collectionRepo: collectionRepo, memoRepo: memoRepo, memoryServices: memoryServices, + collectionPurchaseRepo: collectionPurchaseRepo, + collectionPurchaseServices: collectionPurchaseServices, ); final executionServices = ExecutionServicesImpl( @@ -103,6 +119,7 @@ class AppVMImpl extends AppVM { executionServices: executionServices, progressServices: progressServices, resourceServices: resourceServices, + collectionPurchaseServices: collectionPurchaseServices, ); // Scope-specific Services @@ -132,12 +149,14 @@ class AppState { required this.executionServices, required this.progressServices, required this.resourceServices, + required this.collectionPurchaseServices, }); final CollectionServices collectionServices; final ExecutionServices executionServices; final ProgressServices progressServices; final ResourceServices resourceServices; + final CollectionPurchaseServices collectionPurchaseServices; } // Creates uninitialized Provider for all services, which MUST BE overriden in the root `ProviderScope.overrides`. @@ -153,3 +172,6 @@ final progressServices = Provider((_) { final resourceServices = Provider((_) { throw UnimplementedError('resourceServices Provider must be overridden'); }); +final collectionPurchaseServices = Provider((_) { + throw UnimplementedError('collectionPurchaseServices Provider must be overridden'); +}); diff --git a/lib/application/view-models/home/collections_vm.dart b/lib/application/view-models/home/collections_vm.dart index e7247b02..329205e8 100644 --- a/lib/application/view-models/home/collections_vm.dart +++ b/lib/application/view-models/home/collections_vm.dart @@ -4,37 +4,63 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:memo/application/view-models/app_vm.dart'; import 'package:memo/application/view-models/item_metadata.dart'; +import 'package:memo/core/faults/exceptions/base_exception.dart'; +import 'package:memo/domain/services/collection_purchase_services.dart'; import 'package:memo/domain/services/collection_services.dart'; import 'package:memo/domain/transients/collection_status.dart'; import 'package:meta/meta.dart'; final collectionsVM = StateNotifierProvider((ref) { - return CollectionsVMImpl(ref.read(collectionServices)); + return CollectionsVMImpl(ref.read(collectionServices), ref.read(collectionPurchaseServices)); }); /// Segment used to filter the current state of the [CollectionsVM]. enum CollectionsSegment { explore, review } + const availableSegments = CollectionsSegment.values; abstract class CollectionsVM extends StateNotifier { CollectionsVM(CollectionsState state) : super(state); + /// Updates the collections list when connected to the internet. + Future onRefresh(); + /// Updates the current [state] with [segment]. /// /// Changing this [segment] also updates the displayed collections based on this [CollectionsSegment]. Future updateCollectionsSegment(CollectionsSegment segment); + + /// Purchase a deck, comparing its [id] and updating the isAvailable state to `true`. + Future purchaseCollection(String id); } class CollectionsVMImpl extends CollectionsVM { - CollectionsVMImpl(this._services) : super(LoadingCollectionsState(availableSegments.first)) { + CollectionsVMImpl(this._services, this._purchaseServices) : super(LoadingCollectionsState(availableSegments.first)) { _addCollectionsListeners(); } final CollectionServices _services; + final CollectionPurchaseServices _purchaseServices; StreamSubscription>? _statusListener; List _cachedCollectionItems = []; + @override + Future onRefresh() async { + await _addCollectionsListeners(); + } + + @override + Future purchaseCollection(String id) async { + try { + await _purchaseServices.purchaseCollection(id: id); + state = PurchaseCollectionSuccess(state.currentSegment); + } on BaseException catch (exception) { + state = PurchaseCollectionFailed(exception, state.currentSegment); + } + _updateToLoadedStateWithCachedMetadata(); + } + @override Future updateCollectionsSegment(CollectionsSegment segment) async { if (state is LoadingCollectionsState) { @@ -132,3 +158,16 @@ class LoadedCollectionsState extends CollectionsState { @override List get props => [collectionItems, ...super.props]; } + +class PurchaseCollectionFailed extends CollectionsState { + const PurchaseCollectionFailed(this.exception, CollectionsSegment currentSegment) : super(currentSegment); + + final BaseException exception; + + @override + List get props => [exception, ...super.props]; +} + +class PurchaseCollectionSuccess extends CollectionsState { + const PurchaseCollectionSuccess(CollectionsSegment currentSegment) : super(currentSegment); +} diff --git a/lib/application/view-models/home/update_collection_vm.dart b/lib/application/view-models/home/update_collection_vm.dart index f3b7da27..6700f6f6 100644 --- a/lib/application/view-models/home/update_collection_vm.dart +++ b/lib/application/view-models/home/update_collection_vm.dart @@ -94,7 +94,6 @@ class UpdateCollectionVMImpl extends UpdateCollectionVM { final loadedState = state as UpdateCollectionLoaded; try { // TODO(ggirotto): Call services to save the collection - } on BaseException catch (exception) { state = UpdateCollectionFailedSaving( exception, diff --git a/lib/application/view-models/item_metadata.dart b/lib/application/view-models/item_metadata.dart index f5bbd0d8..b9e1af9a 100644 --- a/lib/application/view-models/item_metadata.dart +++ b/lib/application/view-models/item_metadata.dart @@ -15,15 +15,23 @@ class CollectionsCategoryMetadata extends ItemMetadata { } abstract class CollectionItem extends ItemMetadata { - CollectionItem({required this.id, required this.name, required this.category, required this.tags}); + CollectionItem({ + required this.id, + required this.name, + required this.category, + required this.tags, + required this.isVisible, + }); final String id; final String name; final String category; final List tags; + final bool isVisible; + @override - List get props => [id, name, category, tags]; + List get props => [id, name, category, tags, isVisible]; } /// Represents a collection that have been fully executed - where no pristine memos are left. @@ -34,7 +42,14 @@ class CompletedCollectionItem extends CollectionItem { required String name, required String category, required List tags, - }) : super(id: id, name: name, category: category, tags: tags); + required bool isVisible, + }) : super( + id: id, + name: name, + category: category, + tags: tags, + isVisible: isVisible, + ); final double recallLevel; String get readableRecall => (recallLevel * 100).round().toString(); @@ -52,7 +67,14 @@ class IncompleteCollectionItem extends CollectionItem { required String name, required String category, required List tags, - }) : super(id: id, name: name, category: category, tags: tags); + required bool isVisibile, + }) : super( + id: id, + name: name, + category: category, + tags: tags, + isVisible: isVisibile, + ); final int executedUniqueMemos; final int totalUniqueMemos; @@ -75,6 +97,7 @@ CollectionItem mapStatusToMetadata(CollectionStatus status) { name: collection.name, category: collection.category, tags: collection.tags, + isVisible: status.isVisible, ); } else { return IncompleteCollectionItem( @@ -84,6 +107,7 @@ CollectionItem mapStatusToMetadata(CollectionStatus status) { name: collection.name, category: collection.category, tags: collection.tags, + isVisibile: status.isVisible, ); } } diff --git a/lib/application/widgets/theme/collection_card.dart b/lib/application/widgets/theme/collection_card.dart index 8c98f350..89b2e784 100644 --- a/lib/application/widgets/theme/collection_card.dart +++ b/lib/application/widgets/theme/collection_card.dart @@ -6,6 +6,7 @@ import 'package:layoutr/common_layout.dart'; import 'package:memo/application/constants/animations.dart' as anims; import 'package:memo/application/constants/colors.dart' as colors; import 'package:memo/application/constants/dimensions.dart' as dimens; +import 'package:memo/application/constants/images.dart' as images; import 'package:memo/application/theme/memo_theme_data.dart'; import 'package:memo/application/theme/theme_controller.dart'; import 'package:memo/application/widgets/animatable_progress.dart'; @@ -20,6 +21,7 @@ class CollectionCard extends ConsumerWidget { required this.name, required this.tags, required this.padding, + required this.isVisible, this.hasBorder = true, this.progressDescription, this.progressValue, @@ -52,6 +54,9 @@ class CollectionCard extends ConsumerWidget { final VoidCallback? onTap; + /// Indicator if deck is available. + final bool isVisible; + @override Widget build(BuildContext context, WidgetRef ref) { final theme = ref.watch(themeController); @@ -78,13 +83,15 @@ class CollectionCard extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Flexible(child: firstRowElements), + Flexible( + child: isVisible ? _buildLockedCollection(context, firstRowElements, theme) : firstRowElements, + ), if (progressDescription != null && progressValue != null) ...[ context.verticalBox(Spacing.large), _buildMemoryRecallTitle(context, theme), context.verticalBox(Spacing.xSmall), _buildMemoryRecallProgress(theme), - ] + ], ], ), ), @@ -149,6 +156,26 @@ class CollectionCard extends ConsumerWidget { semanticLabel: progressSemanticLabel, ); } + + Widget _buildLockedCollection(BuildContext context, Widget child, MemoThemeData theme) { + return Stack( + children: [ + Center( + child: Transform.scale( + scale: 2, + child: Image.asset(images.lockAsset, color: theme.neutralSwatch), + ), + ).withAllPadding(context, Spacing.small), + ImageFiltered( + imageFilter: ui.ImageFilter.blur( + sigmaX: dimens.collectionsBlurSize, + sigmaY: dimens.collectionsBlurSize, + ), + child: child, + ), + ], + ); + } } /// Custom background painter for a Card that represents a `Collection`. diff --git a/lib/application/widgets/theme/item_collection_card.dart b/lib/application/widgets/theme/item_collection_card.dart index 57f617f8..f30a8ce6 100644 --- a/lib/application/widgets/theme/item_collection_card.dart +++ b/lib/application/widgets/theme/item_collection_card.dart @@ -9,6 +9,7 @@ Widget buildCollectionCardFromItem( required EdgeInsets padding, bool hasBorder = true, VoidCallback? onTap, + bool isVisible = false, }) { String? progressDescription; double? progressValue; @@ -41,5 +42,6 @@ Widget buildCollectionCardFromItem( progressValue: progressValue, progressSemanticLabel: progressSemanticLabel, onTap: onTap, + isVisible: isVisible, ); } diff --git a/lib/application/widgets/theme/rich_text_field.dart b/lib/application/widgets/theme/rich_text_field.dart index 0195ac50..e9b62e5d 100644 --- a/lib/application/widgets/theme/rich_text_field.dart +++ b/lib/application/widgets/theme/rich_text_field.dart @@ -388,7 +388,6 @@ class _ThemedEditor extends ConsumerWidget { scrollable: true, padding: EdgeInsets.zero, autoFocus: !readOnly, - readOnly: readOnly, expands: false, enableInteractiveSelection: !readOnly, showCursor: !readOnly, diff --git a/lib/core/env.dart b/lib/core/env.dart index bb506877..f2d91695 100644 --- a/lib/core/env.dart +++ b/lib/core/env.dart @@ -13,6 +13,9 @@ abstract class EnvMetadata { /// `true` when running in [Env.dev]. bool get isDev; + + /// RevenueCat SDK API Key. + String get inAppPurchaseKey; } class EnvMetadataImpl implements EnvMetadata { @@ -34,6 +37,17 @@ class EnvMetadataImpl implements EnvMetadata { throw InconsistentStateError('Unsupported platform - ${Platform.operatingSystem}'); } + + @override + String get inAppPurchaseKey { + switch (platform) { + case SupportedPlatform.ios: + return 'appl_edKVhziuBuXDpmVPASASRdEJhKc'; + default: + // TODO joao: Replace with the correct key for Android + return 'appl_edKVhziuBuXDpmVPASASRdEJhKc'; + } + } } /// Application's supported environments. diff --git a/lib/core/faults/exceptions/base_exception.dart b/lib/core/faults/exceptions/base_exception.dart index 49f86b8f..46f77458 100644 --- a/lib/core/faults/exceptions/base_exception.dart +++ b/lib/core/faults/exceptions/base_exception.dart @@ -43,4 +43,8 @@ enum ExceptionType { // Validation emptyField, fieldLengthExceeded, + + // PurchaseException + purchaseProductFailed, + failedPurchase, } diff --git a/lib/core/faults/exceptions/purchase_exception.dart b/lib/core/faults/exceptions/purchase_exception.dart new file mode 100644 index 00000000..6aab49c7 --- /dev/null +++ b/lib/core/faults/exceptions/purchase_exception.dart @@ -0,0 +1,9 @@ +import 'package:memo/core/faults/exceptions/base_exception.dart'; + +/// Failed to purchase a deck for any specific reason. +class PurchaseException extends BaseException { + PurchaseException.purchaseProductFailed({String? debugInfo}) + : super(type: ExceptionType.purchaseProductFailed, debugInfo: debugInfo); + + PurchaseException.failedPurchase() : super(type: ExceptionType.failedPurchase); +} diff --git a/lib/data/gateways/purchase_gateway.dart b/lib/data/gateways/purchase_gateway.dart new file mode 100644 index 00000000..0034a048 --- /dev/null +++ b/lib/data/gateways/purchase_gateway.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:memo/core/env.dart'; +import 'package:memo/core/faults/exceptions/purchase_exception.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; + +/// Handles in-app purchases. +abstract class PurchaseGateway { + /// Purchase the product using a unique [identifier] per product. + /// + /// Throws a [PurchaseException.failedPurchase] if a purchase failed. + /// Throws a [PurchaseException.purchaseProductFailed] if anything wrong happens during the purchase flow. + Future purchase({required String identifier}); + + /// Get all purchases the user has made. + Future> purchasesInfo(); + + /// Check which products are available for purchase. + Future> getAvailableProducts(); +} + +class PurchaseGatewayImpl extends PurchaseGateway { + PurchaseGatewayImpl(this._env); + + final EnvMetadata _env; + + bool _hasInitialized = false; + + FutureOr _init() async { + PurchasesConfiguration? configuration; + configuration = PurchasesConfiguration(_env.inAppPurchaseKey); + + if (!_hasInitialized) { + await Purchases.setLogLevel(LogLevel.debug); + await Purchases.configure(configuration); + + _hasInitialized = true; + } + } + + @override + Future purchase({required String identifier}) async { + try { + await _init(); + await Purchases.purchaseProduct(identifier, type: PurchaseType.inapp); + } on PlatformException catch (exception) { + final errorCode = PurchasesErrorHelper.getErrorCode(exception); + if (errorCode == PurchasesErrorCode.offlineConnectionError) { + throw PurchaseException.failedPurchase(); + } + + throw PurchaseException.purchaseProductFailed(debugInfo: exception.toString()); + } + } + + @override + Future> purchasesInfo() async { + await _init(); + final customerInfo = await Purchases.getCustomerInfo(); + + final purchases = customerInfo.allPurchasedProductIdentifiers; + + return purchases; + } + + @override + Future> getAvailableProducts() async { + await _init(); + final offerings = await Purchases.getOfferings(); + + final productIdentifiers = offerings.current!.availablePackages; + + return productIdentifiers; + } +} diff --git a/lib/data/gateways/sembast_database.dart b/lib/data/gateways/sembast_database.dart index 26a5000c..7cd8f0c2 100644 --- a/lib/data/gateways/sembast_database.dart +++ b/lib/data/gateways/sembast_database.dart @@ -31,6 +31,7 @@ abstract class SembastTransactionHandler implements DatabaseTransactionHandler { await db.transaction((transaction) async { currentTransaction = transaction; await run(); + currentTransaction = null; }); // ignore: avoid_catches_without_on_clauses } catch (error, stack) { @@ -133,7 +134,7 @@ class SembastDatabaseImpl extends SembastDatabase { } @override - Future?> get({required String id, required String store}) { + Future?> get({required String id, required String store}) async { final storeMap = sembast.stringMapStoreFactory.store(store); return storeMap.record(id).get(currentTransaction ?? db); } diff --git a/lib/data/repositories/collection_purchase_repository.dart b/lib/data/repositories/collection_purchase_repository.dart new file mode 100644 index 00000000..bb26e60d --- /dev/null +++ b/lib/data/repositories/collection_purchase_repository.dart @@ -0,0 +1,55 @@ +import 'package:memo/data/gateways/purchase_gateway.dart'; +import 'package:memo/data/gateways/sembast_database.dart'; +import 'package:memo/data/repositories/collection_repository.dart'; +import 'package:memo/data/serializers/collection_serializer.dart'; + +abstract class CollectionPurchaseRepository { + /// Purchase products in the app with the store ID [storeId] for the local user. + Future purchaseInApp({required String storeId}); + + /// Receives purchase information made by the user. + Future> getPurchasesInfo(); + + /// Check which products are available for purchase. + Future> isAvailable(); + + /// Updates the collection with the [id] to be premium or not. + Future updatePurchaseCollection({required String id, required bool isPremium}); +} + +class CollectionPurchaseRepositoryImpl implements CollectionPurchaseRepository { + CollectionPurchaseRepositoryImpl(this._db, this._purchaseGateway, this.collectionRepo); + + final SembastDatabase _db; + final _collectionStore = 'collections'; + + final PurchaseGateway _purchaseGateway; + + final CollectionRepository collectionRepo; + + @override + Future purchaseInApp({required String storeId}) => _purchaseGateway.purchase( + identifier: storeId, + ); + + @override + Future> getPurchasesInfo() async { + final info = await _purchaseGateway.purchasesInfo(); + return info.map((purchase) => purchase).toList(); + } + + @override + Future> isAvailable() async { + final products = await _purchaseGateway.getAvailableProducts(); + return products.map((product) => product.storeProduct.identifier).toList(); + } + + @override + Future updatePurchaseCollection({required String id, required bool isPremium}) => _db.put( + id: id, + object: { + CollectionKeys.isPremium: isPremium, + }, + store: _collectionStore, + ); +} diff --git a/lib/data/repositories/collection_repository.dart b/lib/data/repositories/collection_repository.dart index d6b5ee9d..eaf3a7c1 100644 --- a/lib/data/repositories/collection_repository.dart +++ b/lib/data/repositories/collection_repository.dart @@ -90,6 +90,8 @@ class CollectionRepositoryImpl implements CollectionRepository { CollectionKeys.contributors: collection.contributors.map(_contributorSerializer.to), CollectionKeys.uniqueMemosAmount: collection.uniqueMemosAmount, CollectionKeys.uniqueMemoExecutionsAmount: collection.uniqueMemoExecutionsAmount, + CollectionKeys.isPremium: collection.isPremium, + CollectionKeys.appStoreId: collection.appStoreId, }, ) .toList(), diff --git a/lib/data/serializers/collection_memos_serializer.dart b/lib/data/serializers/collection_memos_serializer.dart index 805295b7..814d7dd2 100644 --- a/lib/data/serializers/collection_memos_serializer.dart +++ b/lib/data/serializers/collection_memos_serializer.dart @@ -11,6 +11,9 @@ class CollectionMemosKeys { static const contributors = 'contributors'; static const tags = 'tags'; static const memosMetadata = 'memos'; + static const isPremium = 'isPremium'; + static const appStoreId = 'appStoreId'; + static const playStoreId = 'playStoreId'; } class CollectionMemosSerializer implements Serializer> { @@ -23,6 +26,9 @@ class CollectionMemosSerializer implements Serializer.from(json[CollectionMemosKeys.tags] as List); @@ -40,6 +46,9 @@ class CollectionMemosSerializer implements Serializer> { @@ -41,6 +43,10 @@ class CollectionSerializer implements Serializer>.from(json[CollectionKeys.contributors] as List); final contributors = rawContributors.map(contributorSerializer.from).toList(); + final isPremium = json[CollectionKeys.isPremium] as bool; + + final appStoreId = json[CollectionKeys.appStoreId] as String?; + return Collection( id: id, name: name, @@ -52,6 +58,8 @@ class CollectionSerializer implements Serializer MapEntry(key.raw, value)), CollectionKeys.contributors: collection.contributors.map(contributorSerializer.to), CollectionKeys.timeSpentInMillis: collection.timeSpentInMillis, + CollectionKeys.isPremium: collection.isPremium, + CollectionKeys.appStoreId: collection.appStoreId, }; } diff --git a/lib/domain/models/collection.dart b/lib/domain/models/collection.dart index 2e12a7f1..8d93cb4f 100644 --- a/lib/domain/models/collection.dart +++ b/lib/domain/models/collection.dart @@ -16,6 +16,8 @@ class Collection extends MemoExecutionsMetadata with EquatableMixin implements C required this.tags, required this.uniqueMemosAmount, required this.contributors, + required this.isPremium, + required this.appStoreId, this.uniqueMemoExecutionsAmount = 0, Map executionsAmounts = const {}, int timeSpentInMillis = 0, @@ -47,6 +49,13 @@ class Collection extends MemoExecutionsMetadata with EquatableMixin implements C @override final List contributors; + /// `true` if this [Collection] is a premium. + @override + final bool isPremium; + + @override + final String appStoreId; + @override final int uniqueMemosAmount; @@ -67,6 +76,8 @@ class Collection extends MemoExecutionsMetadata with EquatableMixin implements C category, tags, contributors, + isPremium, + appStoreId, uniqueMemoExecutionsAmount, uniqueMemosAmount, ...super.props, @@ -86,6 +97,12 @@ abstract class CollectionMetadata { /// Contributors (or owners) that have created (or made changes) to this collection. List get contributors; + /// Informs whether the collection is premium or not. + bool get isPremium; + + /// App store id for this collection. + String get appStoreId; + /// Total amount of unique `Memo`s associated with this collection. int get uniqueMemosAmount; diff --git a/lib/domain/services/collection_purchase_services.dart b/lib/domain/services/collection_purchase_services.dart new file mode 100644 index 00000000..b34adb05 --- /dev/null +++ b/lib/domain/services/collection_purchase_services.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:memo/core/env.dart'; +import 'package:memo/data/repositories/collection_purchase_repository.dart'; +import 'package:memo/data/repositories/collection_repository.dart'; +import 'package:memo/domain/models/collection.dart'; + +abstract class CollectionPurchaseServices { + /// Purchases the collection - from [id]. + Future purchaseCollection({required String id}); + + /// Verifies if the collection - from [id] - is visible to the user. + Future isVisible({required String id}); +} + +class CollectionPurchaseServicesImpl implements CollectionPurchaseServices { + CollectionPurchaseServicesImpl({ + required this.env, + required this.collectionPurchaseRepo, + required this.collectionRepo, + }); + + final EnvMetadata env; + + final CollectionPurchaseRepository collectionPurchaseRepo; + + final CollectionRepository collectionRepo; + + @override + Future purchaseCollection({required String id}) async { + final collection = await collectionRepo.getCollection(id: id); + + await _purchaseInAppCollection(collection); + await _updatePurchaseCollection(id: id, isPremium: false); + } + + Future _updatePurchaseCollection({required String id, required bool isPremium}) async { + final collection = await collectionRepo.getCollection(id: id); + final isPurchased = await collectionPurchaseRepo.getPurchasesInfo(); + + if (isPurchased.contains(collection.appStoreId)) { + await collectionPurchaseRepo.updatePurchaseCollection(id: id, isPremium: isPremium); + } + } + + Future _purchaseInAppCollection(Collection collection) async { + switch (env.platform) { + case SupportedPlatform.ios: + await collectionPurchaseRepo.purchaseInApp(storeId: collection.appStoreId); + break; + case SupportedPlatform.android: + break; + } + } + + @override + Future isVisible({required String id}) async { + final collection = await collectionRepo.getCollection(id: id); + + if (collection.isPremium) { + final isAvailable = await collectionPurchaseRepo.isAvailable(); + final isPurchased = await collectionPurchaseRepo.getPurchasesInfo(); + + if (isAvailable.contains(collection.appStoreId)) { + return !isPurchased.contains(collection.appStoreId); + } + } + return collection.isPremium; + } +} diff --git a/lib/domain/services/collection_services.dart b/lib/domain/services/collection_services.dart index 796b4cf1..c9d841f0 100644 --- a/lib/domain/services/collection_services.dart +++ b/lib/domain/services/collection_services.dart @@ -1,9 +1,13 @@ +import 'package:flutter/services.dart'; import 'package:memo/core/faults/errors/inconsistent_state_error.dart'; +import 'package:memo/data/repositories/collection_purchase_repository.dart'; import 'package:memo/data/repositories/collection_repository.dart'; import 'package:memo/data/repositories/memo_repository.dart'; import 'package:memo/domain/isolated_services/memory_recall_services.dart'; import 'package:memo/domain/models/collection.dart'; +import 'package:memo/domain/services/collection_purchase_services.dart'; import 'package:memo/domain/transients/collection_status.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; /// Handles all domain-specific operations associated with [Collection]s. abstract class CollectionServices { @@ -26,23 +30,50 @@ abstract class CollectionServices { } class CollectionServicesImpl implements CollectionServices { - CollectionServicesImpl({required this.collectionRepo, required this.memoRepo, required this.memoryServices}); + CollectionServicesImpl({ + required this.collectionRepo, + required this.memoRepo, + required this.memoryServices, + required this.collectionPurchaseRepo, + required this.collectionPurchaseServices, + }); final CollectionRepository collectionRepo; final MemoRepository memoRepo; final MemoryRecallServices memoryServices; + final CollectionPurchaseRepository collectionPurchaseRepo; + final CollectionPurchaseServices collectionPurchaseServices; + @override Future>> listenToAllCollectionsStatus() async { final collectionsStream = await collectionRepo.listenToAllCollections(); + // Asynchronously transform the stream due to the async calculations. - return collectionsStream.asyncMap( - (collections) { - final mappedStatuses = collections.map(_mapCollectionToCollectionStatus).toList(); - return Future.wait(mappedStatuses); - }, - ); + try { + final isAvailbleList = await collectionPurchaseRepo.isAvailable(); + if (isAvailbleList.isNotEmpty) { + return collectionsStream.asyncMap( + (collections) { + final mappedStatuses = collections.map(_mapCollectionToCollectionStatus).toList(); + return Future.wait(mappedStatuses); + }, + ); + } + } on PlatformException catch (exception) { + final errorCode = PurchasesErrorHelper.getErrorCode(exception); + if (errorCode == PurchasesErrorCode.offlineConnectionError) { + return collectionsStream.asyncMap( + (collections) { + final offlineCollections = collections.where((collection) => !collection.isPremium).toList(); + final mappedStatuses = offlineCollections.map(_mapCollectionToCollectionStatus).toList(); + return Future.wait(mappedStatuses); + }, + ); + } + } + throw InconsistentStateError.service('Missing required collection purchase information'); } @override @@ -65,11 +96,12 @@ class CollectionServicesImpl implements CollectionServices { Future _mapCollectionToCollectionStatus(Collection collection) async { double? memoryRecall; + bool isVisible; if (collection.isCompleted) { memoryRecall = await _getMemosAverageMemoryRecall(collectionId: collection.id); } - - return CollectionStatus(collection, memoryRecall); + isVisible = await collectionPurchaseServices.isVisible(id: collection.id); + return CollectionStatus(collection, memoryRecall, isVisible: isVisible); } @override diff --git a/lib/domain/transients/collection_memos.dart b/lib/domain/transients/collection_memos.dart index d65fa97d..ed06fabe 100644 --- a/lib/domain/transients/collection_memos.dart +++ b/lib/domain/transients/collection_memos.dart @@ -14,6 +14,9 @@ class CollectionMemos extends CollectionMetadata with EquatableMixin { required this.tags, required this.contributors, required this.memosMetadata, + required this.isPremium, + required this.appStoreId, + this.playStoreId, int uniqueMemoExecutionsAmount = 0, }) : _uniqueMemoExecutionsAmount = uniqueMemoExecutionsAmount, assert(memosMetadata.isNotEmpty, 'must not be an empty list of memos'), @@ -40,6 +43,14 @@ class CollectionMemos extends CollectionMetadata with EquatableMixin { @override final List contributors; + @override + final bool isPremium; + + @override + final String appStoreId; + + final String? playStoreId; + @override int get uniqueMemosAmount => memosMetadata.length; @@ -64,5 +75,8 @@ class CollectionMemos extends CollectionMetadata with EquatableMixin { _uniqueMemoExecutionsAmount, uniqueMemosAmount, memosMetadata, + isPremium, + appStoreId, + playStoreId, ]; } diff --git a/lib/domain/transients/collection_status.dart b/lib/domain/transients/collection_status.dart index 1a7f87d0..a930d66e 100644 --- a/lib/domain/transients/collection_status.dart +++ b/lib/domain/transients/collection_status.dart @@ -2,7 +2,7 @@ import 'package:memo/domain/models/collection.dart'; /// Groups a [Collection] with its memory recall. class CollectionStatus { - CollectionStatus(this.collection, this.memoryRecall); + CollectionStatus(this.collection, this.memoryRecall, {required this.isVisible}); final Collection collection; @@ -10,4 +10,6 @@ class CollectionStatus { /// /// Should be `null` if [Collection.isCompleted] is `false`. final double? memoryRecall; + + final bool isVisible; } diff --git a/pubspec.yaml b/pubspec.yaml index b7ad03b1..d2e967b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,6 +51,7 @@ dependencies: # Keep dependency locked, as we need it to be the exact same in `memo-editor` flutter_quill: ^9.3.3 + purchases_flutter: ^6.29.1 dev_dependencies: flutter_test: diff --git a/test/application/widgets/theme/custom_button_test.dart b/test/application/widgets/theme/custom_button_test.dart index eafa7e5b..40c07c40 100644 --- a/test/application/widgets/theme/custom_button_test.dart +++ b/test/application/widgets/theme/custom_button_test.dart @@ -173,7 +173,7 @@ void main() { }); testWidgets('must shrink based on its content', (tester) async { - const expectedWidth = 64.0; + const expectedWidth = 64.4000015258789; const textButton = CustomTextButton(text: 'Text'); await pumpProviderScoped(tester, textButton); diff --git a/test/data/serializers/collection_memos_serializer_test.dart b/test/data/serializers/collection_memos_serializer_test.dart index ec788b29..349be89c 100644 --- a/test/data/serializers/collection_memos_serializer_test.dart +++ b/test/data/serializers/collection_memos_serializer_test.dart @@ -15,13 +15,15 @@ void main() { description: 'This collection represents a collection.', category: 'Category', tags: const ['Tag 1', 'Tag 2'], + isPremium: false, + appStoreId: 'appStoreId', contributors: [const Contributor(name: 'name')], memosMetadata: [ MemoCollectionMetadata( uniqueId: '1', rawQuestion: fakes.question, rawAnswer: fakes.answer, - ) + ), ], ); diff --git a/test/data/serializers/collection_serializer_test.dart b/test/data/serializers/collection_serializer_test.dart index ed7de15f..e9035128 100644 --- a/test/data/serializers/collection_serializer_test.dart +++ b/test/data/serializers/collection_serializer_test.dart @@ -13,6 +13,8 @@ void main() { name: 'My Collection', description: 'This collection represents a collection.', category: 'Category', + isPremium: false, + appStoreId: 'appStoreId', contributors: const [Contributor(name: 'name')], tags: const ['Tag 1', 'Tag 2'], uniqueMemosAmount: 1, @@ -102,6 +104,8 @@ void main() { category: 'Category', contributors: const [Contributor(name: 'name')], tags: const ['Tag 1', 'Tag 2'], + isPremium: false, + appStoreId: 'appStoreId', uniqueMemosAmount: 1, uniqueMemoExecutionsAmount: 1, executionsAmounts: const {MemoDifficulty.easy: 1}, diff --git a/test/domain/models/collection_test.dart b/test/domain/models/collection_test.dart index 095d599b..b23c12b7 100644 --- a/test/domain/models/collection_test.dart +++ b/test/domain/models/collection_test.dart @@ -16,6 +16,8 @@ void main() { description: 'description', category: 'category', tags: const [], + isPremium: false, + appStoreId: 'appStoreId', contributors: contributors ?? const [Contributor(name: 'name')], uniqueMemosAmount: uniqueMemosAmount, executionsAmounts: executionsAmounts, diff --git a/test/domain/transients/collection_memos_test.dart b/test/domain/transients/collection_memos_test.dart index 52e43016..536a46d9 100644 --- a/test/domain/transients/collection_memos_test.dart +++ b/test/domain/transients/collection_memos_test.dart @@ -15,6 +15,8 @@ void main() { description: 'description', category: 'category', tags: const [], + isPremium: false, + appStoreId: 'appStoreId', contributors: contributors ?? const [Contributor(name: 'name')], memosMetadata: memosMetadata ?? [MemoCollectionMetadata(uniqueId: '1', rawAnswer: const [], rawQuestion: const [])], diff --git a/test/fixtures/collection.json b/test/fixtures/collection.json index 13fd5913..8b67431b 100644 --- a/test/fixtures/collection.json +++ b/test/fixtures/collection.json @@ -4,7 +4,12 @@ "description": "This collection represents a collection.", "category": "Category", "contributors": [], - "tags": ["Tag 1", "Tag 2"], + "tags": [ + "Tag 1", + "Tag 2" + ], + "isPremium": false, + "appStoreId": "appStoreId", "uniqueMemosAmount": 1, "uniqueMemoExecutionsAmount": 0, "executionsAmounts": { diff --git a/test/fixtures/collection_memos.json b/test/fixtures/collection_memos.json index 74e1c2ff..91f220b4 100644 --- a/test/fixtures/collection_memos.json +++ b/test/fixtures/collection_memos.json @@ -4,6 +4,12 @@ "description": "This collection represents a collection.", "category": "Category", "contributors": [], - "tags": ["Tag 1", "Tag 2"], + "tags": [ + "Tag 1", + "Tag 2" + ], + "isPremium": false, + "appStoreId": "appStoreId", + "playStoreId": null, "memos": [] } \ No newline at end of file