From c86eeb99874e56bdb86e87ebb645627885957854 Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Sun, 3 Aug 2025 13:01:59 +0200 Subject: [PATCH] aligned with iap --- .../src/providers/iap_products_provider.dart | 11 +- integration_test/mocks/iap_products_mock.dart | 9 +- .../utils/widget_tester_actions.dart | 5 +- lib/runner.dart | 13 +- .../widget_offering_lightmeter_pro.dart | 184 ++++++++++++++++++ .../lightmeter_pro/screen_lightmeter_pro.dart | 25 +-- .../buy_pro/widget_list_tile_buy_pro.dart | 20 +- .../iap_list_tile/widget_list_tile_iap.dart | 3 - lib/utils/context_utils.dart | 2 +- 9 files changed, 199 insertions(+), 73 deletions(-) create mode 100644 lib/screens/lightmeter_pro/components/offering/widget_offering_lightmeter_pro.dart diff --git a/iap/lib/src/providers/iap_products_provider.dart b/iap/lib/src/providers/iap_products_provider.dart index 936f296..aca54c9 100644 --- a/iap/lib/src/providers/iap_products_provider.dart +++ b/iap/lib/src/providers/iap_products_provider.dart @@ -2,10 +2,9 @@ import 'package:flutter/material.dart'; import 'package:m3_lightmeter_iap/src/data/models/iap_product.dart'; class IAPProductsProvider extends StatefulWidget { - final String apiUrl; final Widget child; - const IAPProductsProvider({required this.apiUrl, required this.child, super.key}); + const IAPProductsProvider({required this.child, super.key}); static IAPProductsProviderState of(BuildContext context) => IAPProductsProvider.maybeOf(context)!; @@ -21,13 +20,7 @@ class IAPProductsProviderState extends State { @override Widget build(BuildContext context) { return IAPProducts( - products: [ - IAPProduct( - storeId: IAPProductType.paidFeatures.storeId, - status: IAPProductStatus.purchased, - price: '0.0\$', - ) - ], + isPro: true, child: widget.child, ); } diff --git a/integration_test/mocks/iap_products_mock.dart b/integration_test/mocks/iap_products_mock.dart index 9742c8a..b5d9aaa 100644 --- a/integration_test/mocks/iap_products_mock.dart +++ b/integration_test/mocks/iap_products_mock.dart @@ -20,16 +20,11 @@ class MockIAPProductsProvider extends StatefulWidget { class MockIAPProductsProviderState extends State { late bool _purchased = widget.initialyPurchased; + @override Widget build(BuildContext context) { return IAPProducts( - products: List.from([ - IAPProduct( - storeId: IAPProductType.paidFeatures.storeId, - status: _purchased ? IAPProductStatus.purchased : IAPProductStatus.purchasable, - price: '0.0\$', - ), - ]), + isPro: _purchased, child: widget.child, ); } diff --git a/integration_test/utils/widget_tester_actions.dart b/integration_test/utils/widget_tester_actions.dart index 72c0e55..7754bb4 100644 --- a/integration_test/utils/widget_tester_actions.dart +++ b/integration_test/utils/widget_tester_actions.dart @@ -7,7 +7,6 @@ import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart'; import 'package:lightmeter/screens/metering/screen_metering.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import '../mocks/iap_products_mock.dart'; @@ -19,7 +18,7 @@ const mockPhotoEv100 = 8.3; extension WidgetTesterCommonActions on WidgetTester { Future pumpApplication({ - IAPProductStatus productStatus = IAPProductStatus.purchased, + bool isPro = true, TogglableMap? equipmentProfiles, String selectedEquipmentProfileId = '', TogglableMap? predefinedFilms, @@ -28,7 +27,7 @@ extension WidgetTesterCommonActions on WidgetTester { }) async { await pumpWidget( MockIAPProductsProvider( - initialyPurchased: productStatus == IAPProductStatus.purchased, + initialyPurchased: isPro, child: ApplicationWrapper( const Environment.dev(), child: MockIAPProviders( diff --git a/lib/runner.dart b/lib/runner.dart index 765f300..16f7255 100644 --- a/lib/runner.dart +++ b/lib/runner.dart @@ -5,7 +5,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:lightmeter/application.dart'; import 'package:lightmeter/application_wrapper.dart'; -import 'package:lightmeter/constants.dart'; import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/data/analytics/api/analytics_firebase.dart'; import 'package:lightmeter/environment.dart'; @@ -27,18 +26,10 @@ Future runLightmeterApp(Environment env) async { runApp( env.buildType == BuildType.dev ? IAPProducts( - products: [ - IAPProduct( - storeId: IAPProductType.paidFeatures.storeId, - price: '0.0\$', - ), - ], + isPro: true, child: application, ) - : IAPProductsProvider( - apiUrl: iapServerUrl, - child: application, - ), + : IAPProductsProvider(child: application), ); }, _errorsLogger.logCrash, diff --git a/lib/screens/lightmeter_pro/components/offering/widget_offering_lightmeter_pro.dart b/lib/screens/lightmeter_pro/components/offering/widget_offering_lightmeter_pro.dart new file mode 100644 index 0000000..7725777 --- /dev/null +++ b/lib/screens/lightmeter_pro/components/offering/widget_offering_lightmeter_pro.dart @@ -0,0 +1,184 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/res/theme.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; + +class LightmeterProBottomControls extends StatefulWidget { + const LightmeterProBottomControls({super.key}); + + @override + State createState() => _LightmeterProBottomControlsState(); +} + +class _LightmeterProBottomControlsState extends State { + late final Future> productsFuture; + bool _isLoading = true; + IAPProduct? monthly; + IAPProduct? lifetime; + IAPProduct? selected; + + @override + void initState() { + super.initState(); + productsFuture = IAPProductsProvider.of(context).fetchProducts(); + productsFuture.then((products) { + monthly = products.firstWhereOrNull((p) => p.type == PurchaseType.monthly); + lifetime = products.firstWhereOrNull((p) => p.type == PurchaseType.lifetime); + selected = monthly ?? lifetime; + }).onError((_, __) { + /// + }).whenComplete(() { + _isLoading = false; + if (mounted) setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + color: Theme.of(context).colorScheme.surfaceElevated1, + width: MediaQuery.sizeOf(context).width, + padding: EdgeInsets.fromLTRB( + Dimens.paddingM, + Dimens.paddingM, + Dimens.paddingM, + Dimens.paddingM + MediaQuery.paddingOf(context).bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AnimatedSwitcher( + duration: Dimens.durationM, + child: _isLoading + ? const CircularProgressIndicator() + : _Products( + monthly: monthly, + lifetime: lifetime, + selected: selected, + onProductSelected: (value) { + setState(() { + selected = value; + }); + }, + ), + ), + const SizedBox(height: Dimens.grid16), + FilledButton( + onPressed: selected != null + ? () { + IAPProductsProvider.of(context).buyPro(selected!); + } + : null, + child: Text("Continue"), + ), + const _RestorePurchasesButton(), + ], + ), + ); + } + + @override + void dispose() { + super.dispose(); + } +} + +class _Products extends StatelessWidget { + const _Products({ + this.monthly, + this.lifetime, + required this.selected, + required this.onProductSelected, + }); + + final IAPProduct? monthly; + final IAPProduct? lifetime; + final IAPProduct? selected; + final ValueChanged onProductSelected; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (monthly case final monthly?) + _OfferingItems( + product: monthly, + isSelected: selected == monthly, + onPressed: () => onProductSelected(monthly), + ), + const SizedBox(height: Dimens.grid8), + if (lifetime case final lifetime?) + _OfferingItems( + product: lifetime, + isSelected: selected == lifetime, + onPressed: () => onProductSelected(lifetime), + ), + ], + ); + } +} + +class _OfferingItems extends StatelessWidget { + const _OfferingItems({ + required this.product, + required this.isSelected, + required this.onPressed, + }); + + final IAPProduct product; + final bool isSelected; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Card( + color: Theme.of(context).colorScheme.primaryContainer, + shape: isSelected + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Dimens.borderRadiusL), + side: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + ) + : null, + child: GestureDetector( + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Dimens.paddingS), + child: ListTile( + title: AnimatedDefaultTextStyle( + duration: Dimens.durationM, + style: Theme.of(context).textTheme.bodyLarge!.boldIfSelected(isSelected), + child: Text(product.type.name), + ), + trailing: AnimatedDefaultTextStyle( + duration: Dimens.durationM, + style: Theme.of(context).textTheme.bodyMedium!.boldIfSelected(isSelected), + child: Text(product.price), + ), + ), + ), + ), + ); + } +} + +class _RestorePurchasesButton extends StatelessWidget { + const _RestorePurchasesButton(); + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: () {}, + child: Text(S.of(context).restorePurchases), + ); + } +} + +extension on TextStyle { + TextStyle boldIfSelected(bool isSelected) => copyWith(fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal); +} diff --git a/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart b/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart index c527f76..6281c05 100644 --- a/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart +++ b/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart @@ -2,12 +2,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:lightmeter/data/models/app_feature.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/res/dimens.dart'; -import 'package:lightmeter/res/theme.dart'; +import 'package:lightmeter/screens/lightmeter_pro/components/offering/widget_offering_lightmeter_pro.dart'; import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; import 'package:lightmeter/utils/text_height.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; class LightmeterProScreen extends StatelessWidget { final features = @@ -64,26 +62,7 @@ class LightmeterProScreen extends StatelessWidget { ], ), ), - Container( - color: Theme.of(context).colorScheme.surfaceElevated1, - width: MediaQuery.sizeOf(context).width, - padding: EdgeInsets.fromLTRB( - Dimens.paddingM, - Dimens.paddingM, - Dimens.paddingM, - Dimens.paddingM + MediaQuery.paddingOf(context).bottom, - ), - child: FilledButton( - onPressed: () { - ServicesProvider.maybeOf(context) - ?.analytics - .setCustomKey('iap_product_type', IAPProductType.paidFeatures.storeId); - IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures); - Navigator.of(context).pop(); - }, - child: Text(S.of(context).getNowFor(IAPProducts.productOf(context, IAPProductType.paidFeatures)!.price)), - ), - ), + const LightmeterProBottomControls(), ], ); } diff --git a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart index 93c39e6..160dcc0 100644 --- a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart +++ b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart @@ -1,31 +1,19 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/navigation/routes.dart'; -import 'package:lightmeter/res/dimens.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; class BuyProListTile extends StatelessWidget { const BuyProListTile({super.key}); @override Widget build(BuildContext context) { - final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status; - final isPending = status == IAPProductStatus.purchased || status == null; + // TODO: implement pending handling via REvenueCat return ListTile( leading: const Icon(Icons.bolt), title: Text(S.of(context).getPro), - onTap: !isPending - ? () { - Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name); - } - : null, - trailing: isPending - ? const SizedBox( - height: Dimens.grid24, - width: Dimens.grid24, - child: CircularProgressIndicator(), - ) - : null, + onTap: () { + Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name); + }, ); } } diff --git a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart index 8cc522a..d44c872 100644 --- a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart +++ b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart @@ -1,19 +1,16 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart'; import 'package:lightmeter/utils/context_utils.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; /// Depends on the product status and replaces [onTap] with purchase callback /// if the product is purchasable. class IAPListTile extends StatelessWidget { - final IAPProductType product; final Icon leading; final Text title; final VoidCallback onTap; final bool showPendingTrailing; const IAPListTile({ - this.product = IAPProductType.paidFeatures, required this.leading, required this.title, required this.onTap, diff --git a/lib/utils/context_utils.dart b/lib/utils/context_utils.dart index f64ea76..0f25953 100644 --- a/lib/utils/context_utils.dart +++ b/lib/utils/context_utils.dart @@ -8,5 +8,5 @@ extension BuildContextUtils on BuildContext { return UserPreferencesProvider.meteringScreenFeatureOf(this, feature); } - bool get isPro => IAPProducts.isPurchased(this, IAPProductType.paidFeatures); + bool get isPro => IAPProducts.isPro(this); }