aligned with iap

This commit is contained in:
Vadim 2025-08-03 13:01:59 +02:00
parent 0f9992b4c9
commit c86eeb9987
9 changed files with 199 additions and 73 deletions

View file

@ -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<IAPProductsProvider> {
@override
Widget build(BuildContext context) {
return IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$',
)
],
isPro: true,
child: widget.child,
);
}

View file

@ -20,16 +20,11 @@ class MockIAPProductsProvider extends StatefulWidget {
class MockIAPProductsProviderState extends State<MockIAPProductsProvider> {
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,
);
}

View file

@ -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<void> pumpApplication({
IAPProductStatus productStatus = IAPProductStatus.purchased,
bool isPro = true,
TogglableMap<EquipmentProfile>? equipmentProfiles,
String selectedEquipmentProfileId = '',
TogglableMap<Film>? 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(

View file

@ -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<void> 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,

View file

@ -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<LightmeterProBottomControls> createState() => _LightmeterProBottomControlsState();
}
class _LightmeterProBottomControlsState extends State<LightmeterProBottomControls> {
late final Future<List<IAPProduct>> 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<IAPProduct> 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);
}

View file

@ -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(),
],
);
}

View file

@ -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);
},
);
}
}

View file

@ -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,

View file

@ -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);
}