diff --git a/app/integration_test/app_test.dart b/app/integration_test/app_test.dart index 3d415d6c1..8e4614b4a 100644 --- a/app/integration_test/app_test.dart +++ b/app/integration_test/app_test.dart @@ -8,11 +8,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:patrol/patrol.dart'; +import 'package:platform_check/platform_check.dart'; import 'package:sharezone/keys.dart'; import 'package:sharezone/main/run_app.dart'; import 'package:sharezone/main/sharezone.dart'; import 'package:sharezone/util/flavor.dart'; -import 'package:platform_check/platform_check.dart'; void main() { const config = PatrolTesterConfig( diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 59903e5ab..4f6a97540 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -399,13 +399,13 @@ PODS: - PromisesObjC (2.4.0) - PromisesSwift (2.4.0): - PromisesObjC (= 2.4.0) - - purchases_flutter (6.6.0): + - purchases_flutter (6.27.0): - Flutter - - PurchasesHybridCommon (= 8.2.1) - - PurchasesHybridCommon (8.2.1): - - RevenueCat (= 4.31.6) + - PurchasesHybridCommon (= 10.4.1) + - PurchasesHybridCommon (10.4.1): + - RevenueCat (= 4.40.1) - RecaptchaInterop (100.0.0) - - RevenueCat (4.31.6) + - RevenueCat (4.40.1) - SDWebImage (5.19.0): - SDWebImage/Core (= 5.19.0) - SDWebImage/Core (5.19.0) @@ -694,10 +694,10 @@ SPEC CHECKSUMS: permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - purchases_flutter: 28b20f31e3186f173d2d24d20e7820e788a833a0 - PurchasesHybridCommon: 9adfb3252d99e22142aae9b8456e0069f0cae72d + purchases_flutter: 5a1857a77feec2e518cf0ea84e090ee0a7441c26 + PurchasesHybridCommon: 596ac8a54be4e03cc1398b012bd9cdf134111b70 RecaptchaInterop: 7d1a4a01a6b2cb1610a47ef3f85f0c411434cb21 - RevenueCat: a853fc1d6eb058e8546d91fba9c9c5cd2e041949 + RevenueCat: 9f6b84da3f00953b2eeaaa6fc4710c1d280a5710 SDWebImage: 981fd7e860af070920f249fd092420006014c3eb SDWebImageWebPCoder: c94f09adbca681822edad9e532ac752db713eabf share: 0b2c3e82132f5888bccca3351c504d0003b3b410 diff --git a/app/lib/legal/terms_of_service/terms_of_service_page.dart b/app/lib/legal/terms_of_service/terms_of_service_page.dart index 6ef7255cd..d5861e81e 100644 --- a/app/lib/legal/terms_of_service/terms_of_service_page.dart +++ b/app/lib/legal/terms_of_service/terms_of_service_page.dart @@ -12,7 +12,10 @@ import 'package:sharezone/legal/privacy_policy/privacy_policy_page.dart'; import 'package:sharezone/legal/privacy_policy/src/privacy_policy_src.dart'; class TermsOfServicePage extends StatelessWidget { - static const tag = "terms-of-service-page"; + // When you change the tag, you also need to change the tag in the + // `styled_markdown_text.dart` because the tag is used on the Sharezone Plus + // page. + static const tag = "terms-of-service"; const TermsOfServicePage({super.key}); diff --git a/app/lib/main/sharezone_bloc_providers.dart b/app/lib/main/sharezone_bloc_providers.dart index c665daf43..30cd6a7f6 100644 --- a/app/lib/main/sharezone_bloc_providers.dart +++ b/app/lib/main/sharezone_bloc_providers.dart @@ -106,7 +106,9 @@ import 'package:sharezone/settings/src/subpages/my_profile/change_type_of_user/c import 'package:sharezone/settings/src/subpages/my_profile/change_type_of_user/change_type_of_user_service.dart'; import 'package:sharezone/settings/src/subpages/timetable/bloc/timetable_settings_bloc_factory.dart'; import 'package:sharezone/settings/src/subpages/timetable/time_picker_settings_cache.dart'; +import 'package:sharezone/sharezone_plus/page/sharezone_plus_page_analytics.dart'; import 'package:sharezone/sharezone_plus/page/sharezone_plus_page_controller.dart'; +import 'package:sharezone/sharezone_plus/subscription_service/is_buying_enabled.dart'; import 'package:sharezone/sharezone_plus/subscription_service/revenue_cat_sharezone_plus_service.dart'; import 'package:sharezone/sharezone_plus/subscription_service/subscription_service.dart'; import 'package:sharezone/support/support_page_controller.dart'; @@ -318,7 +320,7 @@ class _SharezoneBlocProvidersState extends State { const clock = Clock(); final subscriptionService = SubscriptionService( user: api.user.userStream, - clock: clock, + functions: widget.blocDependencies.functions, ); final feedbackApi = FirebaseFeedbackApi(firestore); @@ -343,9 +345,12 @@ class _SharezoneBlocProvidersState extends State { ), ChangeNotifierProvider( create: (context) => SharezonePlusPageController( + buyingFlagApi: BuyingEnabledApi(client: http.Client()), userId: UserId(api.uID), purchaseService: RevenueCatPurchaseService(), subscriptionService: subscriptionService, + crashAnalytics: crashAnalytics, + analytics: SharezonePlusPageAnalytics(analytics), stripeCheckoutSession: StripeCheckoutSession( createCheckoutSessionFunctionUrl: widget .blocDependencies.remoteConfiguration diff --git a/app/lib/sharezone_plus/page/sharezone_plus_page.dart b/app/lib/sharezone_plus/page/sharezone_plus_page.dart index 6c12841a8..d73426925 100644 --- a/app/lib/sharezone_plus/page/sharezone_plus_page.dart +++ b/app/lib/sharezone_plus/page/sharezone_plus_page.dart @@ -14,6 +14,7 @@ import 'package:sharezone/navigation/logic/navigation_bloc.dart'; import 'package:sharezone/navigation/models/navigation_item.dart'; import 'package:sharezone/navigation/scaffold/sharezone_main_scaffold.dart'; import 'package:sharezone/sharezone_plus/page/sharezone_plus_page_controller.dart'; +import 'package:sharezone/support/support_page.dart'; import 'package:sharezone/util/launch_link.dart'; import 'package:sharezone_plus_page_ui/sharezone_plus_page_ui.dart'; import 'package:sharezone_widgets/sharezone_widgets.dart'; @@ -63,12 +64,7 @@ class SharezonePlusPageMain extends StatelessWidget { const SizedBox(height: 18), const WhyPlusSharezoneCard(), const SizedBox(height: 18), - SharezonePlusAdvantages( - isHomeworkDoneListsFeatureVisible: - typeOfUser == TypeOfUser.teacher, - isHomeworkReminderFeatureVisible: - typeOfUser == TypeOfUser.student, - ), + _Advantages(typeOfUser: typeOfUser), const SizedBox(height: 18), const _CallToActionSection(), const SizedBox(height: 32), @@ -86,6 +82,30 @@ class SharezonePlusPageMain extends StatelessWidget { } } +class _Advantages extends StatelessWidget { + const _Advantages({ + required this.typeOfUser, + }); + + final TypeOfUser? typeOfUser; + + @override + Widget build(BuildContext context) { + return SharezonePlusAdvantages( + isHomeworkDoneListsFeatureVisible: typeOfUser == TypeOfUser.teacher, + isHomeworkReminderFeatureVisible: typeOfUser == TypeOfUser.student, + onOpenedAdvantage: (advantage) { + final analytics = context.read(); + analytics.logOpenedAdvantage(advantage); + }, + onGitHubOpen: () { + final analytics = context.read(); + analytics.logOpenGitHub(); + }, + ); + } +} + class _CallToActionSection extends StatelessWidget { const _CallToActionSection(); @@ -98,7 +118,7 @@ class _CallToActionSection extends StatelessWidget { // _SubscribeSection which will show loading indicators in turn. child: hasPlus ?? false ? const _UnsubscribeSection() - : const _SubscribeSection(), + : const _PurchaseSection(), ); } } @@ -108,32 +128,40 @@ class _UnsubscribeSection extends StatelessWidget { @override Widget build(BuildContext context) { - final price = context.watch().price; - final priceIsLoading = price == null; - + final hasLifetime = + context.watch().hasLifetime; return Column( key: const ValueKey('unsubscribe-section'), children: [ - priceIsLoading - ? const SharezonePlusPriceLoadingIndicator() - : SharezonePlusPrice(price), - const SizedBox(height: 12), - const _UnsubscribeText(), const SizedBox(height: 12), - const _UnsubscribeButton(), + _UnsubscribeText(hasLifetime: hasLifetime), + if (!hasLifetime) ...const [ + SizedBox(height: 12), + _UnsubscribeButton(), + ] ], ); } } class _UnsubscribeText extends StatelessWidget { - const _UnsubscribeText(); + const _UnsubscribeText({ + required this.hasLifetime, + }); + + final bool hasLifetime; + + String getText(bool hasLifetime) { + if (hasLifetime) { + return 'Du hast Sharezone-Plus auf Lebenszeit. Solltest du nicht zufrieden sein, würden wir uns über ein [Feedback](#feedback) freuen!'; + } + return 'Du hast aktuell das Sharezone-Plus Abo. Solltest du nicht zufrieden sein, würden wir uns über ein [Feedback](#feedback) freuen! Natürlich kannst du dich jederzeit dafür entscheiden, das Abo zu kündigen.'; + } @override Widget build(BuildContext context) { return MarkdownBody( - data: - 'Du hast aktuell das Sharezone-Plus Abo. Solltest du nicht zufrieden sein, würden wir uns über ein [Feedback](#feedback) freuen! Natürlich kannst du dich jederzeit dafür entscheiden, das Abo zu kündigen.', + data: getText(hasLifetime), styleSheet: MarkdownStyleSheet( a: TextStyle( color: Theme.of(context).primaryColor, @@ -164,8 +192,24 @@ class _UnsubscribeButton extends StatelessWidget { const flatRed = Color(0xFFF55F4B); return CallToActionButton( onPressed: () async { + await showDialog( + context: context, + builder: (context) => const _UnsubscribeNoteDialog(), + ); + if (!context.mounted) { + return; + } + final controller = context.read(); - await controller.cancelSubscription(); + try { + await controller.cancelSubscription(); + } on Exception catch (e) { + if (!context.mounted) return; + showDialog( + context: context, + builder: (context) => _UnsubscribeFailure(error: '$e'), + ); + } }, text: const Text('Kündigen'), backgroundColor: flatRed, @@ -173,56 +217,137 @@ class _UnsubscribeButton extends StatelessWidget { } } -class _SubscribeSection extends StatelessWidget { - const _SubscribeSection(); +class _UnsubscribeFailure extends StatelessWidget { + const _UnsubscribeFailure({ + required this.error, + }); + + final String error; @override Widget build(BuildContext context) { - final price = context.watch().price; - final priceIsLoading = price == null; + return MaxWidthConstraintBox( + maxWidth: 400, + child: AlertDialog( + title: const Text('Kündigung fehlgeschlagen'), + content: SingleChildScrollView( + child: Text( + 'Es ist ein Fehler aufgetreten. Bitte versuche es später erneut.\n\nFehler: $error'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + FilledButton( + onPressed: () => Navigator.pushNamed(context, SupportPage.tag), + child: const Text('Support kontaktieren'), + ), + ], + ), + ); + } +} + +class _UnsubscribeNoteDialog extends StatelessWidget { + const _UnsubscribeNoteDialog(); + + @override + Widget build(BuildContext context) { + return MaxWidthConstraintBox( + maxWidth: 400, + child: AlertDialog( + title: const Text('Bist du dir sicher?'), + content: const Text( + 'Wenn du dein Sharezone-Plus Abo kündigst, verlierst du den Zugriff auf alle Plus-Funktionen.\n\nBist du sicher, dass du kündigen möchtest?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Kündigen'), + ), + ], + ), + ); + } +} + +class _PurchaseSection extends StatelessWidget { + const _PurchaseSection(); + @override + Widget build(BuildContext context) { + final controller = context.watch(); + final monthlyPrice = controller.monthlySubscriptionPrice; + final lifetimePrice = controller.lifetimePrice; + final isLoading = monthlyPrice == null || lifetimePrice == null; + final isCanceled = controller.isCancelled; return Column( - key: const ValueKey('subscribe-section'), children: [ - priceIsLoading - ? const SharezonePlusPriceLoadingIndicator() - : SharezonePlusPrice(price), - const SizedBox(height: 12), - _SubscribeButton(loading: priceIsLoading), - const SizedBox(height: 12), - const SharezonePlusLegalText(), + if (isCanceled) ...const [ + _CanceledSubscriptionNote(), + SizedBox(height: 6), + ], + BuySection( + key: const ValueKey('subscribe-section'), + monthlyPrice: monthlyPrice, + lifetimePrice: lifetimePrice, + currentPeriod: controller.selectedPurchasePeriod, + onPeriodChanged: controller.setPeriodOption, + isPriceLoading: isLoading, + isPurchaseButtonLoading: controller.isPurchaseButtonLoading, + onPurchase: () async { + final controller = context.read(); + + try { + final isBuyingEnabled = await controller.isBuyingEnabled(); + + if (!context.mounted) { + return; + } + + if (!isBuyingEnabled) { + showDialog( + context: context, + builder: (context) => const BuyingDisabledDialog(), + ); + return; + } + + await controller.buy(); + } catch (e) { + if (!context.mounted) { + return; + } + showDialog( + context: context, + builder: (context) => BuyingFailedDialog(error: '$e'), + ); + } + }, + ), ], ); } } -class _SubscribeButton extends StatelessWidget { - const _SubscribeButton({this.loading = false}); - - final bool loading; +class _CanceledSubscriptionNote extends StatelessWidget { + const _CanceledSubscriptionNote(); @override Widget build(BuildContext context) { - // When using [GrayShimmer] the text inside the [_CallToActionButton] - // disappears. Using a [Stack] fixes this issue (I don't know why). - return Stack( - alignment: Alignment.center, - children: [ - GrayShimmer( - enabled: loading, - child: CallToActionButton( - text: const Text('Abonnieren'), - onPressed: loading - ? null - : () async { - final controller = - context.read(); - await controller.buySubscription(); - }, - backgroundColor: Theme.of(context).primaryColor, - ), - ) - ], + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + 'Du hast dein Sharezone-Plus Abo gekündigt. Du kannst deine Vorteile noch bis zum Ende des aktuellen Abrechnungszeitraums nutzen. Solltest du es dir anders überlegen, kannst du es jederzeit wieder erneut Sharezone-Plus abonnieren.', + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6)), + ), ); } } diff --git a/app/lib/sharezone_plus/page/sharezone_plus_page_analytics.dart b/app/lib/sharezone_plus/page/sharezone_plus_page_analytics.dart new file mode 100644 index 000000000..f290931f2 --- /dev/null +++ b/app/lib/sharezone_plus/page/sharezone_plus_page_analytics.dart @@ -0,0 +1,57 @@ +// Copyright (c) 2024 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'package:analytics/analytics.dart'; + +class SharezonePlusPageAnalytics { + final Analytics analytics; + + const SharezonePlusPageAnalytics(this.analytics); + + void logOpenedAdvantage(String advantage) { + analytics.log( + NamedAnalyticsEvent( + name: 'sz_plus_page_opened_advantage', + data: { + 'advantage': advantage, + }, + ), + ); + } + + void logOpenedFaq(String question) { + analytics.log( + NamedAnalyticsEvent( + name: 'sz_plus_page_opened_faq', + data: { + 'question': question, + }, + ), + ); + } + + void logSubscribed(String period, String platform) { + analytics.log( + NamedAnalyticsEvent( + name: 'sz_plus_subscribed', + data: { + 'period': period, + 'platform': platform, + }, + ), + ); + } + + void logCancelledSubscription() { + analytics.log(NamedAnalyticsEvent(name: 'sz_plus_cancelled')); + } + + void logOpenGitHub() { + analytics.log(NamedAnalyticsEvent(name: 'sz_plus_page_opened_github')); + } +} diff --git a/app/lib/sharezone_plus/page/sharezone_plus_page_controller.dart b/app/lib/sharezone_plus/page/sharezone_plus_page_controller.dart index c1ff4a77c..1d4b20bb4 100644 --- a/app/lib/sharezone_plus/page/sharezone_plus_page_controller.dart +++ b/app/lib/sharezone_plus/page/sharezone_plus_page_controller.dart @@ -9,54 +9,87 @@ import 'dart:async'; import 'package:common_domain_models/common_domain_models.dart'; +import 'package:crash_analytics/crash_analytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:platform_check/platform_check.dart'; +import 'package:sharezone/sharezone_plus/page/sharezone_plus_page_analytics.dart'; +import 'package:sharezone/sharezone_plus/subscription_service/is_buying_enabled.dart'; +import 'package:sharezone/sharezone_plus/subscription_service/purchase_service.dart'; import 'package:sharezone/sharezone_plus/subscription_service/revenue_cat_sharezone_plus_service.dart'; import 'package:sharezone/sharezone_plus/subscription_service/subscription_service.dart'; -import 'package:platform_check/platform_check.dart'; +import 'package:sharezone_plus_page_ui/sharezone_plus_page_ui.dart'; import 'package:stripe_checkout_session/stripe_checkout_session.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:user/user.dart'; -/// A fallback price if the price cannot be fetched from the backend. -/// -/// On macOS the price is not fetched from the backend because RevenueCat does -/// not support macOS. -const fallbackPlusPrice = '4,99 €'; +const fallbackPlusMonthlyPrice = '2,99 €'; +const fallbackPlusLifetimePrice = '19,99 €'; class SharezonePlusPageController extends ChangeNotifier { - // ignore: unused_field late RevenueCatPurchaseService _purchaseService; - // ignore: unused_field late SubscriptionService _subscriptionService; - + late CrashAnalytics _crashAnalytics; late StripeCheckoutSession _stripeCheckoutSession; late UserId _userId; + late BuyingEnabledApi _buyingFlagApi; + late SharezonePlusPageAnalytics _analytics; StreamSubscription? _hasPlusSubscription; + StreamSubscription? _sharezonePlusStatusSubscription; SharezonePlusPageController({ required RevenueCatPurchaseService purchaseService, required SubscriptionService subscriptionService, required StripeCheckoutSession stripeCheckoutSession, + required CrashAnalytics crashAnalytics, required UserId userId, + required BuyingEnabledApi buyingFlagApi, + required SharezonePlusPageAnalytics analytics, }) { _purchaseService = purchaseService; _subscriptionService = subscriptionService; _stripeCheckoutSession = stripeCheckoutSession; _userId = userId; + monthlySubscriptionPrice = fallbackPlusMonthlyPrice; + lifetimePrice = fallbackPlusLifetimePrice; + _buyingFlagApi = buyingFlagApi; + _crashAnalytics = crashAnalytics; + _analytics = analytics; - // Fake loading for development purposes. - Future.delayed(const Duration(seconds: 1)).then((value) { - hasPlus = false; - price = fallbackPlusPrice; - notifyListeners(); + listenToStatus(); + } + + void listenToStatus() { + final statusStream = _subscriptionService.sharezonePlusStatusStream; + _sharezonePlusStatusSubscription = statusStream.listen((status) { + if (_status != status) { + _status = status; + + // The loading is started when buy process starts and stopped when the + // status is updated. Therefore, the loading is still displayed even if + // our payment provider (like RevenueCat) has already processed the + // payment but the status is not yet updated in the database. + isPurchaseButtonLoading = false; + + notifyListeners(); + } }); } + SharezonePlusStatus? _status; + /// Whether the user has a Sharezone Plus subscription. /// /// If `null` then the status is still loading. - bool? hasPlus; + bool? get hasPlus => _status?.hasPlus; + + /// Whether the user has a Sharezone Plus subscription that is cancelled. + bool get isCancelled => _status?.isCancelled ?? false; + + /// Whether the user has a Sharezone Plus subscription that has a lifetime + /// period. + bool get hasLifetime => _status?.hasLifetime ?? false; /// The price for the Sharezone Plus per month, including the currency sign. /// @@ -67,21 +100,63 @@ class SharezonePlusPageController extends ChangeNotifier { /// for a new subscription. /// /// If `null` then the price is still loading. - String? price; + String? monthlySubscriptionPrice; - Future buySubscription() async { - if (PlatformCheck.isWeb) { - await _buyOnWeb(); + /// The price for the Sharezone Plus lifetime purchase, including the currency + /// sign. + String? lifetimePrice; + + /// Whether the purchase button is currently loading. + /// + /// This is used to show a loading indicator on the purchase button while the + /// purchase is in progress. + bool isPurchaseButtonLoading = false; + + /// The purchase option that the user has selected. + PurchasePeriod selectedPurchasePeriod = PurchasePeriod.monthly; + + Future buy() async { + try { + _analytics.logSubscribed( + selectedPurchasePeriod.name, + PlatformCheck.currentPlatform.name, + ); + + if (PlatformCheck.isWeb) { + await _buyOnWeb(); + } else { + await _purchaseService + .purchase(const ProductId('default-dev-plus-subscription')); + } + } catch (e, s) { + _crashAnalytics.recordError('Error when buying Sharezone Plus: $e', s); + isPurchaseButtonLoading = false; + notifyListeners(); + rethrow; } + } - hasPlus = true; + Future isBuyingEnabled() async { + isPurchaseButtonLoading = true; notifyListeners(); + + final flag = await _buyingFlagApi.isBuyingEnabled(); + + return switch (flag) { + BuyingFlag.enabled => true, + BuyingFlag.disabled => false, + BuyingFlag.unknown => + throw const CouldNotDetermineIsBuyingEnabledException(), + }; } Future _buyOnWeb() async { // The URL is used to redirect the user back to the web app after the // payment is completed or canceled. - final webAppUrl = Uri.base; + final webAppUrl = switch (PlatformCheck.currentPlatform) { + Platform.web => Uri.base, + _ => Uri.parse('https://web.sharezone.net'), + }; final checkoutUrl = await _stripeCheckoutSession.create( userId: '$_userId', @@ -91,6 +166,7 @@ class SharezonePlusPageController extends ChangeNotifier { // Ticket: https://github.com/SharezoneApp/sharezone-app/issues/971 successUrl: webAppUrl, cancelUrl: webAppUrl, + period: selectedPurchasePeriod.name, ); await launchUrl( @@ -105,13 +181,82 @@ class SharezonePlusPageController extends ChangeNotifier { } Future cancelSubscription() async { - hasPlus = false; + try { + _analytics.logCancelledSubscription(); + final source = _subscriptionService.getSource(); + if (source == null) { + throw StateError( + '$SubscriptionSource was null, can not cancel subscription.'); + } + + if (!canCancelSubscription(source)) { + throw CanNotCancelOnThisPlatformException(source); + } + + if (source == SubscriptionSource.stripe) { + await _cancelStripeSubscription(); + } else { + final managementUrl = await _purchaseService.getManagementUrl(); + if (managementUrl != null) { + await launchUrl(Uri.parse(managementUrl)); + } else { + throw const CouldNotGetManagementUrlException(); + } + } + } catch (e, s) { + _crashAnalytics.recordError('Error when canceling Sharezone Plus: $e', s); + rethrow; + } + } + + Future _cancelStripeSubscription() async { + await _subscriptionService.cancelStripeSubscription(); + } + + bool canCancelSubscription(SubscriptionSource source) { + return switch (source) { + SubscriptionSource.appStore => PlatformCheck.isIOS, + SubscriptionSource.playStore => PlatformCheck.isAndroid, + SubscriptionSource.stripe => true, + SubscriptionSource.unknown => false, + }; + } + + void setPeriodOption(PurchasePeriod period) { + selectedPurchasePeriod = period; notifyListeners(); } + void logOpenedAdvantage(String advantage) { + _analytics.logOpenedAdvantage(advantage); + } + + void logOpenedFaq(String question) { + _analytics.logOpenedFaq(question); + } + + void logOpenGitHub() { + _analytics.logOpenGitHub(); + } + @override void dispose() { _hasPlusSubscription?.cancel(); + _sharezonePlusStatusSubscription?.cancel(); super.dispose(); } } + +class CanNotCancelOnThisPlatformException implements Exception { + final SubscriptionSource? source; + + const CanNotCancelOnThisPlatformException(this.source); +} + +class CouldNotGetManagementUrlException implements Exception { + const CouldNotGetManagementUrlException(); +} + +class CouldNotDetermineIsBuyingEnabledException implements Exception { + const CouldNotDetermineIsBuyingEnabledException(); +} diff --git a/app/lib/sharezone_plus/subscription_service/is_buying_enabled.dart b/app/lib/sharezone_plus/subscription_service/is_buying_enabled.dart new file mode 100644 index 000000000..e3ef8cf0c --- /dev/null +++ b/app/lib/sharezone_plus/subscription_service/is_buying_enabled.dart @@ -0,0 +1,55 @@ +// Copyright (c) 2024 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'dart:convert'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:helper_functions/helper_functions.dart'; +import 'package:http/http.dart' as http; +import 'package:retry/retry.dart'; + +class BuyingEnabledApi { + final http.Client _client; + + const BuyingEnabledApi({ + required http.Client client, + }) : _client = client; + + Future isBuyingEnabled() async { + final projectId = Firebase.app().options.projectId; + + return retry(() async { + final response = await _client.get(Uri.parse( + 'https://europe-west1-$projectId.cloudfunctions.net/isBuyingEnabled')); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body); + return BuyingFlag.values.tryByName( + json['status'], + defaultValue: BuyingFlag.unknown, + ); + } + + return BuyingFlag.unknown; + }); + } +} + +enum BuyingFlag { + /// Payment feature are enabled. + enabled, + + /// Payment feature are disabled. + disabled, + + /// The status could not be determined. + /// + /// In this case it's likely that either the client has no internet connection + /// or our backend is down. + unknown, +} diff --git a/app/lib/sharezone_plus/subscription_service/purchase_service.dart b/app/lib/sharezone_plus/subscription_service/purchase_service.dart index e7efcaa47..eff854a26 100644 --- a/app/lib/sharezone_plus/subscription_service/purchase_service.dart +++ b/app/lib/sharezone_plus/subscription_service/purchase_service.dart @@ -7,7 +7,6 @@ // SPDX-License-Identifier: EUPL-1.2 import 'package:common_domain_models/common_domain_models.dart'; -import 'package:purchases_flutter/purchases_flutter.dart'; class ProductId extends Id { const ProductId(super.id); @@ -15,5 +14,5 @@ class ProductId extends Id { abstract class PurchaseService { Future purchase(ProductId id); - Future> getProducts(); + Future getManagementUrl(); } diff --git a/app/lib/sharezone_plus/subscription_service/revenue_cat_sharezone_plus_service.dart b/app/lib/sharezone_plus/subscription_service/revenue_cat_sharezone_plus_service.dart index 571b633e4..437753725 100644 --- a/app/lib/sharezone_plus/subscription_service/revenue_cat_sharezone_plus_service.dart +++ b/app/lib/sharezone_plus/subscription_service/revenue_cat_sharezone_plus_service.dart @@ -18,32 +18,14 @@ class RevenueCatPurchaseService implements PurchaseService { .getOffering('default-dev-plus-subscription')! .availablePackages; final packageToPurchase = availablePackages + // ignore: deprecated_member_use .singleWhere((package) => package.offeringIdentifier == id.toString()); await Purchases.purchasePackage(packageToPurchase); } - Future getPlusSubscriptionProduct() async { - return (await getProducts()).firstOrNull; - } - @override - Future> getProducts() async { - final offerings = await Purchases.getOfferings(); - - final offering = offerings.getOffering('default-dev-plus-subscription'); - if (offering == null) { - return []; - } - - final availablePackages = offering.availablePackages; - final identifiers = availablePackages - .map((package) => package.storeProduct.identifier) - .toList(); - - final products = await Purchases.getProducts( - identifiers, - ); - - return products; + Future getManagementUrl() async { + final customerInfo = await Purchases.getCustomerInfo(); + return customerInfo.managementURL; } } diff --git a/app/lib/sharezone_plus/subscription_service/subscription_service.dart b/app/lib/sharezone_plus/subscription_service/subscription_service.dart index af1b5b3a6..210b068f2 100644 --- a/app/lib/sharezone_plus/subscription_service/subscription_service.dart +++ b/app/lib/sharezone_plus/subscription_service/subscription_service.dart @@ -6,20 +6,25 @@ // // SPDX-License-Identifier: EUPL-1.2 -import 'package:clock/clock.dart'; +import 'dart:async'; + +import 'package:cloud_functions/cloud_functions.dart'; import 'package:user/user.dart'; class SubscriptionService { final Stream user; - final Clock clock; + final FirebaseFunctions functions; + late Stream sharezonePlusStatusStream; + late StreamSubscription _userSubscription; late AppUser? _user; SubscriptionService({ required this.user, - required this.clock, + required this.functions, }) { - user.listen((event) { + sharezonePlusStatusStream = user.map((event) => event?.sharezonePlus); + _userSubscription = user.listen((event) { _user = event; }); } @@ -27,8 +32,9 @@ class SubscriptionService { bool isSubscriptionActive([AppUser? appUser]) { appUser ??= _user; - if (appUser?.subscription == null) return false; - return clock.now().isBefore(appUser!.subscription!.expiresAt); + final plus = appUser?.sharezonePlus; + if (plus == null) return false; + return plus.hasPlus; } Stream isSubscriptionActiveStream() { @@ -41,27 +47,25 @@ class SubscriptionService { if (!_user!.typeOfUser.isStudent) return true; if (!isSubscriptionActive()) return false; - return _user!.subscription!.tier.hasUnlocked(feature); + return true; } Stream hasFeatureUnlockedStream(SharezonePlusFeature feature) { return user.map((event) => hasFeatureUnlocked(feature)); } -} -const _featuresMap = { - SubscriptionTier.teacherPlus: { - SharezonePlusFeature.submissionsList, - SharezonePlusFeature.infoSheetReadByUsersList, - SharezonePlusFeature.homeworkDoneByUsersList, - SharezonePlusFeature.filterTimetableByClass, - SharezonePlusFeature.changeHomeworkReminderTime, - SharezonePlusFeature.plusSupport, - SharezonePlusFeature.moreGroupColors, - SharezonePlusFeature.addEventToLocalCalendar, - SharezonePlusFeature.viewPastEvents, - }, -}; + SubscriptionSource? getSource() { + return _user?.sharezonePlus?.source; + } + + Future cancelStripeSubscription() async { + await functions.httpsCallable('cancelStripeSubscription').call(); + } + + void dispose() { + _userSubscription.cancel(); + } +} enum SharezonePlusFeature { submissionsList, @@ -75,9 +79,3 @@ enum SharezonePlusFeature { viewPastEvents, homeworkDueDateChips, } - -extension SubscriptionTierExtension on SubscriptionTier { - bool hasUnlocked(SharezonePlusFeature feature) { - return _featuresMap[this]?.contains(feature) ?? false; - } -} diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock index a6dffe930..26c447e10 100644 --- a/app/macos/Podfile.lock +++ b/app/macos/Podfile.lock @@ -226,7 +226,7 @@ PODS: - GoogleUtilities/Network (~> 7.11) - "GoogleUtilities/NSData+zlib (~> 7.11)" - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleDataTransport (9.3.0): + - GoogleDataTransport (9.4.0): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) @@ -234,30 +234,39 @@ PODS: - AppAuth (~> 1.5) - GTMAppAuth (< 3.0, >= 1.3) - GTMSessionFetcher/Core (< 4.0, >= 1.1) - - GoogleUtilities/AppDelegateSwizzler (7.12.0): + - GoogleUtilities/AppDelegateSwizzler (7.13.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - - GoogleUtilities/Environment (7.12.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (7.13.0): + - GoogleUtilities/Privacy - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.12.0): + - GoogleUtilities/Logger (7.13.0): - GoogleUtilities/Environment - - GoogleUtilities/MethodSwizzler (7.12.0): + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (7.13.0): - GoogleUtilities/Logger - - GoogleUtilities/Network (7.12.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Network (7.13.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.12.0)" - - GoogleUtilities/Reachability (7.12.0): + - "GoogleUtilities/NSData+zlib (7.13.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (7.13.0) + - GoogleUtilities/Reachability (7.13.0): - GoogleUtilities/Logger - - GoogleUtilities/UserDefaults (7.12.0): + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (7.13.0): - GoogleUtilities/Logger + - GoogleUtilities/Privacy - GTMAppAuth (2.0.0): - AppAuth/Core (~> 1.6) - GTMSessionFetcher/Core (< 4.0, >= 1.5) - GTMSessionFetcher/Core (3.3.1) - - leveldb-library (1.22.3) + - leveldb-library (1.22.4) - mobile_scanner (3.0.0): - FlutterMacOS - nanopb (2.30909.1): @@ -279,12 +288,12 @@ PODS: - PromisesObjC (2.4.0) - PromisesSwift (2.4.0): - PromisesObjC (= 2.4.0) - - purchases_flutter (6.6.0): + - purchases_flutter (6.27.0): - FlutterMacOS - - PurchasesHybridCommon (= 8.2.1) - - PurchasesHybridCommon (8.2.1): - - RevenueCat (= 4.31.6) - - RevenueCat (4.31.6) + - PurchasesHybridCommon (= 10.4.1) + - PurchasesHybridCommon (10.4.1): + - RevenueCat (= 4.40.1) + - RevenueCat (4.40.1) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -475,12 +484,12 @@ SPEC CHECKSUMS: FMDB: aa44149f6fb634b1ac54f64f47064bb0d0c5a032 google_sign_in_ios: ede92ec558c3a73ec07cb180f31820152fb8ca89 GoogleAppMeasurement: bb3c564c3efb933136af0e94899e0a46167466a8 - GoogleDataTransport: 57c22343ab29bc686febbf7cbb13bad167c2d8fe + GoogleDataTransport: bed3a36c04c8552479fbb9b76326e0fc69bddcb2 GoogleSignIn: b232380cf495a429b8095d3178a8d5855b42e842 - GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34 + GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae GTMSessionFetcher: 8a1b34ad97ebe6f909fb8b9b77fba99943007556 - leveldb-library: e74c27d8fbd22854db7cb467968a0b8aa1db7126 + leveldb-library: 06a69cc7582d64b29424a63e085e683cc188230a mobile_scanner: ed7618fb749adc6574563e053f3b8e5002c13994 nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c @@ -489,9 +498,9 @@ SPEC CHECKSUMS: pdfx: d1c406047b02acc54fe98495ae24b04fe9b4f024 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 - purchases_flutter: d960a45de67746a2bdbfd4125cd5c15369bc434d - PurchasesHybridCommon: 9adfb3252d99e22142aae9b8456e0069f0cae72d - RevenueCat: a853fc1d6eb058e8546d91fba9c9c5cd2e041949 + purchases_flutter: ff47324a5a489da6af3ad7e74db46bae20864e2c + PurchasesHybridCommon: 596ac8a54be4e03cc1398b012bd9cdf134111b70 + RevenueCat: 9f6b84da3f00953b2eeaaa6fc4710c1d280a5710 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 diff --git a/app/pubspec.lock b/app/pubspec.lock index cac782981..388faef7e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1790,10 +1790,10 @@ packages: dependency: "direct main" description: name: purchases_flutter - sha256: "0e87add3989b08dc80dbd42c9c4b053a6842a844dfad890224d24ebdf9a9335d" + sha256: "375eee487e4098b99b7d7d360f1c6033e6ed6b8e4b9c1e92c6df7a3dd44846cf" url: "https://pub.dev" source: hosted - version: "6.6.0" + version: "6.27.0" qr: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index a97c88b67..089a0d380 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -150,7 +150,7 @@ dependencies: permission_handler: ^11.1.0 photo_view: ^0.14.0 provider: ^6.0.3 - purchases_flutter: ^6.6.0 + purchases_flutter: ^6.27.0 qr_code_scanner: path: ../lib/qr_code_scanner qr_flutter: ^4.0.0 diff --git a/app/test/pages/settings/notification_page_test.mocks.dart b/app/test/pages/settings/notification_page_test.mocks.dart index 01526413e..632763c8f 100644 --- a/app/test/pages/settings/notification_page_test.mocks.dart +++ b/app/test/pages/settings/notification_page_test.mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i2; -import 'package:clock/clock.dart' as _i4; +import 'package:cloud_functions/cloud_functions.dart' as _i4; import 'package:flutter/material.dart' as _i5; import 'package:mockito/mockito.dart' as _i1; import 'package:sharezone/notifications/notifications_bloc.dart' as _i3; @@ -49,8 +49,9 @@ class _FakeNotificationsBloc_1 extends _i1.SmartFake ); } -class _FakeClock_2 extends _i1.SmartFake implements _i4.Clock { - _FakeClock_2( +class _FakeFirebaseFunctions_2 extends _i1.SmartFake + implements _i4.FirebaseFunctions { + _FakeFirebaseFunctions_2( Object parent, Invocation parentInvocation, ) : super( @@ -204,17 +205,34 @@ class MockSubscriptionService extends _i1.Mock returnValueForMissingStub: _i2.Stream<_i8.AppUser?>.empty(), ) as _i2.Stream<_i8.AppUser?>); @override - _i4.Clock get clock => (super.noSuchMethod( - Invocation.getter(#clock), - returnValue: _FakeClock_2( + _i4.FirebaseFunctions get functions => (super.noSuchMethod( + Invocation.getter(#functions), + returnValue: _FakeFirebaseFunctions_2( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - returnValueForMissingStub: _FakeClock_2( + returnValueForMissingStub: _FakeFirebaseFunctions_2( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - ) as _i4.Clock); + ) as _i4.FirebaseFunctions); + @override + _i2.Stream<_i8.SharezonePlusStatus?> get sharezonePlusStatusStream => + (super.noSuchMethod( + Invocation.getter(#sharezonePlusStatusStream), + returnValue: _i2.Stream<_i8.SharezonePlusStatus?>.empty(), + returnValueForMissingStub: _i2.Stream<_i8.SharezonePlusStatus?>.empty(), + ) as _i2.Stream<_i8.SharezonePlusStatus?>); + @override + set sharezonePlusStatusStream( + _i2.Stream<_i8.SharezonePlusStatus?>? _sharezonePlusStatusStream) => + super.noSuchMethod( + Invocation.setter( + #sharezonePlusStatusStream, + _sharezonePlusStatusStream, + ), + returnValueForMissingStub: null, + ); @override bool isSubscriptionActive([_i8.AppUser? appUser]) => (super.noSuchMethod( Invocation.method( @@ -254,4 +272,21 @@ class MockSubscriptionService extends _i1.Mock returnValue: _i2.Stream.empty(), returnValueForMissingStub: _i2.Stream.empty(), ) as _i2.Stream); + @override + _i2.Future cancelStripeSubscription() => (super.noSuchMethod( + Invocation.method( + #cancelStripeSubscription, + [], + ), + returnValue: _i2.Future.value(), + returnValueForMissingStub: _i2.Future.value(), + ) as _i2.Future); + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); } diff --git a/app/test/settings/notifications/notification_test.mocks.dart b/app/test/settings/notifications/notification_test.mocks.dart index d576eb446..48e864e52 100644 --- a/app/test/settings/notifications/notification_test.mocks.dart +++ b/app/test/settings/notifications/notification_test.mocks.dart @@ -454,6 +454,12 @@ class MockAppUser extends _i1.Mock implements _i3.AppUser { ), ) as _i3.UserTipData); @override + Map get legalData => (super.noSuchMethod( + Invocation.getter(#legalData), + returnValue: {}, + returnValueForMissingStub: {}, + ) as Map); + @override Map toCreateJson() => (super.noSuchMethod( Invocation.method( #toCreateJson, @@ -485,7 +491,7 @@ class MockAppUser extends _i1.Mock implements _i3.AppUser { bool? commentsNotifications, _i3.UserSettings? userSettings, _i3.UserTipData? userTipData, - _i3.Subscription? subscription, + _i3.SharezonePlusStatus? sharezonePlus, _i3.Features? features, }) => (super.noSuchMethod( @@ -505,7 +511,7 @@ class MockAppUser extends _i1.Mock implements _i3.AppUser { #commentsNotifications: commentsNotifications, #userSettings: userSettings, #userTipData: userTipData, - #subscription: subscription, + #sharezonePlus: sharezonePlus, #features: features, }, ), @@ -527,7 +533,7 @@ class MockAppUser extends _i1.Mock implements _i3.AppUser { #commentsNotifications: commentsNotifications, #userSettings: userSettings, #userTipData: userTipData, - #subscription: subscription, + #sharezonePlus: sharezonePlus, #features: features, }, ), @@ -550,7 +556,7 @@ class MockAppUser extends _i1.Mock implements _i3.AppUser { #commentsNotifications: commentsNotifications, #userSettings: userSettings, #userTipData: userTipData, - #subscription: subscription, + #sharezonePlus: sharezonePlus, #features: features, }, ), diff --git a/app/test/sharezone_plus/sharezone_plus_page_test.dart b/app/test/sharezone_plus/sharezone_plus_page_test.dart index 025808678..7854a91f6 100644 --- a/app/test/sharezone_plus/sharezone_plus_page_test.dart +++ b/app/test/sharezone_plus/sharezone_plus_page_test.dart @@ -12,6 +12,8 @@ import 'package:provider/provider.dart'; import 'package:sharezone/sharezone_plus/page/sharezone_plus_page.dart'; import 'package:sharezone/sharezone_plus/page/sharezone_plus_page_controller.dart'; import 'package:sharezone_plus_page_ui/sharezone_plus_page_ui.dart'; +import 'package:sharezone_widgets/sharezone_widgets.dart'; +import 'package:user/user.dart'; class MockSharezonePlusPageController extends ChangeNotifier implements SharezonePlusPageController { @@ -19,11 +21,11 @@ class MockSharezonePlusPageController extends ChangeNotifier bool? hasPlus; @override - String? price; + String? monthlySubscriptionPrice; bool buySubscriptionCalled = false; @override - Future buySubscription() async { + Future buy() async { buySubscriptionCalled = true; } @@ -32,6 +34,52 @@ class MockSharezonePlusPageController extends ChangeNotifier Future cancelSubscription() async { cancelSubscriptionCalled = true; } + + @override + bool canCancelSubscription(SubscriptionSource source) { + return true; + } + + @override + Future isBuyingEnabled() async { + return true; + } + + @override + bool isPurchaseButtonLoading = false; + + @override + String? lifetimePrice; + + @override + void setPeriodOption(PurchasePeriod period) {} + + @override + PurchasePeriod selectedPurchasePeriod = PurchasePeriod.monthly; + + bool _isCancelled = false; + @override + bool get isCancelled => _isCancelled; + + void setIsCancelled(bool isCancelled) { + _isCancelled = isCancelled; + notifyListeners(); + } + + @override + void listenToStatus() {} + + @override + bool get hasLifetime => false; + + @override + void logOpenGitHub() {} + + @override + void logOpenedAdvantage(String advantage) {} + + @override + void logOpenedFaq(String question) {} } void main() { @@ -52,34 +100,23 @@ void main() { ); } - testWidgets( - 'shows $SharezonePlusPriceLoadingIndicator if plus status has loaded but the price hasnt', - (tester) async { - controller.hasPlus = true; - controller.price = null; - - await pumpPlusPage(tester); - await tester.ensureVisible(find.byType(CallToActionButton)); - - expect(find.byType(SharezonePlusPriceLoadingIndicator), findsOneWidget); - }); - - testWidgets( - 'if loading then the $SharezonePlusPriceLoadingIndicator is shown', - (tester) async { + testWidgets('if loading then $GrayShimmer is shown', (tester) async { controller.hasPlus = null; - controller.price = null; + controller.monthlySubscriptionPrice = null; + controller.lifetimePrice = null; await pumpPlusPage(tester); await tester.ensureVisible(find.byType(CallToActionButton)); - expect(find.byType(SharezonePlusPriceLoadingIndicator), findsOneWidget); + expect(find.byType(GrayShimmer), findsWidgets); }); testWidgets('if loading then the "subscribe" button is disabled', (tester) async { controller.hasPlus = null; - controller.price = null; + controller.monthlySubscriptionPrice = null; + controller.lifetimePrice = null; + controller.isPurchaseButtonLoading = true; await pumpPlusPage(tester); await tester.ensureVisible(find.byType(CallToActionButton)); @@ -89,19 +126,22 @@ void main() { expect(controller.cancelSubscriptionCalled, false); expect( tester - .widget(find.byType(CallToActionButton)) - .onPressed, - null); + .widget(find.byType(BuySection)) + .isPurchaseButtonLoading, + true); }); testWidgets('calls cancelSubscription() when "cancel" is pressed', (tester) async { controller.hasPlus = true; - controller.price = '4,99 €'; + controller.monthlySubscriptionPrice = '4,99 €'; + controller.lifetimePrice = '19,99 €'; await pumpPlusPage(tester); await tester.ensureVisible(find.byType(CallToActionButton)); await tester.tap(find.widgetWithText(CallToActionButton, 'Kündigen')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Kündigen')); expect(controller.cancelSubscriptionCalled, true); }); @@ -109,7 +149,8 @@ void main() { testWidgets('calls buySubscription() when "subscribe" is pressed', (tester) async { controller.hasPlus = false; - controller.price = '4,99 €'; + controller.monthlySubscriptionPrice = '4,99 €'; + controller.lifetimePrice = '19,99 €'; await pumpPlusPage(tester); await tester.ensureVisible(find.byType(CallToActionButton)); @@ -120,35 +161,18 @@ void main() { testWidgets('shows price to pay if not subscribed', (tester) async { controller.hasPlus = false; - controller.price = '4,99 €'; + controller.monthlySubscriptionPrice = '4,99 €'; + controller.lifetimePrice = '19,99 €'; await pumpPlusPage(tester); expect(find.text('4,99 €'), findsOneWidget); }); - testWidgets('shows currently paid price if subscribed', (tester) async { - controller.hasPlus = true; - controller.price = '4,99 €'; - - await pumpPlusPage(tester); - - expect(find.text('4,99 €'), findsOneWidget); - }); - - testWidgets('shows "subscribe" button if not subscribed', (tester) async { - controller.hasPlus = false; - controller.price = '4,99 €'; - - await pumpPlusPage(tester); - - expect(find.widgetWithText(CallToActionButton, 'Abonnieren'), - findsOneWidget); - }); - testWidgets('shows "cancel" button if subscribed', (tester) async { controller.hasPlus = true; - controller.price = '4,99 €'; + controller.monthlySubscriptionPrice = '4,99 €'; + controller.lifetimePrice = '19,99 €'; await pumpPlusPage(tester); diff --git a/app/test/timetable/timetable_page_test.mocks.dart b/app/test/timetable/timetable_page_test.mocks.dart index a4e6e9263..525df3e92 100644 --- a/app/test/timetable/timetable_page_test.mocks.dart +++ b/app/test/timetable/timetable_page_test.mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i4; -import 'package:clock/clock.dart' as _i2; +import 'package:cloud_functions/cloud_functions.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:sharezone/sharezone_plus/subscription_service/subscription_service.dart' as _i3; @@ -24,8 +24,9 @@ import 'package:user/user.dart' as _i5; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeClock_0 extends _i1.SmartFake implements _i2.Clock { - _FakeClock_0( +class _FakeFirebaseFunctions_0 extends _i1.SmartFake + implements _i2.FirebaseFunctions { + _FakeFirebaseFunctions_0( Object parent, Invocation parentInvocation, ) : super( @@ -46,17 +47,34 @@ class MockSubscriptionService extends _i1.Mock returnValueForMissingStub: _i4.Stream<_i5.AppUser?>.empty(), ) as _i4.Stream<_i5.AppUser?>); @override - _i2.Clock get clock => (super.noSuchMethod( - Invocation.getter(#clock), - returnValue: _FakeClock_0( + _i2.FirebaseFunctions get functions => (super.noSuchMethod( + Invocation.getter(#functions), + returnValue: _FakeFirebaseFunctions_0( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - returnValueForMissingStub: _FakeClock_0( + returnValueForMissingStub: _FakeFirebaseFunctions_0( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - ) as _i2.Clock); + ) as _i2.FirebaseFunctions); + @override + _i4.Stream<_i5.SharezonePlusStatus?> get sharezonePlusStatusStream => + (super.noSuchMethod( + Invocation.getter(#sharezonePlusStatusStream), + returnValue: _i4.Stream<_i5.SharezonePlusStatus?>.empty(), + returnValueForMissingStub: _i4.Stream<_i5.SharezonePlusStatus?>.empty(), + ) as _i4.Stream<_i5.SharezonePlusStatus?>); + @override + set sharezonePlusStatusStream( + _i4.Stream<_i5.SharezonePlusStatus?>? _sharezonePlusStatusStream) => + super.noSuchMethod( + Invocation.setter( + #sharezonePlusStatusStream, + _sharezonePlusStatusStream, + ), + returnValueForMissingStub: null, + ); @override bool isSubscriptionActive([_i5.AppUser? appUser]) => (super.noSuchMethod( Invocation.method( @@ -96,4 +114,21 @@ class MockSubscriptionService extends _i1.Mock returnValue: _i4.Stream.empty(), returnValueForMissingStub: _i4.Stream.empty(), ) as _i4.Stream); + @override + _i4.Future cancelStripeSubscription() => (super.noSuchMethod( + Invocation.method( + #cancelStripeSubscription, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); } diff --git a/app/test_goldens/blackboard/details/blackboard_item_read_by_users_list_page_test.mocks.dart b/app/test_goldens/blackboard/details/blackboard_item_read_by_users_list_page_test.mocks.dart index f3dc82256..2f87867e9 100644 --- a/app/test_goldens/blackboard/details/blackboard_item_read_by_users_list_page_test.mocks.dart +++ b/app/test_goldens/blackboard/details/blackboard_item_read_by_users_list_page_test.mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i4; -import 'package:clock/clock.dart' as _i2; +import 'package:cloud_functions/cloud_functions.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:sharezone/blackboard/details/blackboard_item_read_by_users_list/blackboard_item_read_by_users_list_bloc.dart' as _i3; @@ -28,8 +28,9 @@ import 'package:user/user.dart' as _i7; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeClock_0 extends _i1.SmartFake implements _i2.Clock { - _FakeClock_0( +class _FakeFirebaseFunctions_0 extends _i1.SmartFake + implements _i2.FirebaseFunctions { + _FakeFirebaseFunctions_0( Object parent, Invocation parentInvocation, ) : super( @@ -71,17 +72,34 @@ class MockSubscriptionService extends _i1.Mock returnValueForMissingStub: _i4.Stream<_i7.AppUser?>.empty(), ) as _i4.Stream<_i7.AppUser?>); @override - _i2.Clock get clock => (super.noSuchMethod( - Invocation.getter(#clock), - returnValue: _FakeClock_0( + _i2.FirebaseFunctions get functions => (super.noSuchMethod( + Invocation.getter(#functions), + returnValue: _FakeFirebaseFunctions_0( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - returnValueForMissingStub: _FakeClock_0( + returnValueForMissingStub: _FakeFirebaseFunctions_0( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - ) as _i2.Clock); + ) as _i2.FirebaseFunctions); + @override + _i4.Stream<_i7.SharezonePlusStatus?> get sharezonePlusStatusStream => + (super.noSuchMethod( + Invocation.getter(#sharezonePlusStatusStream), + returnValue: _i4.Stream<_i7.SharezonePlusStatus?>.empty(), + returnValueForMissingStub: _i4.Stream<_i7.SharezonePlusStatus?>.empty(), + ) as _i4.Stream<_i7.SharezonePlusStatus?>); + @override + set sharezonePlusStatusStream( + _i4.Stream<_i7.SharezonePlusStatus?>? _sharezonePlusStatusStream) => + super.noSuchMethod( + Invocation.setter( + #sharezonePlusStatusStream, + _sharezonePlusStatusStream, + ), + returnValueForMissingStub: null, + ); @override bool isSubscriptionActive([_i7.AppUser? appUser]) => (super.noSuchMethod( Invocation.method( @@ -121,4 +139,21 @@ class MockSubscriptionService extends _i1.Mock returnValue: _i4.Stream.empty(), returnValueForMissingStub: _i4.Stream.empty(), ) as _i4.Stream); + @override + _i4.Future cancelStripeSubscription() => (super.noSuchMethod( + Invocation.method( + #cancelStripeSubscription, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); } diff --git a/app/test_goldens/groups/src/pages/course/course_edit/design/src/dialog/select_design_dialog_test.mocks.dart b/app/test_goldens/groups/src/pages/course/course_edit/design/src/dialog/select_design_dialog_test.mocks.dart index 1bb41ffaa..939e55fd0 100644 --- a/app/test_goldens/groups/src/pages/course/course_edit/design/src/dialog/select_design_dialog_test.mocks.dart +++ b/app/test_goldens/groups/src/pages/course/course_edit/design/src/dialog/select_design_dialog_test.mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i4; -import 'package:clock/clock.dart' as _i2; +import 'package:cloud_functions/cloud_functions.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:sharezone/sharezone_plus/subscription_service/subscription_service.dart' as _i3; @@ -24,8 +24,9 @@ import 'package:user/user.dart' as _i5; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeClock_0 extends _i1.SmartFake implements _i2.Clock { - _FakeClock_0( +class _FakeFirebaseFunctions_0 extends _i1.SmartFake + implements _i2.FirebaseFunctions { + _FakeFirebaseFunctions_0( Object parent, Invocation parentInvocation, ) : super( @@ -46,17 +47,34 @@ class MockSubscriptionService extends _i1.Mock returnValueForMissingStub: _i4.Stream<_i5.AppUser?>.empty(), ) as _i4.Stream<_i5.AppUser?>); @override - _i2.Clock get clock => (super.noSuchMethod( - Invocation.getter(#clock), - returnValue: _FakeClock_0( + _i2.FirebaseFunctions get functions => (super.noSuchMethod( + Invocation.getter(#functions), + returnValue: _FakeFirebaseFunctions_0( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - returnValueForMissingStub: _FakeClock_0( + returnValueForMissingStub: _FakeFirebaseFunctions_0( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - ) as _i2.Clock); + ) as _i2.FirebaseFunctions); + @override + _i4.Stream<_i5.SharezonePlusStatus?> get sharezonePlusStatusStream => + (super.noSuchMethod( + Invocation.getter(#sharezonePlusStatusStream), + returnValue: _i4.Stream<_i5.SharezonePlusStatus?>.empty(), + returnValueForMissingStub: _i4.Stream<_i5.SharezonePlusStatus?>.empty(), + ) as _i4.Stream<_i5.SharezonePlusStatus?>); + @override + set sharezonePlusStatusStream( + _i4.Stream<_i5.SharezonePlusStatus?>? _sharezonePlusStatusStream) => + super.noSuchMethod( + Invocation.setter( + #sharezonePlusStatusStream, + _sharezonePlusStatusStream, + ), + returnValueForMissingStub: null, + ); @override bool isSubscriptionActive([_i5.AppUser? appUser]) => (super.noSuchMethod( Invocation.method( @@ -96,4 +114,21 @@ class MockSubscriptionService extends _i1.Mock returnValue: _i4.Stream.empty(), returnValueForMissingStub: _i4.Stream.empty(), ) as _i4.Stream); + @override + _i4.Future cancelStripeSubscription() => (super.noSuchMethod( + Invocation.method( + #cancelStripeSubscription, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); } diff --git a/app/test_goldens/homework/teacher/homework_done_by_users_list/homework_completion_user_list_page_test.mocks.dart b/app/test_goldens/homework/teacher/homework_done_by_users_list/homework_completion_user_list_page_test.mocks.dart index 94c41cbaa..5fb93231f 100644 --- a/app/test_goldens/homework/teacher/homework_done_by_users_list/homework_completion_user_list_page_test.mocks.dart +++ b/app/test_goldens/homework/teacher/homework_done_by_users_list/homework_completion_user_list_page_test.mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i6; -import 'package:clock/clock.dart' as _i3; +import 'package:cloud_functions/cloud_functions.dart' as _i3; import 'package:common_domain_models/common_domain_models.dart' as _i5; import 'package:mockito/mockito.dart' as _i1; import 'package:sharezone/homework/teacher/homework_done_by_users_list/homework_completion_user_list_bloc.dart' @@ -42,8 +42,9 @@ class _FakeHomeworkCompletionUserListBloc_0 extends _i1.SmartFake ); } -class _FakeClock_1 extends _i1.SmartFake implements _i3.Clock { - _FakeClock_1( +class _FakeFirebaseFunctions_1 extends _i1.SmartFake + implements _i3.FirebaseFunctions { + _FakeFirebaseFunctions_1( Object parent, Invocation parentInvocation, ) : super( @@ -132,17 +133,34 @@ class MockSubscriptionService extends _i1.Mock returnValueForMissingStub: _i6.Stream<_i9.AppUser?>.empty(), ) as _i6.Stream<_i9.AppUser?>); @override - _i3.Clock get clock => (super.noSuchMethod( - Invocation.getter(#clock), - returnValue: _FakeClock_1( + _i3.FirebaseFunctions get functions => (super.noSuchMethod( + Invocation.getter(#functions), + returnValue: _FakeFirebaseFunctions_1( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - returnValueForMissingStub: _FakeClock_1( + returnValueForMissingStub: _FakeFirebaseFunctions_1( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - ) as _i3.Clock); + ) as _i3.FirebaseFunctions); + @override + _i6.Stream<_i9.SharezonePlusStatus?> get sharezonePlusStatusStream => + (super.noSuchMethod( + Invocation.getter(#sharezonePlusStatusStream), + returnValue: _i6.Stream<_i9.SharezonePlusStatus?>.empty(), + returnValueForMissingStub: _i6.Stream<_i9.SharezonePlusStatus?>.empty(), + ) as _i6.Stream<_i9.SharezonePlusStatus?>); + @override + set sharezonePlusStatusStream( + _i6.Stream<_i9.SharezonePlusStatus?>? _sharezonePlusStatusStream) => + super.noSuchMethod( + Invocation.setter( + #sharezonePlusStatusStream, + _sharezonePlusStatusStream, + ), + returnValueForMissingStub: null, + ); @override bool isSubscriptionActive([_i9.AppUser? appUser]) => (super.noSuchMethod( Invocation.method( @@ -182,4 +200,21 @@ class MockSubscriptionService extends _i1.Mock returnValue: _i6.Stream.empty(), returnValueForMissingStub: _i6.Stream.empty(), ) as _i6.Stream); + @override + _i6.Future cancelStripeSubscription() => (super.noSuchMethod( + Invocation.method( + #cancelStripeSubscription, + [], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); } diff --git a/app/test_goldens/settings/notification_page_test.mocks.dart b/app/test_goldens/settings/notification_page_test.mocks.dart index 64ce388cd..8ab478076 100644 --- a/app/test_goldens/settings/notification_page_test.mocks.dart +++ b/app/test_goldens/settings/notification_page_test.mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i2; -import 'package:clock/clock.dart' as _i4; +import 'package:cloud_functions/cloud_functions.dart' as _i4; import 'package:flutter/material.dart' as _i5; import 'package:mockito/mockito.dart' as _i1; import 'package:sharezone/notifications/notifications_bloc.dart' as _i3; @@ -49,8 +49,9 @@ class _FakeNotificationsBloc_1 extends _i1.SmartFake ); } -class _FakeClock_2 extends _i1.SmartFake implements _i4.Clock { - _FakeClock_2( +class _FakeFirebaseFunctions_2 extends _i1.SmartFake + implements _i4.FirebaseFunctions { + _FakeFirebaseFunctions_2( Object parent, Invocation parentInvocation, ) : super( @@ -204,17 +205,34 @@ class MockSubscriptionService extends _i1.Mock returnValueForMissingStub: _i2.Stream<_i8.AppUser?>.empty(), ) as _i2.Stream<_i8.AppUser?>); @override - _i4.Clock get clock => (super.noSuchMethod( - Invocation.getter(#clock), - returnValue: _FakeClock_2( + _i4.FirebaseFunctions get functions => (super.noSuchMethod( + Invocation.getter(#functions), + returnValue: _FakeFirebaseFunctions_2( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - returnValueForMissingStub: _FakeClock_2( + returnValueForMissingStub: _FakeFirebaseFunctions_2( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - ) as _i4.Clock); + ) as _i4.FirebaseFunctions); + @override + _i2.Stream<_i8.SharezonePlusStatus?> get sharezonePlusStatusStream => + (super.noSuchMethod( + Invocation.getter(#sharezonePlusStatusStream), + returnValue: _i2.Stream<_i8.SharezonePlusStatus?>.empty(), + returnValueForMissingStub: _i2.Stream<_i8.SharezonePlusStatus?>.empty(), + ) as _i2.Stream<_i8.SharezonePlusStatus?>); + @override + set sharezonePlusStatusStream( + _i2.Stream<_i8.SharezonePlusStatus?>? _sharezonePlusStatusStream) => + super.noSuchMethod( + Invocation.setter( + #sharezonePlusStatusStream, + _sharezonePlusStatusStream, + ), + returnValueForMissingStub: null, + ); @override bool isSubscriptionActive([_i8.AppUser? appUser]) => (super.noSuchMethod( Invocation.method( @@ -254,4 +272,21 @@ class MockSubscriptionService extends _i1.Mock returnValue: _i2.Stream.empty(), returnValueForMissingStub: _i2.Stream.empty(), ) as _i2.Stream); + @override + _i2.Future cancelStripeSubscription() => (super.noSuchMethod( + Invocation.method( + #cancelStripeSubscription, + [], + ), + returnValue: _i2.Future.value(), + returnValueForMissingStub: _i2.Future.value(), + ) as _i2.Future); + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); } diff --git a/app/test_goldens/sharezone_plus/goldens/sharezone_plus_unsubscribe_section.tablet_portrait.png b/app/test_goldens/sharezone_plus/goldens/sharezone_plus_unsubscribe_section.tablet_portrait.png index 2befe9c16..f91b36bad 100644 Binary files a/app/test_goldens/sharezone_plus/goldens/sharezone_plus_unsubscribe_section.tablet_portrait.png and b/app/test_goldens/sharezone_plus/goldens/sharezone_plus_unsubscribe_section.tablet_portrait.png differ diff --git a/app/test_goldens/sharezone_plus/sharezone_plus_page_test.dart b/app/test_goldens/sharezone_plus/sharezone_plus_page_test.dart index 2ef5b1e11..c7a7dec3a 100644 --- a/app/test_goldens/sharezone_plus/sharezone_plus_page_test.dart +++ b/app/test_goldens/sharezone_plus/sharezone_plus_page_test.dart @@ -78,7 +78,10 @@ void main() { when(hasUnreadFeedbackMessagesProvider.hasUnreadFeedbackMessages) .thenAnswer((_) => false); - when(controller.price).thenAnswer((_) => fallbackPlusPrice); + when(controller.monthlySubscriptionPrice) + .thenAnswer((_) => fallbackPlusMonthlyPrice); + when(controller.lifetimePrice) + .thenAnswer((_) => fallbackPlusLifetimePrice); }); Future pumpPlusPage( diff --git a/app/test_goldens/sharezone_plus/sharezone_plus_page_test.mocks.dart b/app/test_goldens/sharezone_plus/sharezone_plus_page_test.mocks.dart index 3af18f1c0..13a42b4e0 100644 --- a/app/test_goldens/sharezone_plus/sharezone_plus_page_test.mocks.dart +++ b/app/test_goldens/sharezone_plus/sharezone_plus_page_test.mocks.dart @@ -3,34 +3,34 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i23; -import 'dart:ui' as _i24; +import 'dart:async' as _i24; +import 'dart:ui' as _i25; import 'package:analytics/analytics.dart' as _i5; import 'package:app_functions/app_functions.dart' as _i20; -import 'package:authentification_base/authentification.dart' as _i32; -import 'package:cloud_firestore/cloud_firestore.dart' as _i33; +import 'package:authentification_base/authentification.dart' as _i33; +import 'package:cloud_firestore/cloud_firestore.dart' as _i34; import 'package:common_domain_models/common_domain_models.dart' as _i9; import 'package:feedback_shared_implementation/feedback_shared_implementation.dart' as _i21; -import 'package:firebase_auth/firebase_auth.dart' as _i34; +import 'package:firebase_auth/firebase_auth.dart' as _i35; import 'package:flutter/material.dart' as _i1; import 'package:mockito/mockito.dart' as _i2; -import 'package:mockito/src/dummies.dart' as _i31; +import 'package:mockito/src/dummies.dart' as _i32; import 'package:rxdart/rxdart.dart' as _i3; import 'package:shared_preferences/shared_preferences.dart' as _i7; import 'package:sharezone/feedback/unread_messages/has_unread_feedback_messages_provider.dart' - as _i35; + as _i36; import 'package:sharezone/filesharing/file_sharing_api.dart' as _i12; -import 'package:sharezone/main/application_bloc.dart' as _i30; +import 'package:sharezone/main/application_bloc.dart' as _i31; import 'package:sharezone/navigation/analytics/navigation_analytics.dart' - as _i29; -import 'package:sharezone/navigation/logic/navigation_bloc.dart' as _i25; -import 'package:sharezone/navigation/models/navigation_item.dart' as _i26; + as _i30; +import 'package:sharezone/navigation/logic/navigation_bloc.dart' as _i26; +import 'package:sharezone/navigation/models/navigation_item.dart' as _i27; import 'package:sharezone/navigation/scaffold/portable/bottom_navigation_bar/navigation_experiment/navigation_experiment_cache.dart' - as _i27; -import 'package:sharezone/navigation/scaffold/portable/bottom_navigation_bar/navigation_experiment/navigation_experiment_option.dart' as _i28; +import 'package:sharezone/navigation/scaffold/portable/bottom_navigation_bar/navigation_experiment/navigation_experiment_option.dart' + as _i29; import 'package:sharezone/sharezone_plus/page/sharezone_plus_page_controller.dart' as _i22; import 'package:sharezone/util/api.dart' as _i4; @@ -43,6 +43,7 @@ import 'package:sharezone/util/api/timetable_gateway.dart' as _i18; import 'package:sharezone/util/api/user_api.dart' as _i13; import 'package:sharezone/util/navigation_service.dart' as _i8; import 'package:sharezone_common/references.dart' as _i14; +import 'package:sharezone_plus_page_ui/sharezone_plus_page_ui.dart' as _i23; import 'package:streaming_shared_preferences/streaming_shared_preferences.dart' as _i6; import 'package:user/user.dart' as _i19; @@ -280,45 +281,147 @@ class _FakeFeedbackApi_19 extends _i2.SmartFake implements _i21.FeedbackApi { class MockSharezonePlusPageController extends _i2.Mock implements _i22.SharezonePlusPageController { @override - set hasPlus(bool? _hasPlus) => super.noSuchMethod( + set monthlySubscriptionPrice(String? _monthlySubscriptionPrice) => + super.noSuchMethod( + Invocation.setter( + #monthlySubscriptionPrice, + _monthlySubscriptionPrice, + ), + returnValueForMissingStub: null, + ); + @override + set lifetimePrice(String? _lifetimePrice) => super.noSuchMethod( + Invocation.setter( + #lifetimePrice, + _lifetimePrice, + ), + returnValueForMissingStub: null, + ); + @override + bool get isPurchaseButtonLoading => (super.noSuchMethod( + Invocation.getter(#isPurchaseButtonLoading), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + @override + set isPurchaseButtonLoading(bool? _isPurchaseButtonLoading) => + super.noSuchMethod( Invocation.setter( - #hasPlus, - _hasPlus, + #isPurchaseButtonLoading, + _isPurchaseButtonLoading, ), returnValueForMissingStub: null, ); @override - set price(String? _price) => super.noSuchMethod( + _i23.PurchasePeriod get selectedPurchasePeriod => (super.noSuchMethod( + Invocation.getter(#selectedPurchasePeriod), + returnValue: _i23.PurchasePeriod.monthly, + returnValueForMissingStub: _i23.PurchasePeriod.monthly, + ) as _i23.PurchasePeriod); + @override + set selectedPurchasePeriod(_i23.PurchasePeriod? _selectedPurchasePeriod) => + super.noSuchMethod( Invocation.setter( - #price, - _price, + #selectedPurchasePeriod, + _selectedPurchasePeriod, ), returnValueForMissingStub: null, ); @override + bool get isCancelled => (super.noSuchMethod( + Invocation.getter(#isCancelled), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + @override + bool get hasLifetime => (super.noSuchMethod( + Invocation.getter(#hasLifetime), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + @override bool get hasListeners => (super.noSuchMethod( Invocation.getter(#hasListeners), returnValue: false, returnValueForMissingStub: false, ) as bool); @override - _i23.Future buySubscription() => (super.noSuchMethod( + void listenToStatus() => super.noSuchMethod( + Invocation.method( + #listenToStatus, + [], + ), + returnValueForMissingStub: null, + ); + @override + _i24.Future buy() => (super.noSuchMethod( Invocation.method( - #buySubscription, + #buy, [], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override - _i23.Future cancelSubscription() => (super.noSuchMethod( + _i24.Future isBuyingEnabled() => (super.noSuchMethod( + Invocation.method( + #isBuyingEnabled, + [], + ), + returnValue: _i24.Future.value(false), + returnValueForMissingStub: _i24.Future.value(false), + ) as _i24.Future); + @override + _i24.Future cancelSubscription() => (super.noSuchMethod( Invocation.method( #cancelSubscription, [], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); + @override + bool canCancelSubscription(_i19.SubscriptionSource? source) => + (super.noSuchMethod( + Invocation.method( + #canCancelSubscription, + [source], + ), + returnValue: false, + returnValueForMissingStub: false, + ) as bool); + @override + void setPeriodOption(_i23.PurchasePeriod? period) => super.noSuchMethod( + Invocation.method( + #setPeriodOption, + [period], + ), + returnValueForMissingStub: null, + ); + @override + void logOpenedAdvantage(String? advantage) => super.noSuchMethod( + Invocation.method( + #logOpenedAdvantage, + [advantage], + ), + returnValueForMissingStub: null, + ); + @override + void logOpenedFaq(String? question) => super.noSuchMethod( + Invocation.method( + #logOpenedFaq, + [question], + ), + returnValueForMissingStub: null, + ); + @override + void logOpenGitHub() => super.noSuchMethod( + Invocation.method( + #logOpenGitHub, + [], + ), + returnValueForMissingStub: null, + ); @override void dispose() => super.noSuchMethod( Invocation.method( @@ -328,7 +431,7 @@ class MockSharezonePlusPageController extends _i2.Mock returnValueForMissingStub: null, ); @override - void addListener(_i24.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i25.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #addListener, [listener], @@ -336,7 +439,7 @@ class MockSharezonePlusPageController extends _i2.Mock returnValueForMissingStub: null, ); @override - void removeListener(_i24.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i25.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #removeListener, [listener], @@ -356,7 +459,7 @@ class MockSharezonePlusPageController extends _i2.Mock /// A class which mocks [NavigationBloc]. /// /// See the documentation for Mockito's code generation for more information. -class MockNavigationBloc extends _i2.Mock implements _i25.NavigationBloc { +class MockNavigationBloc extends _i2.Mock implements _i26.NavigationBloc { @override _i1.GlobalKey<_i1.State<_i1.StatefulWidget>> get scaffoldKey => (super.noSuchMethod( @@ -400,23 +503,23 @@ class MockNavigationBloc extends _i2.Mock implements _i25.NavigationBloc { ), ) as _i1.GlobalKey<_i1.State<_i1.StatefulWidget>>); @override - _i23.Stream<_i26.NavigationItem> get currentItemStream => (super.noSuchMethod( + _i24.Stream<_i27.NavigationItem> get currentItemStream => (super.noSuchMethod( Invocation.getter(#currentItemStream), - returnValue: _i23.Stream<_i26.NavigationItem>.empty(), - returnValueForMissingStub: _i23.Stream<_i26.NavigationItem>.empty(), - ) as _i23.Stream<_i26.NavigationItem>); + returnValue: _i24.Stream<_i27.NavigationItem>.empty(), + returnValueForMissingStub: _i24.Stream<_i27.NavigationItem>.empty(), + ) as _i24.Stream<_i27.NavigationItem>); @override - _i26.NavigationItem get currentItem => (super.noSuchMethod( + _i27.NavigationItem get currentItem => (super.noSuchMethod( Invocation.getter(#currentItem), - returnValue: _i26.NavigationItem.overview, - returnValueForMissingStub: _i26.NavigationItem.overview, - ) as _i26.NavigationItem); + returnValue: _i27.NavigationItem.overview, + returnValueForMissingStub: _i27.NavigationItem.overview, + ) as _i27.NavigationItem); @override - dynamic Function(_i26.NavigationItem) get navigateTo => (super.noSuchMethod( + dynamic Function(_i27.NavigationItem) get navigateTo => (super.noSuchMethod( Invocation.getter(#navigateTo), - returnValue: (_i26.NavigationItem __p0) => null, - returnValueForMissingStub: (_i26.NavigationItem __p0) => null, - ) as dynamic Function(_i26.NavigationItem)); + returnValue: (_i27.NavigationItem __p0) => null, + returnValueForMissingStub: (_i27.NavigationItem __p0) => null, + ) as dynamic Function(_i27.NavigationItem)); @override void dispose() => super.noSuchMethod( Invocation.method( @@ -431,23 +534,23 @@ class MockNavigationBloc extends _i2.Mock implements _i25.NavigationBloc { /// /// See the documentation for Mockito's code generation for more information. class MockNavigationExperimentCache extends _i2.Mock - implements _i27.NavigationExperimentCache { + implements _i28.NavigationExperimentCache { @override - _i3.ValueStream<_i28.NavigationExperimentOption> get currentNavigation => + _i3.ValueStream<_i29.NavigationExperimentOption> get currentNavigation => (super.noSuchMethod( Invocation.getter(#currentNavigation), - returnValue: _FakeValueStream_1<_i28.NavigationExperimentOption>( + returnValue: _FakeValueStream_1<_i29.NavigationExperimentOption>( this, Invocation.getter(#currentNavigation), ), returnValueForMissingStub: - _FakeValueStream_1<_i28.NavigationExperimentOption>( + _FakeValueStream_1<_i29.NavigationExperimentOption>( this, Invocation.getter(#currentNavigation), ), - ) as _i3.ValueStream<_i28.NavigationExperimentOption>); + ) as _i3.ValueStream<_i29.NavigationExperimentOption>); @override - void setNavigation(_i28.NavigationExperimentOption? option) => + void setNavigation(_i29.NavigationExperimentOption? option) => super.noSuchMethod( Invocation.method( #setNavigation, @@ -469,9 +572,9 @@ class MockNavigationExperimentCache extends _i2.Mock /// /// See the documentation for Mockito's code generation for more information. class MockNavigationAnalytics extends _i2.Mock - implements _i29.NavigationAnalytics { + implements _i30.NavigationAnalytics { @override - void logBottomNavigationBarEvent(_i26.NavigationItem? item) => + void logBottomNavigationBarEvent(_i27.NavigationItem? item) => super.noSuchMethod( Invocation.method( #logBottomNavigationBarEvent, @@ -480,7 +583,7 @@ class MockNavigationAnalytics extends _i2.Mock returnValueForMissingStub: null, ); @override - void logDrawerEvent(_i26.NavigationItem? item) => super.noSuchMethod( + void logDrawerEvent(_i27.NavigationItem? item) => super.noSuchMethod( Invocation.method( #logDrawerEvent, [item], @@ -524,7 +627,7 @@ class MockNavigationAnalytics extends _i2.Mock /// A class which mocks [SharezoneContext]. /// /// See the documentation for Mockito's code generation for more information. -class MockSharezoneContext extends _i2.Mock implements _i30.SharezoneContext { +class MockSharezoneContext extends _i2.Mock implements _i31.SharezoneContext { @override _i4.SharezoneGateway get api => (super.noSuchMethod( Invocation.getter(#api), @@ -603,11 +706,11 @@ class MockSharezoneGateway extends _i2.Mock implements _i4.SharezoneGateway { @override String get uID => (super.noSuchMethod( Invocation.getter(#uID), - returnValue: _i31.dummyValue( + returnValue: _i32.dummyValue( this, Invocation.getter(#uID), ), - returnValueForMissingStub: _i31.dummyValue( + returnValueForMissingStub: _i32.dummyValue( this, Invocation.getter(#uID), ), @@ -687,11 +790,11 @@ class MockSharezoneGateway extends _i2.Mock implements _i4.SharezoneGateway { @override String get memberID => (super.noSuchMethod( Invocation.getter(#memberID), - returnValue: _i31.dummyValue( + returnValue: _i32.dummyValue( this, Invocation.getter(#memberID), ), - returnValueForMissingStub: _i31.dummyValue( + returnValueForMissingStub: _i32.dummyValue( this, Invocation.getter(#memberID), ), @@ -745,14 +848,14 @@ class MockSharezoneGateway extends _i2.Mock implements _i4.SharezoneGateway { ), ) as _i18.TimetableGateway); @override - _i23.Future dispose() => (super.noSuchMethod( + _i24.Future dispose() => (super.noSuchMethod( Invocation.method( #dispose, [], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); } /// A class which mocks [UserGateway]. @@ -774,54 +877,54 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { @override String get uID => (super.noSuchMethod( Invocation.getter(#uID), - returnValue: _i31.dummyValue( + returnValue: _i32.dummyValue( this, Invocation.getter(#uID), ), - returnValueForMissingStub: _i31.dummyValue( + returnValueForMissingStub: _i32.dummyValue( this, Invocation.getter(#uID), ), ) as String); @override - _i23.Stream<_i19.AppUser?> get userStream => (super.noSuchMethod( + _i24.Stream<_i19.AppUser?> get userStream => (super.noSuchMethod( Invocation.getter(#userStream), - returnValue: _i23.Stream<_i19.AppUser?>.empty(), - returnValueForMissingStub: _i23.Stream<_i19.AppUser?>.empty(), - ) as _i23.Stream<_i19.AppUser?>); + returnValue: _i24.Stream<_i19.AppUser?>.empty(), + returnValueForMissingStub: _i24.Stream<_i19.AppUser?>.empty(), + ) as _i24.Stream<_i19.AppUser?>); @override - _i23.Stream<_i32.AuthUser?> get authUserStream => (super.noSuchMethod( + _i24.Stream<_i33.AuthUser?> get authUserStream => (super.noSuchMethod( Invocation.getter(#authUserStream), - returnValue: _i23.Stream<_i32.AuthUser?>.empty(), - returnValueForMissingStub: _i23.Stream<_i32.AuthUser?>.empty(), - ) as _i23.Stream<_i32.AuthUser?>); + returnValue: _i24.Stream<_i33.AuthUser?>.empty(), + returnValueForMissingStub: _i24.Stream<_i33.AuthUser?>.empty(), + ) as _i24.Stream<_i33.AuthUser?>); @override - _i23.Stream get isSignedInStream => (super.noSuchMethod( + _i24.Stream get isSignedInStream => (super.noSuchMethod( Invocation.getter(#isSignedInStream), - returnValue: _i23.Stream.empty(), - returnValueForMissingStub: _i23.Stream.empty(), - ) as _i23.Stream); + returnValue: _i24.Stream.empty(), + returnValueForMissingStub: _i24.Stream.empty(), + ) as _i24.Stream); @override - _i23.Stream<_i33.DocumentSnapshot> get userDocument => + _i24.Stream<_i34.DocumentSnapshot> get userDocument => (super.noSuchMethod( Invocation.getter(#userDocument), - returnValue: _i23.Stream<_i33.DocumentSnapshot>.empty(), + returnValue: _i24.Stream<_i34.DocumentSnapshot>.empty(), returnValueForMissingStub: - _i23.Stream<_i33.DocumentSnapshot>.empty(), - ) as _i23.Stream<_i33.DocumentSnapshot>); + _i24.Stream<_i34.DocumentSnapshot>.empty(), + ) as _i24.Stream<_i34.DocumentSnapshot>); @override - _i23.Stream<_i32.Provider?> get providerStream => (super.noSuchMethod( + _i24.Stream<_i33.Provider?> get providerStream => (super.noSuchMethod( Invocation.getter(#providerStream), - returnValue: _i23.Stream<_i32.Provider?>.empty(), - returnValueForMissingStub: _i23.Stream<_i32.Provider?>.empty(), - ) as _i23.Stream<_i32.Provider?>); + returnValue: _i24.Stream<_i33.Provider?>.empty(), + returnValueForMissingStub: _i24.Stream<_i33.Provider?>.empty(), + ) as _i24.Stream<_i33.Provider?>); @override - _i23.Future<_i19.AppUser> get() => (super.noSuchMethod( + _i24.Future<_i19.AppUser> get() => (super.noSuchMethod( Invocation.method( #get, [], ), - returnValue: _i23.Future<_i19.AppUser>.value(_FakeAppUser_17( + returnValue: _i24.Future<_i19.AppUser>.value(_FakeAppUser_17( this, Invocation.method( #get, @@ -829,23 +932,23 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { ), )), returnValueForMissingStub: - _i23.Future<_i19.AppUser>.value(_FakeAppUser_17( + _i24.Future<_i19.AppUser>.value(_FakeAppUser_17( this, Invocation.method( #get, [], ), )), - ) as _i23.Future<_i19.AppUser>); + ) as _i24.Future<_i19.AppUser>); @override - _i23.Future logOut() => (super.noSuchMethod( + _i24.Future logOut() => (super.noSuchMethod( Invocation.method( #logOut, [], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override bool isAnonymous() => (super.noSuchMethod( Invocation.method( @@ -856,42 +959,42 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { returnValueForMissingStub: false, ) as bool); @override - _i23.Stream isAnonymousStream() => (super.noSuchMethod( + _i24.Stream isAnonymousStream() => (super.noSuchMethod( Invocation.method( #isAnonymousStream, [], ), - returnValue: _i23.Stream.empty(), - returnValueForMissingStub: _i23.Stream.empty(), - ) as _i23.Stream); + returnValue: _i24.Stream.empty(), + returnValueForMissingStub: _i24.Stream.empty(), + ) as _i24.Stream); @override - _i23.Future linkWithCredential(_i34.AuthCredential? credential) => + _i24.Future linkWithCredential(_i35.AuthCredential? credential) => (super.noSuchMethod( Invocation.method( #linkWithCredential, [credential], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override - _i23.Future changeState(_i19.StateEnum? state) => (super.noSuchMethod( + _i24.Future changeState(_i19.StateEnum? state) => (super.noSuchMethod( Invocation.method( #changeState, [state], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override - _i23.Future addNotificationToken(String? token) => (super.noSuchMethod( + _i24.Future addNotificationToken(String? token) => (super.noSuchMethod( Invocation.method( #addNotificationToken, [token], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override void removeNotificationToken(String? token) => super.noSuchMethod( Invocation.method( @@ -901,27 +1004,27 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { returnValueForMissingStub: null, ); @override - _i23.Future setHomeworkReminderTime(_i1.TimeOfDay? timeOfDay) => + _i24.Future setHomeworkReminderTime(_i1.TimeOfDay? timeOfDay) => (super.noSuchMethod( Invocation.method( #setHomeworkReminderTime, [timeOfDay], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override - _i23.Future updateSettings(_i19.UserSettings? userSettings) => + _i24.Future updateSettings(_i19.UserSettings? userSettings) => (super.noSuchMethod( Invocation.method( #updateSettings, [userSettings], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override - _i23.Future updateSettingsSingleFiled( + _i24.Future updateSettingsSingleFiled( String? fieldName, dynamic data, ) => @@ -933,11 +1036,11 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { data, ], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override - _i23.Future updateUserTip( + _i24.Future updateUserTip( _i19.UserTipKey? userTipKey, bool? value, ) => @@ -949,9 +1052,9 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { value, ], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override void setBlackboardNotifications(bool? enabled) => super.noSuchMethod( Invocation.method( @@ -969,16 +1072,16 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { returnValueForMissingStub: null, ); @override - _i23.Future changeEmail(String? email) => (super.noSuchMethod( + _i24.Future changeEmail(String? email) => (super.noSuchMethod( Invocation.method( #changeEmail, [email], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override - _i23.Future addUser({ + _i24.Future addUser({ required _i19.AppUser? user, bool? merge = false, }) => @@ -991,28 +1094,28 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { #merge: merge, }, ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); @override - _i23.Future deleteUser(_i4.SharezoneGateway? gateway) => + _i24.Future deleteUser(_i4.SharezoneGateway? gateway) => (super.noSuchMethod( Invocation.method( #deleteUser, [gateway], ), - returnValue: _i23.Future.value(false), - returnValueForMissingStub: _i23.Future.value(false), - ) as _i23.Future); + returnValue: _i24.Future.value(false), + returnValueForMissingStub: _i24.Future.value(false), + ) as _i24.Future); @override - _i23.Future<_i20.AppFunctionsResult> updateUser( + _i24.Future<_i20.AppFunctionsResult> updateUser( _i19.AppUser? userData) => (super.noSuchMethod( Invocation.method( #updateUser, [userData], ), - returnValue: _i23.Future<_i20.AppFunctionsResult>.value( + returnValue: _i24.Future<_i20.AppFunctionsResult>.value( _FakeAppFunctionsResult_18( this, Invocation.method( @@ -1021,7 +1124,7 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { ), )), returnValueForMissingStub: - _i23.Future<_i20.AppFunctionsResult>.value( + _i24.Future<_i20.AppFunctionsResult>.value( _FakeAppFunctionsResult_18( this, Invocation.method( @@ -1029,23 +1132,23 @@ class MockUserGateway extends _i2.Mock implements _i13.UserGateway { [userData], ), )), - ) as _i23.Future<_i20.AppFunctionsResult>); + ) as _i24.Future<_i20.AppFunctionsResult>); @override - _i23.Future dispose() => (super.noSuchMethod( + _i24.Future dispose() => (super.noSuchMethod( Invocation.method( #dispose, [], ), - returnValue: _i23.Future.value(), - returnValueForMissingStub: _i23.Future.value(), - ) as _i23.Future); + returnValue: _i24.Future.value(), + returnValueForMissingStub: _i24.Future.value(), + ) as _i24.Future); } /// A class which mocks [HasUnreadFeedbackMessagesProvider]. /// /// See the documentation for Mockito's code generation for more information. class MockHasUnreadFeedbackMessagesProvider extends _i2.Mock - implements _i35.HasUnreadFeedbackMessagesProvider { + implements _i36.HasUnreadFeedbackMessagesProvider { @override _i21.FeedbackApi get feedbackApi => (super.noSuchMethod( Invocation.getter(#feedbackApi), @@ -1091,7 +1194,7 @@ class MockHasUnreadFeedbackMessagesProvider extends _i2.Mock returnValueForMissingStub: null, ); @override - void addListener(_i24.VoidCallback? listener) => super.noSuchMethod( + void addListener(_i25.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #addListener, [listener], @@ -1099,7 +1202,7 @@ class MockHasUnreadFeedbackMessagesProvider extends _i2.Mock returnValueForMissingStub: null, ); @override - void removeListener(_i24.VoidCallback? listener) => super.noSuchMethod( + void removeListener(_i25.VoidCallback? listener) => super.noSuchMethod( Invocation.method( #removeListener, [listener], diff --git a/app/test_goldens/timetable/timetable_page_test.mocks.dart b/app/test_goldens/timetable/timetable_page_test.mocks.dart index b9c66d8e5..1d8a13642 100644 --- a/app/test_goldens/timetable/timetable_page_test.mocks.dart +++ b/app/test_goldens/timetable/timetable_page_test.mocks.dart @@ -5,7 +5,7 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'dart:async' as _i4; -import 'package:clock/clock.dart' as _i2; +import 'package:cloud_functions/cloud_functions.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:sharezone/sharezone_plus/subscription_service/subscription_service.dart' as _i3; @@ -24,8 +24,9 @@ import 'package:user/user.dart' as _i5; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeClock_0 extends _i1.SmartFake implements _i2.Clock { - _FakeClock_0( +class _FakeFirebaseFunctions_0 extends _i1.SmartFake + implements _i2.FirebaseFunctions { + _FakeFirebaseFunctions_0( Object parent, Invocation parentInvocation, ) : super( @@ -46,17 +47,34 @@ class MockSubscriptionService extends _i1.Mock returnValueForMissingStub: _i4.Stream<_i5.AppUser?>.empty(), ) as _i4.Stream<_i5.AppUser?>); @override - _i2.Clock get clock => (super.noSuchMethod( - Invocation.getter(#clock), - returnValue: _FakeClock_0( + _i2.FirebaseFunctions get functions => (super.noSuchMethod( + Invocation.getter(#functions), + returnValue: _FakeFirebaseFunctions_0( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - returnValueForMissingStub: _FakeClock_0( + returnValueForMissingStub: _FakeFirebaseFunctions_0( this, - Invocation.getter(#clock), + Invocation.getter(#functions), ), - ) as _i2.Clock); + ) as _i2.FirebaseFunctions); + @override + _i4.Stream<_i5.SharezonePlusStatus?> get sharezonePlusStatusStream => + (super.noSuchMethod( + Invocation.getter(#sharezonePlusStatusStream), + returnValue: _i4.Stream<_i5.SharezonePlusStatus?>.empty(), + returnValueForMissingStub: _i4.Stream<_i5.SharezonePlusStatus?>.empty(), + ) as _i4.Stream<_i5.SharezonePlusStatus?>); + @override + set sharezonePlusStatusStream( + _i4.Stream<_i5.SharezonePlusStatus?>? _sharezonePlusStatusStream) => + super.noSuchMethod( + Invocation.setter( + #sharezonePlusStatusStream, + _sharezonePlusStatusStream, + ), + returnValueForMissingStub: null, + ); @override bool isSubscriptionActive([_i5.AppUser? appUser]) => (super.noSuchMethod( Invocation.method( @@ -96,4 +114,21 @@ class MockSubscriptionService extends _i1.Mock returnValue: _i4.Stream.empty(), returnValueForMissingStub: _i4.Stream.empty(), ) as _i4.Stream); + @override + _i4.Future cancelStripeSubscription() => (super.noSuchMethod( + Invocation.method( + #cancelStripeSubscription, + [], + ), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) as _i4.Future); + @override + void dispose() => super.noSuchMethod( + Invocation.method( + #dispose, + [], + ), + returnValueForMissingStub: null, + ); } diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/sharezone_plus_page_ui.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/sharezone_plus_page_ui.dart index a417b4a23..6d6c0415d 100644 --- a/lib/sharezone_plus/sharezone_plus_page_ui/lib/sharezone_plus_page_ui.dart +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/sharezone_plus_page_ui.dart @@ -8,6 +8,10 @@ library sharezone_plus_page_ui; +export 'src/buy_section.dart'; +export 'src/buying_disabled_dialog.dart'; +export 'src/buying_failed_dialog.dart'; +export 'src/call_to_action_button.dart'; export 'src/sharezone_plus_advantages.dart'; export 'src/sharezone_plus_faq.dart'; export 'src/sharezone_plus_legal_text.dart'; @@ -15,5 +19,3 @@ export 'src/sharezone_plus_page_header.dart'; export 'src/sharezone_plus_page_theme.dart'; export 'src/sharezone_plus_support_note.dart'; export 'src/why_sharezone_plus_card.dart'; -export 'src/sharezone_plus_price.dart'; -export 'src/call_to_action_button.dart'; diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buy_section.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buy_section.dart new file mode 100644 index 000000000..011d363f3 --- /dev/null +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buy_section.dart @@ -0,0 +1,244 @@ +// Copyright (c) 2024 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'package:flutter/material.dart'; +import 'package:platform_check/platform_check.dart'; +import 'package:sharezone_plus_page_ui/sharezone_plus_page_ui.dart'; +import 'package:sharezone_plus_page_ui/src/styled_markdown_text.dart'; +import 'package:sharezone_widgets/sharezone_widgets.dart'; + +enum PurchasePeriod { + /// A monthly subscription for Sharezone Plus. + monthly, + + /// A lifetime purchase for Sharezone Plus. + /// + /// This is a one-time purchase that gives the user Sharezone Plus for a + /// lifetime. + lifetime, +} + +/// A section where the user can the select the plan they want to buy and start +/// the purchase process. +class BuySection extends StatelessWidget { + const BuySection({ + super.key, + required this.monthlyPrice, + required this.lifetimePrice, + required this.onPurchase, + required this.currentPeriod, + required this.onPeriodChanged, + this.isPriceLoading = false, + this.isPurchaseButtonLoading = false, + }); + + final bool isPriceLoading; + final bool isPurchaseButtonLoading; + final String? monthlyPrice; + final String? lifetimePrice; + final VoidCallback? onPurchase; + final PurchasePeriod currentPeriod; + final ValueChanged onPeriodChanged; + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + layoutBuilder: (currentChild, previousChildren) { + return Stack( + // We need to align the children at the top to prevent the children + // from being centered when the previous child is removed. Otherwise, + // the new child will be centered and then move to the top (which + // looks weird). + alignment: Alignment.topCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + }, + child: Column( + key: ValueKey([isPriceLoading, currentPeriod]), + children: [ + MaxWidthConstraintBox( + maxWidth: 500, + child: Column( + children: [ + _PeriodOption( + name: 'Monatlich', + price: monthlyPrice, + period: PurchasePeriod.monthly, + currentPeriod: currentPeriod, + onPeriodChanged: onPeriodChanged, + isLoading: isPriceLoading, + ), + const SizedBox(height: 6), + _PeriodOption( + name: 'Lifetime (einmaliger Kauf)', + price: lifetimePrice, + period: PurchasePeriod.lifetime, + currentPeriod: currentPeriod, + onPeriodChanged: onPeriodChanged, + isLoading: isPriceLoading, + ), + ], + ), + ), + const SizedBox(height: 12), + _PurchaseButton( + period: currentPeriod, + onPressed: onPurchase, + isEnabled: !isPriceLoading && !isPurchaseButtonLoading, + isLoading: isPriceLoading || isPurchaseButtonLoading, + ), + const SizedBox(height: 12), + _LegalText(period: currentPeriod), + ], + ), + ); + } +} + +class _PurchaseButton extends StatelessWidget { + const _PurchaseButton({ + required this.period, + required this.onPressed, + required this.isEnabled, + required this.isLoading, + }); + + final PurchasePeriod period; + final VoidCallback? onPressed; + final bool isEnabled; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: isLoading, + child: CallToActionButton( + text: isLoading + ? const _LoadingSpinner() + : Text( + switch (period) { + PurchasePeriod.monthly => 'Abonnieren', + PurchasePeriod.lifetime => 'Kaufen', + }, + ), + onPressed: onPressed, + backgroundColor: Theme.of(context).colorScheme.primary, + ), + ); + } +} + +class _LoadingSpinner extends StatelessWidget { + const _LoadingSpinner(); + + @override + Widget build(BuildContext context) { + return const SizedBox( + height: 24.5, + width: 24.5, + child: CircularProgressIndicator( + color: Colors.white, + ), + ); + } +} + +class _PeriodOption extends StatelessWidget { + const _PeriodOption({ + required this.name, + required this.price, + required this.period, + required this.currentPeriod, + required this.onPeriodChanged, + required this.isLoading, + }); + + final String name; + final String? price; + final PurchasePeriod period; + final PurchasePeriod currentPeriod; + final ValueChanged onPeriodChanged; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return GrayShimmer( + enabled: isLoading, + child: CustomCard( + child: ListTile( + title: Text(name), + onTap: isLoading ? null : () => onPeriodChanged(period), + trailing: Radio( + groupValue: currentPeriod, + value: period, + onChanged: (v) { + if (v != null && !isLoading) onPeriodChanged(v); + }, + ), + subtitle: Text( + price ?? '...', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + ), + ), + ), + ), + ); + } +} + +class _LegalText extends StatelessWidget { + const _LegalText({required this.period}); + + final PurchasePeriod period; + + @override + Widget build(BuildContext context) { + return switch (period) { + PurchasePeriod.monthly => const _MonthlySubscriptionLegalText(), + PurchasePeriod.lifetime => const _LifetimeLegalText(), + }; + } +} + +const _termsOfServiceSentence = + 'Durch den Kauf bestätigst du, dass du die [ANBs](https://sharezone.net/terms-of-service) gelesen hast.'; + +class _LifetimeLegalText extends StatelessWidget { + const _LifetimeLegalText(); + + @override + Widget build(BuildContext context) { + return const StyledMarkdownText( + text: 'Einmalige Zahlung (kein Abo o. ä.). $_termsOfServiceSentence', + ); + } +} + +class _MonthlySubscriptionLegalText extends StatelessWidget { + const _MonthlySubscriptionLegalText(); + + @override + Widget build(BuildContext context) { + final currentPlatform = PlatformCheck.currentPlatform; + return StyledMarkdownText( + text: switch (currentPlatform) { + Platform.android => + 'Dein Abo ist monatlich kündbar. Es wird automatisch verlängert, wenn du es nicht mindestens 24 Stunden vor Ablauf der aktuellen Zahlungsperiode über Google Play kündigst. $_termsOfServiceSentence', + Platform.iOS || + Platform.macOS => + 'Dein Abo ist monatlich kündbar. Es wird automatisch verlängert, wenn du es nicht mindestens 24 Stunden vor Ablauf der aktuellen Zahlungsperiode über den App Store kündigst. $_termsOfServiceSentence', + _ => + 'Dein Abo ist monatlich kündbar. Es wird automatisch verlängert, wenn du es nicht vor Ablauf der aktuellen Zahlungsperiode über die App kündigst. $_termsOfServiceSentence', + }); + } +} diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buying_disabled_dialog.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buying_disabled_dialog.dart new file mode 100644 index 000000000..3e6fcb4f2 --- /dev/null +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buying_disabled_dialog.dart @@ -0,0 +1,43 @@ +// Copyright (c) 2023 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'package:flutter/material.dart'; +import 'package:sharezone_plus_page_ui/src/styled_markdown_text.dart'; +import 'package:sharezone_widgets/sharezone_widgets.dart'; + +/// A dialog that is shown when the user tries to buy Sharezone Plus but the +/// buying process is disabled (e.g. during maintenance). +class BuyingDisabledDialog extends StatelessWidget { + const BuyingDisabledDialog({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MaxWidthConstraintBox( + maxWidth: 400, + child: AlertDialog( + title: const Text("Kaufen deaktiviert"), + content: const StyledMarkdownText( + text: + "Der Kauf von Sharezone Plus ist aktuell deaktiviert. Bitte versuche es später erneut.\n\nAuf unserem [Discord](https://sharezone.net/discord) halten wir dich auf dem Laufenden.", + alignment: WrapAlignment.start, + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).primaryColor, + ), + onPressed: () => Navigator.pop(context), + child: const Text("OK"), + ) + ], + ), + ); + } +} diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buying_failed_dialog.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buying_failed_dialog.dart new file mode 100644 index 000000000..474fba87f --- /dev/null +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/buying_failed_dialog.dart @@ -0,0 +1,52 @@ +// Copyright (c) 2023 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'package:flutter/material.dart'; +import 'package:sharezone_plus_page_ui/src/styled_markdown_text.dart'; +import 'package:sharezone_widgets/sharezone_widgets.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// A dialog that is shown when buying Sharezone Plus failed. +class BuyingFailedDialog extends StatelessWidget { + const BuyingFailedDialog({ + super.key, + required this.error, + }); + + final String error; + + @override + Widget build(BuildContext context) { + return MaxWidthConstraintBox( + maxWidth: 400, + child: AlertDialog( + title: const Text("Kaufen fehlgeschlagen"), + content: SingleChildScrollView( + child: StyledMarkdownText( + text: + "Der Kauf von Sharezone Plus ist fehlgeschlagen. Bitte versuche es später erneut.\n\nFehler: $error\n\nBei Fragen wende dich an [plus@sharezone.net](mailto:plus@sharezone.net).", + alignment: WrapAlignment.start, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("OK"), + ), + FilledButton( + onPressed: () async { + const email = 'plus@sharezone.net'; + await launchUrl(Uri.parse('mailto:$email')); + }, + child: const Text('Support kontaktieren'), + ), + ], + ), + ); + } +} diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/call_to_action_button.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/call_to_action_button.dart index 0eee96127..953fbff05 100644 --- a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/call_to_action_button.dart +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/call_to_action_button.dart @@ -23,7 +23,7 @@ class CallToActionButton extends StatelessWidget { @override Widget build(BuildContext context) { return MaxWidthConstraintBox( - maxWidth: 300, + maxWidth: 350, child: SizedBox( width: double.infinity, child: ElevatedButton( diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_advantages.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_advantages.dart index bd1128922..197cf7aab 100644 --- a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_advantages.dart +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_advantages.dart @@ -16,125 +16,183 @@ class SharezonePlusAdvantages extends StatelessWidget { super.key, required this.isHomeworkReminderFeatureVisible, required this.isHomeworkDoneListsFeatureVisible, + this.onOpenedAdvantage, + this.onGitHubOpen, }); final bool isHomeworkReminderFeatureVisible; final bool isHomeworkDoneListsFeatureVisible; + final ValueChanged? onOpenedAdvantage; + final VoidCallback? onGitHubOpen; @override Widget build(BuildContext context) { return Column( children: [ - const _Grades(), - const _MoreColors(), - if (isHomeworkReminderFeatureVisible) const _HomeworkReminder(), - const _PastEvents(), - const _AddEventsToLocalCalendar(), - if (isHomeworkDoneListsFeatureVisible) const _HomeworkDoneLists(), - const _ReadByInformationSheets(), - const _SelectTimetableBySchoolClass(), - const _MoreStorage(), - const _PlusSupport(), - const _DiscordPlusRang(), - const _SupportOpenSource(), + _Grades(onOpen: onOpenedAdvantage), + _MoreColors(onOpen: onOpenedAdvantage), + if (isHomeworkReminderFeatureVisible) + _HomeworkReminder(onOpen: onOpenedAdvantage), + _PastEvents(onOpen: onOpenedAdvantage), + _AddEventsToLocalCalendar(onOpen: onOpenedAdvantage), + if (isHomeworkDoneListsFeatureVisible) + _HomeworkDoneLists(onOpen: onOpenedAdvantage), + _ReadByInformationSheets(onOpen: onOpenedAdvantage), + _SelectTimetableBySchoolClass(onOpen: onOpenedAdvantage), + _MoreStorage(onOpen: onOpenedAdvantage), + _PlusSupport(onOpen: onOpenedAdvantage), + _DiscordPlusRang(onOpen: onOpenedAdvantage), + _SupportOpenSource( + onOpen: onOpenedAdvantage, + onGitHubOpen: onGitHubOpen, + ), ], ); } } class _Grades extends StatelessWidget { - const _Grades(); + const _Grades({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.emoji_events), - title: Text('Noten'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('grades'); + }, + icon: const Icon(Icons.emoji_events), + title: const Text('Noten'), + description: const Text( 'Speichere deine Schulnoten mit Sharezone Plus und behalte den Überblick über deine Leistungen. Schriftliche Prüfungen, mündliche Mitarbeit, Halbjahresnoten - alles an einem Ort.'), ); } } class _MoreColors extends StatelessWidget { - const _MoreColors(); + const _MoreColors({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.color_lens), - title: Text('Mehr Farben für die Gruppen'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('more_colors'); + }, + icon: const Icon(Icons.color_lens), + title: const Text('Mehr Farben für die Gruppen'), + description: const Text( 'Sharezone Plus bietet dir über 200 (statt 19) Farben für deine Gruppen. Setzt du mit Sharezone Plus eine Farbe für deine Gruppe, so können auch deine Gruppenmitglieder diese Farbe sehen.'), ); } } class _SelectTimetableBySchoolClass extends StatelessWidget { - const _SelectTimetableBySchoolClass(); + const _SelectTimetableBySchoolClass({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.calendar_month), - title: Text('Stundenplan nach Klasse auswählen'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('select_timetable_by_school_class'); + }, + icon: const Icon(Icons.calendar_month), + title: const Text('Stundenplan nach Klasse auswählen'), + description: const Text( 'Du bist in mehreren Klassen? Mit Sharezone Plus kannst du den Stundenplan für jede Klasse einzeln auswählen. So siehst du immer den richtigen Stundenplan.'), ); } } class _PastEvents extends StatelessWidget { - const _PastEvents(); + const _PastEvents({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.history), - title: Text('Vergangene Termine einsehen'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('past_events'); + }, + icon: const Icon(Icons.history), + title: const Text('Vergangene Termine einsehen'), + description: const Text( 'Mit Sharezone Plus kannst du alle vergangenen Termine, wie z.B. Prüfungen, einsehen.'), ); } } class _AddEventsToLocalCalendar extends StatelessWidget { - const _AddEventsToLocalCalendar(); + const _AddEventsToLocalCalendar({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.calendar_today), - title: Text('Termine zum lokalen Kalender hinzufügen'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('add_events_to_calendar'); + }, + icon: const Icon(Icons.calendar_today), + title: const Text('Termine zum lokalen Kalender hinzufügen'), + description: const Text( 'Füge mit nur einem Klick einen Termin zu deinem lokalen Kalender hinzu (z.B. Apple oder Google Kalender).\n\nBeachte, dass die Funktion nur auf Android & iOS verfügbar ist. Zudem aktualisiert sich der Termin in deinem Kalender nicht automatisch, wenn dieser in Sharezone geändert wird.'), ); } } class _MoreStorage extends StatelessWidget { - const _MoreStorage(); + const _MoreStorage({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.storage), - title: Text('30 GB Speicherplatz'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('more_storage'); + }, + icon: const Icon(Icons.storage), + title: const Text('30 GB Speicherplatz'), + description: const Text( 'Mit Sharezone Plus erhältst du 30 GB Speicherplatz (statt 100 MB) für deine Dateien & Anhänge (bei Hausaufgaben & Infozetteln). Dies entspricht ca. 15.000 Fotos (2 MB pro Bild).\n\nDie Begrenzung gilt nicht für Dateien, die als Abgabe bei Hausaufgaben hochgeladen wird.'), ); } } class _PlusSupport extends StatelessWidget { - const _PlusSupport(); + const _PlusSupport({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.support_agent), - title: Text('Premium Support'), - description: MarkdownBody( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('support'); + }, + icon: const Icon(Icons.support_agent), + title: const Text('Premium Support'), + description: const MarkdownBody( data: '''Mit Sharezone Plus erhältst du Zugriff auf unseren Premium Support: - Innerhalb von wenigen Stunden eine Rückmeldung per E-Mail (anstatt bis zu 2 Wochen) @@ -145,53 +203,81 @@ class _PlusSupport extends StatelessWidget { } class _HomeworkReminder extends StatelessWidget { - const _HomeworkReminder(); + const _HomeworkReminder({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.notifications), - title: Text('Individuelle Uhrzeit für Hausaufgaben-Erinnerungen'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('homework_reminder'); + }, + icon: const Icon(Icons.notifications), + title: const Text('Individuelle Uhrzeit für Hausaufgaben-Erinnerungen'), + description: const Text( 'Mit Sharezone Plus kannst du die Erinnerung am Vortag für die Hausaufgaben individuell im 30-Minuten-Tack einstellen, z.B. 15:00 oder 15:30 Uhr. Dieses Feature ist nur für Schüler*innen verfügbar.'), ); } } class _HomeworkDoneLists extends StatelessWidget { - const _HomeworkDoneLists(); + const _HomeworkDoneLists({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.checklist), - title: Text('Erledigt-Status bei Hausaufgaben'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('homework_done_lists'); + }, + icon: const Icon(Icons.checklist), + title: const Text('Erledigt-Status bei Hausaufgaben'), + description: const Text( 'Erhalte eine Liste mit allen Schüler*innen samt Erledigt-Status für jede Hausaufgabe. Dieses Feature ist nur für Lehrkräfte verfügbar.'), ); } } class _ReadByInformationSheets extends StatelessWidget { - const _ReadByInformationSheets(); + const _ReadByInformationSheets({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { - return const _AdvantageTile( - icon: Icon(Icons.format_list_bulleted), - title: Text('Gelesen-Status bei Infozetteln'), - description: Text( + return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('read_by_information_sheets'); + }, + icon: const Icon(Icons.format_list_bulleted), + title: const Text('Gelesen-Status bei Infozetteln'), + description: const Text( 'Erhalte eine Liste mit allen Gruppenmitgliedern samt Lesestatus für jeden Infozettel - und stelle somit sicher, dass wichtige Informationen bei allen Mitgliedern angekommen sind.'), ); } } class _DiscordPlusRang extends StatelessWidget { - const _DiscordPlusRang(); + const _DiscordPlusRang({ + required this.onOpen, + }); + + final ValueChanged? onOpen; @override Widget build(BuildContext context) { return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('discord_plus_rang'); + }, icon: const Icon(Icons.discord), title: const Text('Discord Sharezone Plus Rang'), description: MarkdownBody( @@ -213,11 +299,20 @@ class _DiscordPlusRang extends StatelessWidget { } class _SupportOpenSource extends StatelessWidget { - const _SupportOpenSource(); + const _SupportOpenSource({ + required this.onOpen, + required this.onGitHubOpen, + }); + + final ValueChanged? onOpen; + final VoidCallback? onGitHubOpen; @override Widget build(BuildContext context) { return _AdvantageTile( + onOpen: () { + if (onOpen != null) onOpen!('open_source'); + }, icon: const Icon(Icons.favorite), title: const Text('Unterstützung von Open-Source'), description: MarkdownBody( @@ -231,6 +326,9 @@ class _SupportOpenSource extends StatelessWidget { ), onTapLink: (text, href, title) async { if (href == null) return; + if (onGitHubOpen != null) { + onGitHubOpen!(); + } await launchUrl(Uri.parse(href)); }, ), @@ -247,17 +345,20 @@ class _AdvantageTile extends StatelessWidget { // ignore: unused_element this.subtitle, required this.description, + required this.onOpen, }); final Icon icon; final Widget title; final Widget? subtitle; final Widget description; + final VoidCallback? onOpen; @override Widget build(BuildContext context) { const green = Color(0xFF6FCF97); return ExpansionCard( + onOpen: onOpen, header: Row( children: [ Container( diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_legal_text.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_legal_text.dart index 5329b77fe..89c107095 100644 --- a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_legal_text.dart +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_legal_text.dart @@ -7,14 +7,14 @@ // SPDX-License-Identifier: EUPL-1.2 import 'package:flutter/widgets.dart'; -import 'package:sharezone_plus_page_ui/src/markdown_centered_text.dart'; +import 'package:sharezone_plus_page_ui/src/styled_markdown_text.dart'; class SharezonePlusLegalText extends StatelessWidget { const SharezonePlusLegalText({super.key}); @override Widget build(BuildContext context) { - return const MarkdownCenteredText( + return const StyledMarkdownText( text: 'Dein Abo ist monatlich kündbar. Es wird automatisch verlängert, wenn du es nicht mindestens 24 Stunden vor Ablauf der aktuellen Zahlungsperiode über Google Play kündigst. Durch den Kauf bestätigst du, dass du die [AGBs](https://sharezone.net/terms-of-service) gelesen hast.', ); diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_price.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_price.dart deleted file mode 100644 index 733d969a2..000000000 --- a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_price.dart +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2024 Sharezone UG (haftungsbeschränkt) -// Licensed under the EUPL-1.2-or-later. -// -// You may obtain a copy of the Licence at: -// https://joinup.ec.europa.eu/software/page/eupl -// -// SPDX-License-Identifier: EUPL-1.2 - -import 'package:flutter/material.dart'; -import 'package:sharezone_widgets/sharezone_widgets.dart'; - -class SharezonePlusPriceLoadingIndicator extends StatelessWidget { - const SharezonePlusPriceLoadingIndicator({super.key}); - - @override - Widget build(BuildContext context) { - return const GrayShimmer(child: SharezonePlusPrice('-,-- €')); - } -} - -class SharezonePlusPrice extends StatelessWidget { - const SharezonePlusPrice( - this.monthlyPriceWithCurrencySign, { - super.key, - }); - - final String monthlyPriceWithCurrencySign; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - monthlyPriceWithCurrencySign, - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(width: 4), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - '/Monat', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ); - } -} diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_support_note.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_support_note.dart index 3ae173a88..e83d0879a 100644 --- a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_support_note.dart +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/sharezone_plus_support_note.dart @@ -7,7 +7,7 @@ // SPDX-License-Identifier: EUPL-1.2 import 'package:flutter/material.dart'; -import 'package:sharezone_plus_page_ui/src/markdown_centered_text.dart'; +import 'package:sharezone_plus_page_ui/src/styled_markdown_text.dart'; import 'package:sharezone_widgets/sharezone_widgets.dart'; class SharezonePlusSupportNote extends StatelessWidget { @@ -19,7 +19,7 @@ class SharezonePlusSupportNote extends StatelessWidget { Widget build(BuildContext context) { return const MaxWidthConstraintBox( maxWidth: 710, - child: MarkdownCenteredText( + child: StyledMarkdownText( text: 'Du hast noch Fragen zu Sharezone Plus? Schreib uns an ' '[plus@sharezone.net](mailto:plus@sharezone.net) eine E-Mail und ' 'wir helfen dir gerne weiter.', diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/markdown_centered_text.dart b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/styled_markdown_text.dart similarity index 70% rename from lib/sharezone_plus/sharezone_plus_page_ui/lib/src/markdown_centered_text.dart rename to lib/sharezone_plus/sharezone_plus_page_ui/lib/src/styled_markdown_text.dart index f6cba7170..d0b74a477 100644 --- a/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/markdown_centered_text.dart +++ b/lib/sharezone_plus/sharezone_plus_page_ui/lib/src/styled_markdown_text.dart @@ -10,10 +10,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:url_launcher/url_launcher.dart'; -class MarkdownCenteredText extends StatelessWidget { - const MarkdownCenteredText({super.key, required this.text}); +class StyledMarkdownText extends StatelessWidget { + const StyledMarkdownText({ + super.key, + required this.text, + this.alignment = WrapAlignment.center, + }); final String text; + final WrapAlignment alignment; @override Widget build(BuildContext context) { @@ -24,13 +29,13 @@ class MarkdownCenteredText extends StatelessWidget { color: Theme.of(context).primaryColor, decoration: TextDecoration.underline, ), - textAlign: WrapAlignment.center, + textAlign: alignment, ), onTapLink: (text, href, title) async { if (href == null) return; - if (href == 'https://sharezone.net/privacy-policy') { - Navigator.pushNamed(context, 'privacy-policy'); + if (href == 'https://sharezone.net/terms-of-service') { + Navigator.pushNamed(context, 'terms-of-service'); return; } diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/pubspec.lock b/lib/sharezone_plus/sharezone_plus_page_ui/pubspec.lock index 03b8c1433..a2350b10e 100644 --- a/lib/sharezone_plus/sharezone_plus_page_ui/pubspec.lock +++ b/lib/sharezone_plus/sharezone_plus_page_ui/pubspec.lock @@ -232,7 +232,7 @@ packages: source: hosted version: "6.0.2" platform_check: - dependency: transitive + dependency: "direct main" description: path: "../../platform_check" relative: true diff --git a/lib/sharezone_plus/sharezone_plus_page_ui/pubspec.yaml b/lib/sharezone_plus/sharezone_plus_page_ui/pubspec.yaml index 8941bedd1..03a5b540e 100644 --- a/lib/sharezone_plus/sharezone_plus_page_ui/pubspec.yaml +++ b/lib/sharezone_plus/sharezone_plus_page_ui/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: url: https://github.com/SharezoneApp/packages path: packages/flutter_markdown ref: 2f680bb80119f6fd037a7bda0984bc811b5dccb8 + platform_check: + path: ../../platform_check sharezone_widgets: path: ../../sharezone_widgets url_launcher: ^6.2.5 diff --git a/lib/sharezone_plus/stripe_checkout_session/lib/src/stripe_checkout_session.dart b/lib/sharezone_plus/stripe_checkout_session/lib/src/stripe_checkout_session.dart index a6b1c5daa..bd312960c 100644 --- a/lib/sharezone_plus/stripe_checkout_session/lib/src/stripe_checkout_session.dart +++ b/lib/sharezone_plus/stripe_checkout_session/lib/src/stripe_checkout_session.dart @@ -29,6 +29,9 @@ class StripeCheckoutSession { /// [successUrl] and [cancelUrl] are the URLs to which the user is redirected /// after the payment is completed or canceled. /// + /// [period] is the period for which the user is buying the subscription + /// / lifetime purchase. + /// /// Returns the URL of the Stripe checkout session. The user must be /// redirected to this URL to complete the payment. Future create({ @@ -36,6 +39,7 @@ class StripeCheckoutSession { String? buysFor, required Uri successUrl, required Uri cancelUrl, + required String period, }) async { assert( userId != null || buysFor != null, @@ -55,6 +59,7 @@ class StripeCheckoutSession { if (buysFor != null) 'buysFor': buysFor, 'successUrl': '$successUrl', 'cancelUrl': '$cancelUrl', + 'period': period, }, ), ); diff --git a/lib/sharezone_plus/stripe_checkout_session/test/stripe_checkout_session_test.dart b/lib/sharezone_plus/stripe_checkout_session/test/stripe_checkout_session_test.dart index a3bf8222a..7f59d9847 100644 --- a/lib/sharezone_plus/stripe_checkout_session/test/stripe_checkout_session_test.dart +++ b/lib/sharezone_plus/stripe_checkout_session/test/stripe_checkout_session_test.dart @@ -37,6 +37,7 @@ void main() { test('returns checkout url when passing user ID', () async { const userId = '123'; + const period = 'monthly'; when( client.post( Uri.parse(functionsUrl), @@ -47,6 +48,7 @@ void main() { 'userId': userId, 'successUrl': successUrl, 'cancelUrl': cancelUrl, + 'period': period, }), ), ).thenAnswer((_) async => http.Response('{"url": "$testUrl"}', 200)); @@ -55,6 +57,7 @@ void main() { userId: userId, successUrl: Uri.parse(successUrl), cancelUrl: Uri.parse(cancelUrl), + period: period, ); expect(result, equals(testUrl)); @@ -62,6 +65,7 @@ void main() { test('returns checkout url when passing buysFor ID', () async { const buysFor = '456'; + const period = 'monthly'; when( client.post( Uri.parse(functionsUrl), @@ -72,6 +76,7 @@ void main() { 'buysFor': buysFor, 'successUrl': successUrl, 'cancelUrl': cancelUrl, + 'period': period, }), ), ).thenAnswer((_) async => http.Response('{"url": "$testUrl"}', 200)); @@ -80,6 +85,7 @@ void main() { buysFor: buysFor, successUrl: Uri.parse(successUrl), cancelUrl: Uri.parse(cancelUrl), + period: period, ); expect(result, equals(testUrl)); diff --git a/lib/sharezone_widgets/lib/src/widgets/expansion_card.dart b/lib/sharezone_widgets/lib/src/widgets/expansion_card.dart index 1cf37b1e9..a64e845b0 100644 --- a/lib/sharezone_widgets/lib/src/widgets/expansion_card.dart +++ b/lib/sharezone_widgets/lib/src/widgets/expansion_card.dart @@ -44,6 +44,8 @@ class ExpansionCard extends StatefulWidget { this.backgroundColor, this.openTooltip = 'Aufklappen', this.closeTooltip = 'Zuklappen', + this.onOpen, + this.onClose, }); /// The header of the card. @@ -76,6 +78,12 @@ class ExpansionCard extends StatefulWidget { /// The default value is `Zuklappen`. final String closeTooltip; + /// A callback that is called when the card is opened. + final VoidCallback? onOpen; + + /// A callback that is called when the card is closed. + final VoidCallback? onClose; + @override State createState() => ExpansionCardState(); } @@ -93,6 +101,13 @@ class ExpansionCardState extends State { key: ValueKey(widget.header), onTap: () { setState(() => isExpanded = !isExpanded); + + if (widget.onOpen != null && !isExpanded) { + widget.onOpen!(); + } + if (widget.onClose != null && isExpanded) { + widget.onClose!(); + } }, child: MouseRegion( cursor: SystemMouseCursors.click, diff --git a/lib/user/lib/src/models/sharezone_plus_status.dart b/lib/user/lib/src/models/sharezone_plus_status.dart new file mode 100644 index 000000000..a7075984a --- /dev/null +++ b/lib/user/lib/src/models/sharezone_plus_status.dart @@ -0,0 +1,85 @@ +// Copyright (c) 2023 Sharezone UG (haftungsbeschränkt) +// Licensed under the EUPL-1.2-or-later. +// +// You may obtain a copy of the Licence at: +// https://joinup.ec.europa.eu/software/page/eupl +// +// SPDX-License-Identifier: EUPL-1.2 + +import 'package:helper_functions/helper_functions.dart'; + +class SharezonePlusStatus { + /// Whether the user has Sharezone Plus. + final bool hasPlus; + + /// Whether the subscription is cancelled. + final bool isCancelled; + + /// The source where the subscription was purchased. + /// + /// If `null` the user might have the lifetime option. + final SubscriptionSource? source; + + /// Whether the user has a lifetime subscription. + final bool hasLifetime; + + const SharezonePlusStatus({ + required this.hasPlus, + required this.isCancelled, + required this.source, + required this.hasLifetime, + }); + + static SharezonePlusStatus? fromData(Map? map) { + if (map == null) return null; + return SharezonePlusStatus.fromJson(map); + } + + factory SharezonePlusStatus.fromJson(Map map) { + return SharezonePlusStatus( + hasPlus: map['hasPlus'] ?? false, + isCancelled: map['isCancelled'] ?? false, + source: parseSource(map['subscriptionDetails']), + hasLifetime: map['period'] == 'lifetime', + ); + } + + static SubscriptionSource? parseSource( + Map? subscriptionDetails) { + if (subscriptionDetails == null) return null; + return SubscriptionSource.values.tryByName( + subscriptionDetails['source'], + defaultValue: SubscriptionSource.unknown, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is SharezonePlusStatus && + other.hasPlus == hasPlus && + other.isCancelled == isCancelled && + other.source == source && + other.hasLifetime == hasLifetime; + } + + @override + int get hashCode { + return hasPlus.hashCode ^ + isCancelled.hashCode ^ + source.hashCode ^ + hasLifetime.hashCode; + } +} + +enum SubscriptionSource { + playStore('Play Store'), + appStore('App Store'), + stripe('Stripe'), + unknown('Unknown'); + + const SubscriptionSource(this.uiString); + + final String uiString; +} diff --git a/lib/user/lib/src/models/subscription.dart b/lib/user/lib/src/models/subscription.dart deleted file mode 100644 index 855f79d4e..000000000 --- a/lib/user/lib/src/models/subscription.dart +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2023 Sharezone UG (haftungsbeschränkt) -// Licensed under the EUPL-1.2-or-later. -// -// You may obtain a copy of the Licence at: -// https://joinup.ec.europa.eu/software/page/eupl -// -// SPDX-License-Identifier: EUPL-1.2 - -import 'package:helper_functions/helper_functions.dart'; -import 'package:cloud_firestore_helper/cloud_firestore_helper.dart'; - -enum SubscriptionTier { - teacherPlus, - unknown, -} - -class Subscription { - final SubscriptionTier tier; - - /// The date the user last purchased the subscription. - /// - /// This will be updated every month as the subscription is renewed. - final DateTime purchasedAt; - - /// The date the latest user's subscription expires. - final DateTime expiresAt; - - const Subscription( - this.tier, - this.purchasedAt, - this.expiresAt, - ); - - Map toJson() { - return { - 'tier': tier.name, - 'purchasedAt': purchasedAt, - 'expiresAt': expiresAt, - }; - } - - static Subscription? fromData(Map? map) { - if (map == null) return null; - return Subscription.fromJson(map); - } - - factory Subscription.fromJson(Map map) { - return Subscription( - SubscriptionTier.values.tryByName( - map['tier'], - defaultValue: SubscriptionTier.unknown, - ), - dateTimeFromTimestamp(map['purchasedAt']), - dateTimeFromTimestamp(map['expiresAt']), - ); - } - - @override - String toString() => - 'Subscription(tier: $tier, purchasedAt: $purchasedAt, expiresAt: $expiresAt)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is Subscription && - other.tier == tier && - other.purchasedAt == purchasedAt && - other.expiresAt == expiresAt; - } - - @override - int get hashCode => tier.hashCode ^ purchasedAt.hashCode ^ expiresAt.hashCode; -} diff --git a/lib/user/lib/src/models/user.dart b/lib/user/lib/src/models/user.dart index f715ec186..e4457aacc 100644 --- a/lib/user/lib/src/models/user.dart +++ b/lib/user/lib/src/models/user.dart @@ -7,9 +7,9 @@ // SPDX-License-Identifier: EUPL-1.2 import 'package:characters/characters.dart'; -import 'package:helper_functions/helper_functions.dart'; import 'package:cloud_firestore_helper/cloud_firestore_helper.dart'; -import 'package:user/src/models/subscription.dart'; +import 'package:helper_functions/helper_functions.dart'; +import 'package:user/src/models/sharezone_plus_status.dart'; import 'features.dart'; import 'state_enum.dart'; @@ -36,7 +36,7 @@ class AppUser { final UserTipData userTipData; final DateTime? createdOn; final Features? features; - final Subscription? subscription; + final SharezonePlusStatus? sharezonePlus; final Map legalData; @@ -57,7 +57,7 @@ class AppUser { required this.userTipData, required this.createdOn, this.features, - required this.subscription, + required this.sharezonePlus, required this.legalData, }); @@ -78,7 +78,7 @@ class AppUser { userSettings: UserSettings.defaultSettings(), userTipData: UserTipData.empty(), createdOn: null, - subscription: null, + sharezonePlus: null, legalData: {}); } @@ -100,7 +100,7 @@ class AppUser { userSettings: UserSettings.defaultSettings(), userTipData: UserTipData.empty(), createdOn: null, - subscription: null, + sharezonePlus: null, legalData: {}, ); } @@ -122,7 +122,7 @@ class AppUser { userTipData: UserTipData.fromData(data['tips']), createdOn: dateTimeFromTimestampOrNull(data['createdOn']), features: Features.fromJson(data['features']), - subscription: Subscription.fromData(data['subscription']), + sharezonePlus: SharezonePlusStatus.fromData(data['sharezonePlus']), legalData: decodeMap(data['legal'] ?? {}, (key, decodedMapValue) => decodedMapValue as Map), ); @@ -143,7 +143,6 @@ class AppUser { 'settings': userSettings.toJson(), 'tips': userTipData.toJson(), 'features': features?.toJson(), - 'subscription': null, }; } @@ -177,7 +176,7 @@ class AppUser { bool? commentsNotifications, UserSettings? userSettings, UserTipData? userTipData, - Subscription? subscription, + SharezonePlusStatus? sharezonePlus, Features? features, }) { return AppUser._( @@ -199,7 +198,7 @@ class AppUser { createdOn: createdOn, referredBy: referredBy, features: features ?? this.features, - subscription: subscription ?? this.subscription, + sharezonePlus: sharezonePlus ?? this.sharezonePlus, legalData: legalData, ); } diff --git a/lib/user/lib/user.dart b/lib/user/lib/user.dart index 7b3e1af66..3fb7f057e 100644 --- a/lib/user/lib/user.dart +++ b/lib/user/lib/user.dart @@ -10,7 +10,7 @@ library user; export 'src/models/features.dart'; export 'src/models/state_enum.dart'; -export 'src/models/subscription.dart'; +export 'src/models/sharezone_plus_status.dart'; export 'src/models/timetable/enabled_weekdays.dart'; export 'src/models/timetable/period.dart'; export 'src/models/tips/user_tip_data.dart'; diff --git a/website/lib/sharezone_plus/sharezone_plus_page.dart b/website/lib/sharezone_plus/sharezone_plus_page.dart index 9ebeaadb6..68fe7f25d 100644 --- a/website/lib/sharezone_plus/sharezone_plus_page.dart +++ b/website/lib/sharezone_plus/sharezone_plus_page.dart @@ -59,7 +59,7 @@ class _SubscribeSection extends StatelessWidget { Widget build(BuildContext context) { return const Column( children: [ - SharezonePlusPrice('4,99€'), + Text('4,99€'), SizedBox(height: 12), _SubscribeButton(), SizedBox(height: 12),