ML-245 Add support for subscriptions (#246)

* typos

* added `LogbookPhotosProvider`

* implemented `LogbookScreen`

* implemented `LogbookPhotoEditScreen`

* added photo update

* save geolocation

* added `CameraSettingsSection`

* adjusted logbook grid

* added hero animation

* fixed logbook list updates

* added empty logbook state

* added `saveLogbookPhotos` option

* fixed updating photos

* made `DialogPicker` content scrollable

* added tests for `LogbookPhotosProvider`

* made image preview full-width

* made note field multiline

* wip

* migrated to new iap service

* fixed unit tests

* typo

* fixed arb formatting

* stub logbook photos for tests

* implemented integration test for logbook

* moved date to title

* redundant bottom padding

* added logbook photo screen to screenshots generator

* Update settings.gradle

* aligned iap stub with iap release

* sync

* made logbook iap

* debug screenshots

* Update runner.dart

* fixed dialog picker of optional values

* added bottom padding to logbook edit screen

* fixed tests

* Create camera_stub_image.jpg

* Update films_provider_test.dart

* rename

* aligned with iap

* added missing translations

* theme

* adjusted products color

* check pro status on settings open

* added yearly subscription

* handle purchase errors

* fixed bottom navigation bar behaviour

* handle only lifetime product case

* don't fetch products

* reworked restoring purchases

* fixed mocks

* fixed golden tests

* fixed logbook integration test

* sync pubspec

* sync stub
This commit is contained in:
Vadim 2025-08-09 17:22:34 +02:00 committed by GitHub
parent 7ad47c0636
commit f9246e15d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 645 additions and 347 deletions

View file

@ -1,34 +1,13 @@
enum IAPProductStatus {
purchasable,
pending,
purchased,
}
enum IAPProductType { paidFeatures }
class IAPProduct {
final String storeId;
final IAPProductStatus status;
final PurchaseType type;
final String price;
const IAPProduct({
required this.storeId,
this.status = IAPProductStatus.purchasable,
required this.type,
required this.price,
});
IAPProduct copyWith({IAPProductStatus? status}) => IAPProduct(
storeId: storeId,
status: status ?? this.status,
price: price,
);
}
extension IAPProductTypeExtension on IAPProductType {
String get storeId {
switch (this) {
case IAPProductType.paidFeatures:
return "";
}
}
}
enum PurchaseType { monthly, yearly, lifetime }

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,52 +20,72 @@ class IAPProductsProviderState extends State<IAPProductsProvider> {
@override
Widget build(BuildContext context) {
return IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
isPro: true,
lifetime: const IAPProduct(
storeId: '',
type: PurchaseType.lifetime,
price: '0.0\$',
)
],
),
yearly: const IAPProduct(
storeId: '',
type: PurchaseType.yearly,
price: '0.0\$',
),
monthly: const IAPProduct(
storeId: '',
type: PurchaseType.monthly,
price: '0.0\$',
),
child: widget.child,
);
}
Future<void> buy(IAPProductType type) async {}
Future<List<IAPProduct>> fetchProducts() async {
return [];
}
Future<void> restorePurchases() async {}
Future<bool> buyPro(IAPProduct product) async {
return false;
}
Future<bool> restorePurchases() async {
return false;
}
Future<bool> checkIsPro() async {
return false;
}
}
class IAPProducts extends InheritedModel<IAPProductType> {
final List<IAPProduct> products;
class IAPProducts extends InheritedWidget {
final IAPProduct? lifetime;
final IAPProduct? yearly;
final IAPProduct? monthly;
final bool _isPro;
const IAPProducts({
required this.products,
this.lifetime,
this.yearly,
this.monthly,
required bool isPro,
required super.child,
super.key,
});
}) : _isPro = isPro;
static IAPProduct? productOf(BuildContext context, IAPProductType type) {
final IAPProducts? result = InheritedModel.inheritFrom<IAPProducts>(context, aspect: type);
return result!._findProduct(type);
static IAPProducts of(BuildContext context) {
return context.getInheritedWidgetOfExactType<IAPProducts>()!;
}
static bool isPurchased(BuildContext context, IAPProductType type) {
final IAPProducts? result = InheritedModel.inheritFrom<IAPProducts>(context, aspect: type);
return result!._findProduct(type)?.status == IAPProductStatus.purchased;
static bool isPro(BuildContext context, {bool listen = true}) {
return (listen
? context.dependOnInheritedWidgetOfExactType<IAPProducts>()
: context.getInheritedWidgetOfExactType<IAPProducts>())
?._isPro ==
true;
}
bool get hasSubscriptions => yearly != null || monthly != null;
@override
bool updateShouldNotify(IAPProducts oldWidget) => true;
@override
bool updateShouldNotifyDependent(IAPProducts oldWidget, Set<IAPProductType> dependencies) => true;
IAPProduct? _findProduct(IAPProductType type) {
try {
return products.firstWhere((element) => element.storeId == type.storeId);
} catch (_) {
return null;
}
}
bool updateShouldNotify(IAPProducts oldWidget) => oldWidget._isPro != _isPro;
}

View file

@ -100,7 +100,7 @@ extension on WidgetTester {
Future<void> openPickerAndSelect<V>(String title, String valueToSelect) async {
await tap(find.text(title));
await pumpAndSettle();
final dialogFinder = find.byType(DialogPicker<V?>);
final dialogFinder = find.byType(DialogPicker<Optional<V>>);
final listTileFinder = find.text(valueToSelect);
await scrollUntilVisible(
listTileFinder,

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

@ -15,7 +15,6 @@ import 'package:lightmeter/screens/metering/components/shared/readings_container
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:lightmeter/utils/platform_utils.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -42,7 +41,7 @@ void testPurchases(String description) {
UserPreferencesService.seenChangelogVersionKey: await const PlatformUtils().version,
});
await tester.pumpApplication(productStatus: IAPProductStatus.purchasable);
await tester.pumpApplication(isPro: false);
await tester.takePhoto();
/// Expect the bare minimum free functionallity

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

@ -59,7 +59,7 @@ class Application extends StatelessWidget {
EquipmentProfileEditFlow(args: context.routeArgs<EquipmentProfileEditArgs>()),
NavigationRoutes.filmsListScreen.name: (_) => const FilmsScreen(),
NavigationRoutes.filmEditScreen.name: (context) => FilmEditFlow(args: context.routeArgs<FilmEditArgs>()),
NavigationRoutes.proFeaturesScreen.name: (_) => LightmeterProScreen(),
NavigationRoutes.proFeaturesScreen.name: (_) => const LightmeterProScreen(),
NavigationRoutes.timerScreen.name: (context) => TimerFlow(args: context.routeArgs<TimerFlowArgs>()),
NavigationRoutes.logbookPhotosListScreen.name: (_) => const LogbookPhotosScreen(),
NavigationRoutes.logbookPhotoEditScreen.name: (context) =>

View file

@ -176,5 +176,18 @@
"location": "Standort",
"noMapsAppFound": "Keine Kartenanwendung gefunden.",
"logbook": "Fototagebuch",
"noPhotos": "Keine Fotos"
"noPhotos": "Keine Fotos",
"continuePurchase": "Weiter",
"monthly": "Monatlich",
"yearly": "Jährlich",
"lifetime": "Für immer",
"pricePerMonth": "{price}/Monat",
"pricePerYear": "{price}/Jahr",
"@pricePerMonth": {
"placeholders": {
"price": {
"type": "String"
}
}
}
}

View file

@ -176,5 +176,18 @@
"location": "Location",
"noMapsAppFound": "No maps application found.",
"logbook": "Logbook",
"noPhotos": "No photos"
"noPhotos": "No photos",
"continuePurchase": "Continue",
"monthly": "Monthly",
"yearly": "Yearly",
"lifetime": "Lifetime",
"pricePerMonth": "{price}/month",
"pricePerYear": "{price}/year",
"@pricePerMonth": {
"placeholders": {
"price": {
"type": "String"
}
}
}
}

View file

@ -167,5 +167,18 @@
"location": "Emplacement",
"noMapsAppFound": "Aucune application de cartes trouvée.",
"logbook": "Carnet photo",
"noPhotos": "Aucune photo"
"noPhotos": "Aucune photo",
"continuePurchase": "Continuer",
"monthly": "Mensuel",
"yearly": "Annuel",
"lifetime": "Pour toujours",
"pricePerMonth": "{price}/mois",
"pricePerYear": "{price}/an",
"@pricePerMonth": {
"placeholders": {
"price": {
"type": "String"
}
}
}
}

View file

@ -166,5 +166,18 @@
"location": "Местоположение",
"noMapsAppFound": "Приложение карт не найдено.",
"logbook": "Фотожурнал",
"noPhotos": "Нет фотографий"
"noPhotos": "Нет фотографий",
"continuePurchase": "Продолжить",
"monthly": "Ежемесячно",
"yearly": "Ежегодно",
"lifetime": "Навсегда",
"pricePerMonth": "{price}/месяц",
"pricePerYear": "{price}/год",
"@pricePerMonth": {
"placeholders": {
"price": {
"type": "String"
}
}
}
}

View file

@ -164,5 +164,18 @@
"location": "位置",
"noMapsAppFound": "未找到地图应用程序。",
"logbook": "拍照日志",
"noPhotos": "没有照片"
"noPhotos": "没有照片",
"continuePurchase": "继续",
"monthly": "月付",
"yearly": "年付",
"lifetime": "永久",
"pricePerMonth": "{price}/月",
"pricePerYear": "{price}/年",
"@pricePerMonth": {
"placeholders": {
"price": {
"type": "String"
}
}
}
}

View file

@ -64,6 +64,16 @@ ThemeData themeFrom(Color primaryColor, Brightness brightness) {
scaffoldBackgroundColor: scheme.surface,
);
return theme.copyWith(
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.square(Dimens.grid56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
),
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
),
listTileTheme: ListTileThemeData(
style: ListTileStyle.list,
iconColor: scheme.onSurface,

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,222 @@
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:lightmeter/screens/shared/button/widget_button_filled_large.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class LightmeterProOffering extends StatefulWidget {
const LightmeterProOffering({
super.key,
required this.isEnabled,
required this.onBuyProduct,
});
final bool isEnabled;
final ValueChanged<IAPProduct> onBuyProduct;
@override
State<LightmeterProOffering> createState() => _LightmeterProOfferingState();
}
class _LightmeterProOfferingState extends State<LightmeterProOffering> {
late final Future<List<IAPProduct>> productsFuture;
IAPProduct? selected;
@override
void didChangeDependencies() {
super.didChangeDependencies();
selected = IAPProducts.of(context).monthly ?? IAPProducts.of(context).lifetime;
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(Dimens.borderRadiusL),
topRight: Radius.circular(Dimens.borderRadiusL),
),
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,
mainAxisSize: MainAxisSize.min,
children: [
if (!_isLifetimeOnly)
AnimatedOpacity(
duration: Dimens.durationM,
opacity: widget.isEnabled ? Dimens.enabledOpacity : Dimens.disabledOpacity,
child: IgnorePointer(
ignoring: !widget.isEnabled,
child: Padding(
padding: const EdgeInsets.only(bottom: Dimens.paddingS),
child: _Products(
monthly: IAPProducts.of(context).monthly,
yearly: IAPProducts.of(context).yearly,
lifetime: IAPProducts.of(context).lifetime,
selected: selected,
onProductSelected: _selectProduct,
),
),
),
),
FilledButtonLarge(
title: S.of(context).continuePurchase,
onPressed: widget.isEnabled && selected != null ? () => widget.onBuyProduct(selected!) : null,
),
],
),
);
}
bool get _isLifetimeOnly => !IAPProducts.of(context).hasSubscriptions;
void _selectProduct(IAPProduct product) {
setState(() {
selected = product;
});
}
}
class _Products extends StatelessWidget {
const _Products({
this.monthly,
this.yearly,
this.lifetime,
required this.selected,
required this.onProductSelected,
});
final IAPProduct? monthly;
final IAPProduct? yearly;
final IAPProduct? lifetime;
final IAPProduct? selected;
final ValueChanged<IAPProduct> onProductSelected;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (monthly case final monthly?)
Padding(
padding: const EdgeInsets.only(bottom: Dimens.paddingS),
child: _ProductItem(
title: S.of(context).monthly,
price: S.of(context).pricePerMonth(monthly.price),
isSelected: selected == monthly,
onPressed: () => onProductSelected(monthly),
),
),
if (yearly case final yearly?)
Padding(
padding: const EdgeInsets.only(bottom: Dimens.paddingS),
child: _ProductItem(
title: S.of(context).yearly,
price: S.of(context).pricePerYear(yearly.price),
isSelected: selected == yearly,
onPressed: () => onProductSelected(yearly),
),
),
if (lifetime case final lifetime?)
_ProductItem(
title: S.of(context).lifetime,
price: lifetime.price,
isSelected: selected == lifetime,
onPressed: () => onProductSelected(lifetime),
),
],
);
}
}
class _ProductItem extends StatelessWidget {
const _ProductItem({
required this.title,
required this.price,
required this.isSelected,
required this.onPressed,
});
final String title;
final String price;
final bool isSelected;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Material(
color:
isSelected ? Theme.of(context).colorScheme.primaryContainer : Theme.of(context).colorScheme.surfaceElevated2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
side: isSelected
? BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: BorderSide.none,
),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onPressed,
child: Padding(
/// [Radio] has 12pt paddings around the button.
/// [Dimens.paddingM] - 12pt = 4pt
padding: const EdgeInsets.fromLTRB(
Dimens.grid4,
Dimens.grid4,
Dimens.paddingM,
Dimens.grid4,
),
child: Row(
children: [
Radio(
value: isSelected,
groupValue: true,
onChanged: (_) => onPressed(),
),
_ProductAnimatedText(
title,
isSelected: isSelected,
),
const Spacer(),
_ProductAnimatedText(
price,
isSelected: isSelected,
),
],
),
),
),
);
}
}
class _ProductAnimatedText extends StatelessWidget {
const _ProductAnimatedText(this.text, {required this.isSelected});
final String text;
final bool isSelected;
@override
Widget build(BuildContext context) {
return AnimatedDefaultTextStyle(
duration: Dimens.durationM,
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w500,
color:
isSelected ? Theme.of(context).colorScheme.onPrimaryContainer : Theme.of(context).colorScheme.onSurface,
),
child: Text(text),
);
}
}

View file

@ -1,27 +1,57 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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 {
typedef PurchasesState = ({bool isPurchasingProduct, bool isRestoringPurchases});
class LightmeterProScreen extends StatefulWidget {
const LightmeterProScreen({super.key});
@override
State<LightmeterProScreen> createState() => _LightmeterProScreenState();
}
class _LightmeterProScreenState extends State<LightmeterProScreen> {
final features =
defaultTargetPlatform == TargetPlatform.android ? AppFeature.androidFeatures : AppFeature.iosFeatures;
LightmeterProScreen({super.key});
final _purchasesNotifier = ValueNotifier<PurchasesState>(
(
isPurchasingProduct: false,
isRestoringPurchases: false,
),
);
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: SliverScreen(
return SliverScreen(
title: Text(S.of(context).proFeaturesTitle),
appBarActions: [
ValueListenableBuilder(
valueListenable: _purchasesNotifier,
builder: (context, value, _) {
if (value.isRestoringPurchases) {
return const SizedBox.square(
dimension: Dimens.grid24 - Dimens.grid4,
child: CircularProgressIndicator(),
);
} else {
return IconButton(
onPressed: value.isPurchasingProduct ? null : _restorePurchases,
icon: const Icon(Icons.restore),
tooltip: S.of(context).restorePurchases,
);
}
},
),
],
slivers: [
SliverToBoxAdapter(
child: Padding(
@ -62,30 +92,65 @@ 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)),
),
),
],
bottomNavigationBar: ValueListenableBuilder(
valueListenable: _purchasesNotifier,
builder: (context, value, _) {
return LightmeterProOffering(
isEnabled: !value.isRestoringPurchases && !value.isPurchasingProduct,
onBuyProduct: _buyPro,
);
},
),
);
}
@override
void dispose() {
_purchasesNotifier.dispose();
super.dispose();
}
Future<void> _restorePurchases() async {
_purchasesNotifier.isRestoringPurchases = true;
try {
final isPro = await IAPProductsProvider.of(context).restorePurchases();
if (mounted && isPro) {
Navigator.of(context).pop();
}
} on PlatformException catch (e) {
_showSnackbar(e.message ?? '');
} catch (e) {
_showSnackbar(e.toString());
} finally {
_purchasesNotifier.isRestoringPurchases = false;
}
}
Future<void> _buyPro(IAPProduct product) async {
_purchasesNotifier.isPurchasingProduct = true;
try {
final isPro = await IAPProductsProvider.of(context).buyPro(product);
if (mounted && isPro) {
Navigator.of(context).pop();
}
} on PlatformException catch (e) {
_showSnackbar(e.message ?? '');
} catch (e) {
_showSnackbar(e.toString());
} finally {
_purchasesNotifier.isPurchasingProduct = false;
}
}
void _showSnackbar(String message) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
@ -227,3 +292,13 @@ class _CheckBox extends StatelessWidget {
);
}
}
extension on ValueNotifier<PurchasesState> {
set isPurchasingProduct(bool isPurchasingProduct) {
value = (isPurchasingProduct: isPurchasingProduct, isRestoringPurchases: value.isRestoringPurchases);
}
set isRestoringPurchases(bool isRestoringPurchases) {
value = (isPurchasingProduct: value.isPurchasingProduct, isRestoringPurchases: isRestoringPurchases);
}
}

View file

@ -128,7 +128,7 @@ class AnimatedDialogState extends State<AnimatedDialog> with SingleTickerProvide
@override
Widget build(BuildContext context) {
return InkWell(
return GestureDetector(
key: _key,
onTap: _openDialog,
child: Opacity(

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
? () {
onTap: () {
Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name);
}
: null,
trailing: isPending
? const SizedBox(
height: Dimens.grid24,
width: Dimens.grid24,
child: CircularProgressIndicator(),
)
: null,
},
);
}
}

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

@ -9,6 +9,7 @@ import 'package:lightmeter/screens/settings/components/theme/widget_settings_sec
import 'package:lightmeter/screens/settings/flow_settings.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
import 'package:lightmeter/utils/context_utils.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@ -22,6 +23,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
void didChangeDependencies() {
super.didChangeDependencies();
SettingsInteractorProvider.of(context).disableVolumeHandling();
IAPProductsProvider.maybeOf(context)?.checkIsPro();
}
@override

View file

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
class FilledButtonLarge extends StatelessWidget {
const FilledButtonLarge({
required this.title,
required this.onPressed,
});
final String title;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return FilledButton(
style: Theme.of(context)
.filledButtonTheme
.style!
.copyWith(textStyle: WidgetStatePropertyAll(Theme.of(context).textTheme.titleMedium)),
onPressed: onPressed,
child: Text(S.of(context).continuePurchase),
);
}
}

View file

@ -8,12 +8,14 @@ class SliverScreen extends StatelessWidget {
final List<Widget> appBarActions;
final PreferredSizeWidget? bottom;
final List<Widget> slivers;
final Widget? bottomNavigationBar;
const SliverScreen({
this.title,
this.appBarActions = const [],
this.bottom,
required this.slivers,
this.bottomNavigationBar,
super.key,
});
@ -23,6 +25,9 @@ class SliverScreen extends StatelessWidget {
body: SafeArea(
top: false,
bottom: false,
child: Column(
children: [
Expanded(
child: CustomScrollView(
slivers: <Widget>[
_AppBar(
@ -34,6 +39,10 @@ class SliverScreen extends StatelessWidget {
],
),
),
if (bottomNavigationBar != null) bottomNavigationBar!,
],
),
),
);
}
}

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

View file

@ -623,6 +623,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
frontend_server_client:
dependency: transitive
description:
@ -756,38 +764,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.3.0"
in_app_purchase:
dependency: transitive
description:
name: in_app_purchase
sha256: "960f26a08d9351fb8f89f08901f8a829d41b04d45a694b8f776121d9e41dcad6"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
in_app_purchase_android:
dependency: transitive
description:
name: in_app_purchase_android
sha256: b7cc194f183a97d71e6da1edb4a4095466ca0c22533615ecad021bdf95a5ecdc
url: "https://pub.dev"
source: hosted
version: "0.3.6+13"
in_app_purchase_platform_interface:
dependency: transitive
description:
name: in_app_purchase_platform_interface
sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
in_app_purchase_storekit:
dependency: transitive
description:
name: in_app_purchase_storekit
sha256: "6ce1361278cacc0481508989ba419b2c9f46a2b0dc54b3fe54f5ee63c2718fef"
url: "https://pub.dev"
source: hosted
version: "0.3.22+1"
integration_test:
dependency: "direct dev"
description: flutter
@ -885,11 +861,11 @@ packages:
dependency: "direct main"
description:
path: "."
ref: HEAD
resolved-ref: "32e053b7d14009c6d61daf56f1556de3365297cb"
ref: "v4.0.0"
resolved-ref: e951b5349fb8d1ff8fe476a9f6bd42f20e07f4e9
url: "https://github.com/vodemn/m3_lightmeter_iap"
source: git
version: "2.2.0+31"
version: "4.0.0+33"
m3_lightmeter_resources:
dependency: "direct main"
description:
@ -1163,6 +1139,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
purchases_flutter:
dependency: transitive
description:
name: purchases_flutter
sha256: "0d64050d8620740c67cbcd8697bd7447bfb39ec0b04c09dc12977b6ec4a2ad50"
url: "https://pub.dev"
source: hosted
version: "9.0.0-beta.2"
shared_preferences:
dependency: "direct main"
description:

View file

@ -33,7 +33,7 @@ dependencies:
m3_lightmeter_iap:
git:
url: "https://github.com/vodemn/m3_lightmeter_iap"
branch: v3.0.0
ref: v4.0.0
m3_lightmeter_resources:
git:
url: "https://github.com/vodemn/m3_lightmeter_resources"

View file

@ -57,11 +57,11 @@ class WidgetTestApplicationMock extends StatelessWidget {
}
class GoldenTestApplicationMock extends StatefulWidget {
final IAPProductStatus productStatus;
final bool isPro;
final Widget child;
const GoldenTestApplicationMock({
this.productStatus = IAPProductStatus.purchased,
this.isPro = true,
required this.child,
super.key,
});
@ -100,13 +100,22 @@ class _GoldenTestApplicationMockState extends State<GoldenTestApplicationMock> {
@override
Widget build(BuildContext context) {
return IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: widget.productStatus,
isPro: widget.isPro,
lifetime: const IAPProduct(
storeId: '',
type: PurchaseType.lifetime,
price: '0.0\$',
),
yearly: const IAPProduct(
storeId: '',
type: PurchaseType.yearly,
price: '0.0\$',
),
monthly: const IAPProduct(
storeId: '',
type: PurchaseType.monthly,
price: '0.0\$',
),
],
child: _MockApplicationWrapper(
child: MockIAPProviders(
selectedEquipmentProfileId: mockEquipmentProfiles.first.id,

View file

@ -33,16 +33,10 @@ void main() {
reset(storageService);
});
Future<void> pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async {
Future<void> pumpTestWidget(WidgetTester tester, bool isPro) async {
await tester.pumpWidget(
IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: productStatus,
price: '0.0\$',
),
],
isPro: isPro,
child: EquipmentProfilesProvider(
storageService: storageService,
child: const _Application(),
@ -69,31 +63,23 @@ void main() {
() {
setUp(() {
when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id);
when(() => storageService.getEquipmentProfiles()).thenAnswer((_) => Future.value(_customProfiles.toTogglableMap()));
when(() => storageService.getEquipmentProfiles())
.thenAnswer((_) => Future.value(_customProfiles.toTogglableMap()));
});
testWidgets(
'IAPProductStatus.purchased - show all saved profiles',
'Pro - show all saved profiles',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectEquipmentProfilesCount(_customProfiles.length + 1);
expectSelectedEquipmentProfileName(_customProfiles.first.name);
},
);
testWidgets(
'IAPProductStatus.purchasable - show only default',
'Not Pro - show only default',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchasable);
expectEquipmentProfilesCount(1);
expectSelectedEquipmentProfileName('');
},
);
testWidgets(
'IAPProductStatus.pending - show only default',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.pending);
await pumpTestWidget(tester, false);
expectEquipmentProfilesCount(1);
expectSelectedEquipmentProfileName('');
},
@ -105,7 +91,7 @@ void main() {
'toggleProfile',
(tester) async {
when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id);
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectEquipmentProfilesCount(_customProfiles.length + 1);
expectEquipmentProfilesInUseCount(_customProfiles.length + 1);
expectSelectedEquipmentProfileName(_customProfiles.first.name);
@ -127,7 +113,7 @@ void main() {
when(() => storageService.getEquipmentProfiles()).thenAnswer((_) async => {});
when(() => storageService.selectedEquipmentProfileId).thenReturn('');
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectEquipmentProfilesCount(1);
expectSelectedEquipmentProfileName('');

View file

@ -30,16 +30,10 @@ void main() {
reset(storageService);
});
Future<void> pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async {
Future<void> pumpTestWidget(WidgetTester tester, bool isPro) async {
await tester.pumpWidget(
IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: productStatus,
price: '0.0\$',
),
],
isPro: isPro,
child: FilmsProvider(
storageService: storageService,
child: const _Application(),
@ -76,9 +70,9 @@ void main() {
});
testWidgets(
'IAPProductStatus.purchased - show all saved films',
'Pro - show all saved films',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(mockCustomFilms.length);
expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1);
@ -87,20 +81,9 @@ void main() {
);
testWidgets(
'IAPProductStatus.purchasable - show only default',
'Not Pro - show only default',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchasable);
expectPredefinedFilmsCount(0);
expectCustomFilmsCount(0);
expectFilmsInUseCount(1);
expectSelectedFilmName('');
},
);
testWidgets(
'IAPProductStatus.pending - show only default',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.pending);
await pumpTestWidget(tester, false);
expectPredefinedFilmsCount(0);
expectCustomFilmsCount(0);
expectFilmsInUseCount(1);
@ -117,7 +100,7 @@ void main() {
'toggle predefined film',
(tester) async {
when(() => storageService.selectedFilmId).thenReturn(mockPredefinedFilms.first.id);
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(mockCustomFilms.length);
expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1);
@ -139,7 +122,7 @@ void main() {
'toggle custom film',
(tester) async {
when(() => storageService.selectedFilmId).thenReturn(mockCustomFilms.first.id);
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(mockCustomFilms.length);
expectFilmsInUseCount(mockPredefinedFilms.length + mockCustomFilms.length + 1);
@ -163,7 +146,7 @@ void main() {
'selectFilm',
(tester) async {
when(() => storageService.selectedFilmId).thenReturn('');
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectSelectedFilmName('');
tester.filmsProvider.selectFilm(mockPredefinedFilms.first);
@ -184,7 +167,7 @@ void main() {
(tester) async {
when(() => storageService.selectedFilmId).thenReturn('');
when(() => storageService.getCustomFilms()).thenAnswer((_) => Future.value({}));
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectPredefinedFilmsCount(mockPredefinedFilms.length);
expectCustomFilmsCount(0);
expectFilmsInUseCount(mockPredefinedFilms.length + 0 + 1);

View file

@ -43,16 +43,10 @@ void main() {
reset(storageService);
});
Future<void> pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async {
Future<void> pumpTestWidget(WidgetTester tester, bool isPro) async {
await tester.pumpWidget(
IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: productStatus,
price: '0.0\$',
),
],
isPro: isPro,
child: LogbookPhotosProvider(
storageService: storageService,
geolocationService: geolocationService,
@ -79,27 +73,18 @@ void main() {
});
testWidgets(
'IAPProductStatus.purchased - show all saved photos',
'Pro - show all saved photos',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectLogbookPhotosCount(_customPhotos.length);
expectLogbookPhotosEnabled(true);
},
);
testWidgets(
'IAPProductStatus.purchasable - show empty list',
'Not Pro - show empty list',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.purchasable);
expectLogbookPhotosCount(0);
expectLogbookPhotosEnabled(true);
},
);
testWidgets(
'IAPProductStatus.pending - show empty list',
(tester) async {
await pumpTestWidget(tester, IAPProductStatus.pending);
await pumpTestWidget(tester, false);
expectLogbookPhotosCount(0);
expectLogbookPhotosEnabled(true);
},
@ -111,7 +96,7 @@ void main() {
'saveLogbookPhotos',
(tester) async {
when(() => storageService.getPhotos()).thenAnswer((_) => Future.value(_customPhotos));
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectLogbookPhotosCount(_customPhotos.length);
expectLogbookPhotosEnabled(true);
@ -132,7 +117,7 @@ void main() {
(tester) async {
when(() => storageService.getPhotos()).thenAnswer((_) async => []);
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
expectLogbookPhotosCount(0);
expectLogbookPhotosEnabled(true);
@ -168,7 +153,7 @@ void main() {
'addPhotoIfPossible when disabled',
(tester) async {
when(() => storageService.getPhotos()).thenAnswer((_) async => []);
await pumpTestWidget(tester, IAPProductStatus.purchased);
await pumpTestWidget(tester, true);
// Disable logbook photos
tester.logbookPhotosProvider.saveLogbookPhotos(false);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 298 KiB

View file

@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../application_mock.dart';
@ -41,8 +40,8 @@ class _MockLightmeterProFlow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GoldenTestApplicationMock(
productStatus: IAPProductStatus.purchasable,
return const GoldenTestApplicationMock(
isPro: false,
child: LightmeterProScreen(),
);
}

View file

@ -26,13 +26,7 @@ void main() {
Future<void> pumpApplication(WidgetTester tester) async {
await tester.pumpWidget(
IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$',
),
],
isPro: true,
child: EquipmentProfilesProvider(
storageService: storageService,
child: WidgetTestApplicationMock(

View file

@ -28,13 +28,7 @@ void main() {
Future<void> pumpApplication(WidgetTester tester) async {
await tester.pumpWidget(
IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$',
),
],
isPro: true,
child: FilmsProvider(
storageService: mockFilmsStorageService,
child: const WidgetTestApplicationMock(

View file

@ -9,7 +9,6 @@ import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/metering/flow_metering.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../integration_test/utils/finder_actions.dart';
@ -18,27 +17,27 @@ import '../../application_mock.dart';
import '../../utils/golden_test_set_theme.dart';
class _MeteringScreenConfig {
final IAPProductStatus iapProductStatus;
final bool isPro;
final EvSourceType evSourceType;
_MeteringScreenConfig(
this.iapProductStatus,
this.isPro,
this.evSourceType,
);
@override
String toString() {
final buffer = StringBuffer();
buffer.write(iapProductStatus.toString().split('.')[1]);
buffer.write(isPro ? 'purchased' : 'purchasable');
buffer.write(' - ');
buffer.write(evSourceType.toString().split('.')[1]);
return buffer.toString();
}
}
final _testScenarios = [IAPProductStatus.purchased, IAPProductStatus.purchasable].expand(
(iapProductStatus) => EvSourceType.values.map(
(evSourceType) => _MeteringScreenConfig(iapProductStatus, evSourceType),
final _testScenarios = [true, false].expand(
(isPro) => EvSourceType.values.map(
(evSourceType) => _MeteringScreenConfig(isPro, evSourceType),
),
);
@ -97,7 +96,7 @@ void main() {
for (final scenario in _testScenarios) {
builder.addScenario(
name: scenario.toString(),
widget: _MockMeteringFlow(productStatus: scenario.iapProductStatus),
widget: _MockMeteringFlow(isPro: scenario.isPro),
onCreate: (scenarioWidgetKey) async {
await setEvSource(tester, scenarioWidgetKey, scenario.evSourceType);
if (scenarioWidgetKey.toString().contains('Dark')) {
@ -121,14 +120,14 @@ void main() {
}
class _MockMeteringFlow extends StatelessWidget {
final IAPProductStatus productStatus;
final bool isPro;
const _MockMeteringFlow({required this.productStatus});
const _MockMeteringFlow({required this.isPro});
@override
Widget build(BuildContext context) {
return GoldenTestApplicationMock(
productStatus: productStatus,
isPro: isPro,
child: const MeteringFlow(),
);
}

View file

@ -36,13 +36,7 @@ void main() {
Future<void> pumpTestWidget(WidgetTester tester) async {
await tester.pumpWidget(
IAPProducts(
products: [
IAPProduct(
storeId: IAPProductType.paidFeatures.storeId,
status: IAPProductStatus.purchased,
price: '0.0\$',
),
],
isPro: true,
child: EquipmentProfilesProvider(
storageService: storageService,
child: MaterialApp(

View file

@ -8,7 +8,6 @@ import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/screens/settings/flow_settings.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -16,21 +15,19 @@ import '../../application_mock.dart';
import '../../utils/golden_test_set_theme.dart';
class _SettingsScreenConfig {
final IAPProductStatus iapProductStatus;
final bool isPro;
_SettingsScreenConfig(this.iapProductStatus);
_SettingsScreenConfig(this.isPro);
@override
String toString() {
final buffer = StringBuffer();
buffer.write(iapProductStatus.toString().split('.')[1]);
buffer.write(isPro ? 'purchased' : 'purchasable');
return buffer.toString();
}
}
final _testScenarios = [IAPProductStatus.purchased, IAPProductStatus.purchasable].map(
(iapProductStatus) => _SettingsScreenConfig(iapProductStatus),
);
final _testScenarios = [true, false].map((isPro) => _SettingsScreenConfig(isPro));
void main() {
setUpAll(() {
@ -60,7 +57,7 @@ void main() {
for (final scenario in _testScenarios) {
builder.addScenario(
name: scenario.toString(),
widget: _MockSettingsFlow(productStatus: scenario.iapProductStatus),
widget: _MockSettingsFlow(isPro: scenario.isPro),
onCreate: (scenarioWidgetKey) async {
if (scenarioWidgetKey.toString().contains('Dark')) {
await setTheme<SettingsFlow>(tester, scenarioWidgetKey, ThemeType.dark);
@ -78,14 +75,14 @@ void main() {
}
class _MockSettingsFlow extends StatelessWidget {
final IAPProductStatus productStatus;
final bool isPro;
const _MockSettingsFlow({required this.productStatus});
const _MockSettingsFlow({required this.isPro});
@override
Widget build(BuildContext context) {
return GoldenTestApplicationMock(
productStatus: productStatus,
isPro: isPro,
child: const SettingsFlow(),
);
}