m3_lightmeter/lib/screens/lightmeter_pro/screen_lightmeter_pro.dart
Vadim f9246e15d6
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
2025-08-09 17:22:34 +02:00

304 lines
9 KiB
Dart

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/res/dimens.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';
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;
final _purchasesNotifier = ValueNotifier<PurchasesState>(
(
isPurchasingProduct: false,
isRestoringPurchases: false,
),
);
@override
Widget build(BuildContext context) {
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(
padding: const EdgeInsets.all(Dimens.paddingM),
child: Text(
S.of(context).proFeaturesPromoText,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Dimens.paddingM,
0,
Dimens.paddingM,
Dimens.paddingS,
),
child: Text(
S.of(context).proFeaturesWhatsIncluded,
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
const SliverToBoxAdapter(child: _FeaturesHeader()),
SliverList.separated(
itemCount: features.length,
itemBuilder: (_, index) => _FeatureItem(feature: features[index]),
separatorBuilder: (_, __) => const Padding(
padding: EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Divider(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(Dimens.paddingM),
child: Text(S.of(context).proFeaturesSupportText),
),
),
],
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,
),
);
}
}
}
class _FeaturesHeader extends StatelessWidget {
const _FeaturesHeader();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Row(
children: [
const Spacer(),
_FeatureHighlight(child: Text(S.of(context).featuresFree)),
_FeatureHighlight(
roundedTop: true,
highlight: true,
child: Text(
S.of(context).featuresPro,
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(color: Theme.of(context).colorScheme.onSecondaryContainer),
),
),
],
),
);
}
}
class _FeatureItem extends StatelessWidget {
final AppFeature feature;
const _FeatureItem({
required this.feature,
});
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: Dimens.grid48),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingM,
vertical: Dimens.paddingS,
),
child: Text(
feature.name(context),
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
),
Opacity(
opacity: feature.isFree ? 1 : 0,
child: const _FeatureHighlight(
child: _CheckBox(highlight: false),
),
),
_FeatureHighlight(
highlight: true,
roundedBottom: feature == AppFeature.values.last,
child: const _CheckBox(highlight: true),
),
const SizedBox(width: Dimens.grid16),
],
),
),
);
}
}
class _FeatureHighlight extends StatelessWidget {
final bool highlight;
final bool roundedTop;
final bool roundedBottom;
final Widget child;
const _FeatureHighlight({
this.highlight = false,
this.roundedTop = false,
this.roundedBottom = false,
required this.child,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
minWidth: textSize(
highlight ? S.of(context).featuresPro : S.of(context).featuresFree,
Theme.of(context).textTheme.bodyMedium,
MediaQuery.sizeOf(context).width,
).width +
Dimens.paddingM * 2,
),
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingM,
vertical: Dimens.paddingS,
),
decoration: BoxDecoration(
color: highlight ? Theme.of(context).colorScheme.secondaryContainer : null,
borderRadius: roundedTop
? const BorderRadius.only(
topLeft: Radius.circular(Dimens.borderRadiusM),
topRight: Radius.circular(Dimens.borderRadiusM),
)
: roundedBottom
? const BorderRadius.only(
bottomLeft: Radius.circular(Dimens.borderRadiusM),
bottomRight: Radius.circular(Dimens.borderRadiusM),
)
: null,
),
child: child,
);
}
}
class _CheckBox extends StatelessWidget {
final bool highlight;
const _CheckBox({required this.highlight});
@override
Widget build(BuildContext context) {
return Icon(
Icons.check_outlined,
color: highlight ? Theme.of(context).colorScheme.onSecondaryContainer : null,
);
}
}
extension on ValueNotifier<PurchasesState> {
set isPurchasingProduct(bool isPurchasingProduct) {
value = (isPurchasingProduct: isPurchasingProduct, isRestoringPurchases: value.isRestoringPurchases);
}
set isRestoringPurchases(bool isRestoringPurchases) {
value = (isPurchasingProduct: value.isPurchasingProduct, isRestoringPurchases: isRestoringPurchases);
}
}