mirror of
https://github.com/vodemn/m3_lightmeter.git
synced 2025-08-17 18:46:47 +00:00
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:
parent
7ad47c0636
commit
f9246e15d6
36 changed files with 645 additions and 347 deletions
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -166,5 +166,18 @@
|
|||
"location": "Местоположение",
|
||||
"noMapsAppFound": "Приложение карт не найдено.",
|
||||
"logbook": "Фотожурнал",
|
||||
"noPhotos": "Нет фотографий"
|
||||
"noPhotos": "Нет фотографий",
|
||||
"continuePurchase": "Продолжить",
|
||||
"monthly": "Ежемесячно",
|
||||
"yearly": "Ежегодно",
|
||||
"lifetime": "Навсегда",
|
||||
"pricePerMonth": "{price}/месяц",
|
||||
"pricePerYear": "{price}/год",
|
||||
"@pricePerMonth": {
|
||||
"placeholders": {
|
||||
"price": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -164,5 +164,18 @@
|
|||
"location": "位置",
|
||||
"noMapsAppFound": "未找到地图应用程序。",
|
||||
"logbook": "拍照日志",
|
||||
"noPhotos": "没有照片"
|
||||
"noPhotos": "没有照片",
|
||||
"continuePurchase": "继续",
|
||||
"monthly": "月付",
|
||||
"yearly": "年付",
|
||||
"lifetime": "永久",
|
||||
"pricePerMonth": "{price}/月",
|
||||
"pricePerYear": "{price}/年",
|
||||
"@pricePerMonth": {
|
||||
"placeholders": {
|
||||
"price": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
24
lib/screens/shared/button/widget_button_filled_large.dart
Normal file
24
lib/screens/shared/button/widget_button_filled_large.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
54
pubspec.lock
54
pubspec.lock
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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('');
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 |
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue