ML-248 Make Pro features list tiles enabled (#250)

* open Pro screen on tap

* removed Pro section from Settings screen

* made Pro badge smaller

* updated goldens

* fixed offering padding

* Update metering_screen.png

* fixed pro features width

* brought back enabled adapter

* reworked `DialogSwitchListItem`

* allow more free actions

* typo

* removed settings screen checks

* macos-latest

* typo

* added test for guard Pro tap
This commit is contained in:
Vadim 2025-08-12 13:50:56 +02:00 committed by GitHub
parent 3be2b9b715
commit 09e82c8bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 357 additions and 319 deletions

View file

@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/equipment_profile_edit/screen_equipment_profile_edit.dart';
import 'package:lightmeter/screens/lightmeter_pro/screen_lightmeter_pro.dart';
import 'package:lightmeter/screens/logbook_photos/screen_logbook_photos.dart';
import 'package:lightmeter/screens/settings/screen_settings.dart';
import 'package:lightmeter/utils/platform_utils.dart';
import 'package:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../integration_test/utils/widget_tester_actions.dart';
import 'mocks/iap_products_mock.dart';
@isTest
void testGuardProTap(String description) {
testWidgets(
description,
(tester) async {
SharedPreferences.setMockInitialValues({
/// Metering values
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
UserPreferencesService.meteringScreenLayoutKey: json.encode(
{
MeteringScreenLayoutFeature.equipmentProfiles: true,
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
}.toJson(),
),
UserPreferencesService.seenChangelogVersionKey: await const PlatformUtils().version,
});
await tester.pumpApplication(isPro: false);
await tester.openSettings();
await tester.tapDescendantTextOf<SettingsScreen>(S.current.equipmentProfiles);
/// Try adding a new equipment profile
await tester.tap(find.byIcon(Icons.add_outlined).first);
await tester.pumpAndSettle();
expect(find.byType(LightmeterProScreen), findsOneWidget);
/// Purchase Pro
(tester.state(find.byType(MockIAPProductsProvider)) as MockIAPProductsProviderState).buy();
await tester.navigatorPop(true);
await tester.pumpAndSettle();
expect(find.byType(LightmeterProScreen), findsNothing);
expect(find.byType(EquipmentProfileEditScreen), findsOneWidget);
await tester.navigatorPop();
await tester.navigatorPop();
/// Refund
(tester.state(find.byType(MockIAPProductsProvider)) as MockIAPProductsProviderState).clearPurchases();
await tester.pumpAndSettle();
await tester.tapDescendantTextOf<SettingsScreen>(S.current.logbook);
/// Try enabling logbook
await tester.tap(find.text(S.current.saveNewPhotos));
await tester.pumpAndSettle();
expect(find.byType(LightmeterProScreen), findsOneWidget);
(tester.state(find.byType(MockIAPProductsProvider)) as MockIAPProductsProviderState).buy();
await tester.navigatorPop(true);
await tester.pumpAndSettle();
expect(find.byType(LightmeterProScreen), findsNothing);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(LogbookPhotosScreen),
matching: find.byWidgetPredicate((widget) => widget is SwitchListTile && widget.value),
),
findsOneWidget,
);
},
);
}

View file

@ -5,15 +5,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/lightmeter_pro_badge/widget_badge_lightmeter_pro.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart';
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:meta/meta.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -47,19 +44,10 @@ void testPurchases(String description) {
/// Expect the bare minimum free functionallity
_expectProMeteringScreen(enabled: false);
/// Check, that premium settings are disabled
await tester.openSettings();
await _expectProSettingsScreen(tester, enabled: false);
/// Make purchase
(tester.state(find.byType(MockIAPProductsProvider)) as MockIAPProductsProviderState).buy();
await tester.pumpAndSettle();
/// Check, that premium settings are enabled
await _expectProSettingsScreen(tester, enabled: true);
/// Expect, that all the premium controls are now available to user
await tester.navigatorPop();
_expectProMeteringScreen(enabled: true);
/// Refund
@ -68,19 +56,15 @@ void testPurchases(String description) {
/// Expect the bare minimum free functionallity
_expectProMeteringScreen(enabled: false);
/// Check, that premium settings are disabled
await tester.openSettings();
await _expectProSettingsScreen(tester, enabled: false);
},
);
}
void _expectProMeteringScreen({required bool enabled}) {
expect(find.byType(LightmeterProAnimatedDialog), !enabled ? findsOneWidget : findsNothing);
expect(find.byType(EquipmentProfilePicker), enabled ? findsOneWidget : findsNothing);
expect(find.byType(LightmeterProBadge), !enabled ? findsOneWidget : findsNothing);
expect(find.byType(EquipmentProfilePicker), findsOneWidget);
expect(find.byType(ExtremeExposurePairsContainer), findsOneWidget);
expect(find.byType(FilmPicker), enabled ? findsOneWidget : findsNothing);
expect(find.byType(FilmPicker), findsOneWidget);
expect(find.byType(IsoValuePicker), findsOneWidget);
expect(find.byType(NdValuePicker), findsOneWidget);
expect(
@ -88,25 +72,6 @@ void _expectProMeteringScreen({required bool enabled}) {
of: find.measureButton(),
matching: find.byWidgetPredicate((widget) => widget is Text && widget.data!.contains('\u2081\u2080\u2080')),
),
enabled ? findsOneWidget : findsNothing,
findsOneWidget,
);
}
Future<void> _expectProSettingsScreen(WidgetTester tester, {required bool enabled}) async {
void expectDisabled(String title, bool disabled) {
find.ancestor(
of: find.text(title),
matching: find.byWidgetPredicate((widget) => widget is Disable && widget.disable == disabled),
);
}
expectDisabled(S.current.showEv100, !enabled);
expectDisabled(S.current.equipmentProfiles, !enabled);
expectDisabled(S.current.filmsInUse, !enabled);
expectDisabled(S.current.cameraFeatures, !enabled);
await tester.tapDescendantTextOf<SettingsScreen>(S.current.meteringScreenLayout);
expectDisabled(S.current.meteringScreenLayoutHintEquipmentProfiles, !enabled);
expectDisabled(S.current.meteringScreenFeatureExtremeExposurePairs, false); // must be always enabled
expectDisabled(S.current.meteringScreenFeatureFilmPicker, !enabled);
await tester.tapCancelButton();
}

View file

@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'e2e_test.dart';
import 'guard_pro_tap_test.dart';
import 'logbook_test.dart';
import 'metering_screen_layout_test.dart';
import 'purchases_test.dart';
@ -19,6 +20,7 @@ void main() {
});
testPurchases('Purchase & refund premium features');
testGuardProTap('Guard Pro tap');
testToggleLayoutFeatures('Toggle metering screen layout features');
testLogbook('Logbook');
testE2E('e2e');

View file

@ -68,8 +68,8 @@ extension WidgetTesterCommonActions on WidgetTester {
await pumpAndSettle();
}
Future<void> navigatorPop() async {
(state(find.byType(Navigator)) as NavigatorState).pop();
Future<void> navigatorPop([Object? result]) async {
(state(find.byType(Navigator)) as NavigatorState).pop(result);
await pumpAndSettle(Dimens.durationML);
}
}

View file

@ -26,7 +26,7 @@ Future<void> runLightmeterApp(Environment env) async {
runApp(
env.buildType == BuildType.dev
? IAPProducts(
isPro: true,
isPro: false,
child: application,
)
: IAPProductsProvider(child: application),

View file

@ -6,6 +6,7 @@ import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/equipment_profile_edit/flow_equipment_profile_edit.dart';
import 'package:lightmeter/screens/shared/sliver_placeholder/widget_sliver_placeholder.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
import 'package:lightmeter/utils/guard_pro_tap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class EquipmentProfilesScreen extends StatefulWidget {
@ -41,9 +42,14 @@ class _EquipmentProfilesScreenState extends State<EquipmentProfilesScreen> with
}
void _addProfile() {
Navigator.of(context).pushNamed(
NavigationRoutes.equipmentProfileEditScreen.name,
arguments: const EquipmentProfileEditArgs(editType: EquipmentProfileEditType.add),
guardProTap(
context,
() {
Navigator.of(context).pushNamed(
NavigationRoutes.equipmentProfileEditScreen.name,
arguments: const EquipmentProfileEditArgs(editType: EquipmentProfileEditType.add),
);
},
);
}

View file

@ -107,24 +107,18 @@ class _Products extends StatelessWidget {
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),
),
_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),
),
_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(
@ -133,7 +127,7 @@ class _Products extends StatelessWidget {
isSelected: selected == lifetime,
onPressed: () => onProductSelected(lifetime),
),
],
].intersperse(const SizedBox(height: Dimens.grid8)).toList(growable: false),
);
}
}
@ -220,3 +214,16 @@ class _ProductAnimatedText extends StatelessWidget {
);
}
}
extension on List<Widget> {
Iterable<Widget> intersperse(Widget element) sync* {
final iterator = this.iterator;
if (iterator.moveNext()) {
yield iterator.current;
while (iterator.moveNext()) {
yield element;
yield iterator.current;
}
}
}
}

View file

@ -115,7 +115,7 @@ class _LightmeterProScreenState extends State<LightmeterProScreen> {
try {
final isPro = await IAPProductsProvider.of(context).restorePurchases();
if (mounted && isPro) {
Navigator.of(context).pop();
Navigator.of(context).pop(true);
}
} on PlatformException catch (e) {
_showSnackbar(e.message ?? '');
@ -131,7 +131,7 @@ class _LightmeterProScreenState extends State<LightmeterProScreen> {
try {
final isPro = await IAPProductsProvider.of(context).buyPro(product);
if (mounted && isPro) {
Navigator.of(context).pop();
Navigator.of(context).pop(true);
}
} on PlatformException catch (e) {
_showSnackbar(e.message ?? '');
@ -256,10 +256,7 @@ class _FeatureHighlight extends StatelessWidget {
).width +
Dimens.paddingM * 2,
),
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingM,
vertical: Dimens.paddingS,
),
padding: const EdgeInsets.symmetric(vertical: Dimens.paddingS),
decoration: BoxDecoration(
color: highlight ? Theme.of(context).colorScheme.secondaryContainer : null,
borderRadius: roundedTop
@ -274,7 +271,7 @@ class _FeatureHighlight extends StatelessWidget {
)
: null,
),
child: child,
child: Center(child: child),
);
}
}

View file

@ -7,6 +7,8 @@ import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/logbook_photos/components/grid_tile/widget_grid_tile_logbook_photo.dart';
import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
import 'package:lightmeter/utils/context_utils.dart';
import 'package:lightmeter/utils/guard_pro_tap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class LogbookPhotosScreen extends StatefulWidget {
@ -30,8 +32,15 @@ class _LogbookPhotosScreenState extends State<LogbookPhotosScreen> with SingleTi
child: SwitchListTile(
secondary: const Icon(Icons.book_outlined),
title: Text(S.of(context).saveNewPhotos),
value: LogbookPhotos.isEnabledOf(context),
onChanged: LogbookPhotosProvider.of(context).saveLogbookPhotos,
value: LogbookPhotos.isEnabledOf(context) && context.isPro,
onChanged: (value) {
guardProTap(
context,
() {
LogbookPhotosProvider.of(context).saveLogbookPhotos(value);
},
);
},
),
),
),

View file

@ -4,7 +4,6 @@ import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart';
import 'package:lightmeter/screens/shared/bottom_controls_bar/widget_bottom_controls_bar.dart';
import 'package:lightmeter/utils/context_utils.dart';
class MeteringBottomControls extends StatelessWidget {
final double? ev;
@ -76,7 +75,7 @@ class _EvValueText extends StatelessWidget {
}
String _text(BuildContext context) {
final bool showEv100 = context.isPro && UserPreferencesProvider.showEv100Of(context);
final bool showEv100 = UserPreferencesProvider.showEv100Of(context);
final StringBuffer buffer = StringBuffer()
..writeAll([
(showEv100 ? ev100 : ev).toStringAsFixed(1),

View file

@ -17,6 +17,7 @@ import 'package:lightmeter/screens/metering/components/camera_container/models/c
import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart';
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/metering_top_bar/widget_top_bar_metering.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/lightmeter_pro_badge/widget_badge_lightmeter_pro.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/widget_container_readings.dart';
import 'package:lightmeter/utils/context_utils.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
@ -112,20 +113,17 @@ class CameraContainer extends StatelessWidget {
double _meteringContainerHeight(BuildContext context) {
double enabledFeaturesHeight = 0;
if (!context.isPro) {
if (RemoteConfig.isEnabled(context, Feature.showUnlockProOnMainScreen)) {
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
enabledFeaturesHeight += Dimens.paddingS;
}
} else {
if (context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) {
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
enabledFeaturesHeight += Dimens.paddingS;
}
if (context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) {
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
enabledFeaturesHeight += Dimens.paddingS;
}
if (!context.isPro && RemoteConfig.isEnabled(context, Feature.showUnlockProOnMainScreen)) {
enabledFeaturesHeight += LightmeterProBadge.height(context);
enabledFeaturesHeight += Dimens.paddingS;
}
if (context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) {
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
enabledFeaturesHeight += Dimens.paddingS;
}
if (context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) {
enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight;
enabledFeaturesHeight += Dimens.paddingS;
}
if (context.meteringFeature(MeteringScreenLayoutFeature.extremeExposurePairs)) {
enabledFeaturesHeight += Dimens.readingContainerDoubleValueHeight;

View file

@ -1,27 +0,0 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
class LightmeterProAnimatedDialog extends StatelessWidget {
const LightmeterProAnimatedDialog({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name);
},
child: ReadingValueContainer(
color: Theme.of(context).colorScheme.secondary,
textColor: Theme.of(context).colorScheme.onSecondary,
values: [
ReadingValue(
label: S.of(context).proFeaturesTitle,
value: S.of(context).getPro,
),
],
),
);
}
}

View file

@ -0,0 +1,44 @@
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:lightmeter/utils/text_height.dart';
class LightmeterProBadge extends StatelessWidget {
const LightmeterProBadge({super.key});
static double height(BuildContext context) {
if (Theme.of(context).textTheme.titleMedium?.lineHeight case final lineHeight?) {
return Dimens.paddingS * 2 + lineHeight;
} else {
return 40;
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name);
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Dimens.borderRadiusM),
color: Theme.of(context).colorScheme.secondaryContainer,
),
padding: const EdgeInsets.symmetric(
horizontal: Dimens.paddingM,
vertical: Dimens.paddingS,
),
child: Text(
S.of(context).getPro,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(color: Theme.of(context).colorScheme.onSecondaryContainer),
),
),
);
}
}

View file

@ -9,7 +9,7 @@ import 'package:lightmeter/screens/metering/components/shared/readings_container
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/lightmeter_pro_badge/widget_badge_lightmeter_pro.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart';
import 'package:lightmeter/utils/context_utils.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
@ -38,10 +38,10 @@ class ReadingsContainer extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!context.isPro && RemoteConfig.isEnabled(context, Feature.showUnlockProOnMainScreen)) ...[
const LightmeterProAnimatedDialog(),
const LightmeterProBadge(),
const _InnerPadding(),
],
if (context.isPro && context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) ...[
if (context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) ...[
const EquipmentProfilePicker(),
const _InnerPadding(),
],
@ -52,7 +52,7 @@ class ReadingsContainer extends StatelessWidget {
),
const _InnerPadding(),
],
if (context.isPro && context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) ...[
if (context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) ...[
FilmPicker(selectedIso: iso),
const _InnerPadding(),
],

View file

@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/settings/components/about/components/report_issue/widget_list_tile_report_issue.dart';
import 'package:lightmeter/screens/settings/components/about/components/restore_purchases/widget_list_tile_restore_purchases.dart';
import 'package:lightmeter/screens/settings/components/about/components/source_code/widget_list_tile_source_code.dart';
import 'package:lightmeter/screens/settings/components/about/components/version/widget_list_tile_version.dart';
import 'package:lightmeter/screens/settings/components/about/components/write_email/widget_list_tile_write_email.dart';
import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart';
import 'package:lightmeter/utils/context_utils.dart';
class AboutSettingsSection extends StatelessWidget {
const AboutSettingsSection({super.key});
@ -13,11 +15,12 @@ class AboutSettingsSection extends StatelessWidget {
Widget build(BuildContext context) {
return SettingsSection(
title: S.of(context).about,
children: const [
SourceCodeListTile(),
ReportIssueListTile(),
WriteEmailListTile(),
VersionListTile(),
children: [
const SourceCodeListTile(),
const ReportIssueListTile(),
const WriteEmailListTile(),
const VersionListTile(),
if (!context.isPro) const RestorePurchasesListTile(),
],
);
}

View file

@ -4,39 +4,53 @@ import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart';
import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart';
class CameraFeaturesListTile extends StatelessWidget {
const CameraFeaturesListTile({super.key});
@override
Widget build(BuildContext context) {
return IAPListTile(
return ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: Text(S.of(context).cameraFeatures),
onTap: () {
UserPreferencesProvider.cameraConfigOf(context).entries.map(
(entry) => DialogSwitchListItem(
value: CameraFeature.spotMetering,
title: S.of(context).cameraFeatureSpotMetering,
subtitle: S.of(context).cameraFeatureSpotMeteringHint,
initialValue: UserPreferencesProvider.cameraFeatureOf(context, CameraFeature.spotMetering),
isProRequired: true,
),
);
showDialog(
context: context,
builder: (_) => DialogSwitch<CameraFeature>(
icon: Icons.camera_alt_outlined,
title: S.of(context).cameraFeatures,
values: UserPreferencesProvider.cameraConfigOf(context),
enabledAdapter: (feature) => switch (feature) {
CameraFeature.spotMetering => true,
CameraFeature.histogram => true,
CameraFeature.showFocalLength =>
ServicesProvider.of(context).userPreferencesService.cameraFocalLength != null,
},
titleAdapter: (context, feature) => switch (feature) {
CameraFeature.spotMetering => S.of(context).cameraFeatureSpotMetering,
CameraFeature.histogram => S.of(context).cameraFeatureHistogram,
CameraFeature.showFocalLength => S.of(context).cameraFeaturesShowFocalLength,
},
subtitleAdapter: (context, feature) => switch (feature) {
CameraFeature.spotMetering => S.of(context).cameraFeatureSpotMeteringHint,
CameraFeature.histogram => S.of(context).cameraFeatureHistogramHint,
CameraFeature.showFocalLength => S.of(context).cameraFeaturesShowFocalLengthHint,
},
items: [
DialogSwitchListItem(
value: CameraFeature.showFocalLength,
title: S.of(context).cameraFeaturesShowFocalLength,
subtitle: S.of(context).cameraFeaturesShowFocalLengthHint,
initialValue: UserPreferencesProvider.cameraFeatureOf(context, CameraFeature.showFocalLength),
isEnabled: ServicesProvider.of(context).userPreferencesService.cameraFocalLength != null,
),
DialogSwitchListItem(
value: CameraFeature.spotMetering,
title: S.of(context).cameraFeatureSpotMetering,
subtitle: S.of(context).cameraFeatureSpotMeteringHint,
initialValue: UserPreferencesProvider.cameraFeatureOf(context, CameraFeature.spotMetering),
isProRequired: true,
),
DialogSwitchListItem(
value: CameraFeature.histogram,
title: S.of(context).cameraFeatureHistogram,
subtitle: S.of(context).cameraFeatureHistogramHint,
initialValue: UserPreferencesProvider.cameraFeatureOf(context, CameraFeature.histogram),
isProRequired: true,
),
],
onSave: UserPreferencesProvider.of(context).setCameraFeature,
),
);

View file

@ -1,14 +1,13 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart';
class LogbookListTile extends StatelessWidget {
const LogbookListTile({super.key});
@override
Widget build(BuildContext context) {
return IAPListTile(
return ListTile(
leading: const Icon(Icons.book_outlined),
title: Text(S.of(context).logbook),
onTap: () {

View file

@ -3,24 +3,26 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/settings/components/general/components/timer/bloc_list_tile_timer.dart';
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
import 'package:lightmeter/utils/context_utils.dart';
import 'package:lightmeter/utils/guard_pro_tap.dart';
class TimerListTile extends StatelessWidget {
const TimerListTile({super.key});
@override
Widget build(BuildContext context) {
return Disable(
disable: !context.isPro,
child: BlocBuilder<TimerListTileBloc, bool>(
builder: (context, state) => SwitchListTile(
secondary: const Icon(Icons.timer_outlined),
title: Text(S.of(context).autostartTimer),
value: state && context.isPro,
onChanged: context.read<TimerListTileBloc>().onChanged,
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
),
return BlocBuilder<TimerListTileBloc, bool>(
builder: (context, state) => SwitchListTile(
secondary: const Icon(Icons.timer_outlined),
title: Text(S.of(context).autostartTimer),
value: context.isPro && state,
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
onChanged: (value) {
guardProTap(
context,
() => context.read<TimerListTileBloc>().onChanged(value),
);
},
),
);
}

View file

@ -1,19 +0,0 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/navigation/routes.dart';
class BuyProListTile extends StatelessWidget {
const BuyProListTile({super.key});
@override
Widget build(BuildContext context) {
// TODO: implement pending handling via REvenueCat
return ListTile(
leading: const Icon(Icons.bolt),
title: Text(S.of(context).getPro),
onTap: () {
Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name);
},
);
}
}

View file

@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart';
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/restore_purchases/widget_list_tile_restore_purchases.dart';
import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart';
class LightmeterProSettingsSection extends StatelessWidget {
const LightmeterProSettingsSection({super.key});
@override
Widget build(BuildContext context) {
return SettingsSection(
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
title: S.of(context).proFeaturesTitle,
children: const [
BuyProListTile(),
RestorePurchasesListTile(),
],
);
}
}

View file

@ -1,14 +1,13 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart';
class EquipmentProfilesListTile extends StatelessWidget {
const EquipmentProfilesListTile({super.key});
@override
Widget build(BuildContext context) {
return IAPListTile(
return ListTile(
leading: const Icon(Icons.camera_outlined),
title: Text(S.of(context).equipmentProfiles),
onTap: () {

View file

@ -1,17 +1,24 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart';
import 'package:lightmeter/utils/guard_pro_tap.dart';
class FilmsListTile extends StatelessWidget {
const FilmsListTile({super.key});
@override
Widget build(BuildContext context) {
return IAPListTile(
return ListTile(
leading: const Icon(Icons.camera_roll_outlined),
title: Text(S.of(context).films),
onTap: () => Navigator.of(context).pushNamed(NavigationRoutes.filmsListScreen.name),
onTap: () {
guardProTap(
context,
() {
Navigator.of(context).pushNamed(NavigationRoutes.filmsListScreen.name);
},
);
},
);
}
}

View file

@ -5,7 +5,6 @@ import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart';
import 'package:lightmeter/utils/context_utils.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class MeteringScreenLayoutListTile extends StatelessWidget {
@ -23,17 +22,16 @@ class MeteringScreenLayoutListTile extends StatelessWidget {
icon: Icons.layers_outlined,
title: S.of(context).meteringScreenLayout,
description: S.of(context).meteringScreenLayoutHint,
values: UserPreferencesProvider.meteringScreenConfigOf(context),
titleAdapter: _toStringLocalized,
enabledAdapter: (value) {
switch (value) {
case MeteringScreenLayoutFeature.equipmentProfiles:
case MeteringScreenLayoutFeature.filmPicker:
return context.isPro;
default:
return true;
}
},
items: UserPreferencesProvider.meteringScreenConfigOf(context)
.entries
.map(
(entry) => DialogSwitchListItem(
value: entry.key,
title: _toStringLocalized(context, entry.key),
initialValue: UserPreferencesProvider.meteringScreenFeatureOf(context, entry.key),
),
)
.toList(growable: false),
onSave: (value) {
if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) {
EquipmentProfilesProvider.of(context).selectProfile(EquipmentProfiles.of(context).first);

View file

@ -2,23 +2,18 @@ import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
import 'package:lightmeter/utils/context_utils.dart';
class ShowEv100ListTile extends StatelessWidget {
const ShowEv100ListTile({super.key});
@override
Widget build(BuildContext context) {
return Disable(
disable: !context.isPro,
child: SwitchListTile(
secondary: const Icon(Icons.adjust_outlined),
title: Text(S.of(context).showEv100),
value: context.isPro && UserPreferencesProvider.showEv100Of(context),
onChanged: (_) => UserPreferencesProvider.of(context).toggleShowEv100(),
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
),
return SwitchListTile(
secondary: const Icon(Icons.adjust_outlined),
title: Text(S.of(context).showEv100),
value: UserPreferencesProvider.showEv100Of(context),
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
onChanged: (_) => UserPreferencesProvider.of(context).toggleShowEv100(),
);
}
}

View file

@ -1,28 +1,41 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
import 'package:lightmeter/utils/context_utils.dart';
import 'package:lightmeter/utils/guard_pro_tap.dart';
typedef StringAdapter<T> = String Function(BuildContext context, T value);
class DialogSwitchListItem<T> {
final T value;
final String title;
final String? subtitle;
final bool initialValue;
final bool isEnabled;
final bool isProRequired;
const DialogSwitchListItem({
required this.value,
required this.title,
this.subtitle,
required this.initialValue,
this.isEnabled = true,
this.isProRequired = false,
});
}
class DialogSwitch<T> extends StatefulWidget {
final IconData icon;
final String title;
final String? description;
final Map<T, bool> values;
final StringAdapter<T> titleAdapter;
final StringAdapter<T>? subtitleAdapter;
final bool Function(T value)? enabledAdapter;
final List<DialogSwitchListItem<T>> items;
final ValueChanged<Map<T, bool>> onSave;
const DialogSwitch({
required this.icon,
required this.title,
this.description,
required this.values,
required this.titleAdapter,
this.subtitleAdapter,
this.enabledAdapter,
required this.items,
required this.onSave,
super.key,
});
@ -32,7 +45,11 @@ class DialogSwitch<T> extends StatefulWidget {
}
class _DialogSwitchState<T> extends State<DialogSwitch<T>> {
late final Map<T, bool> _features = Map.from(widget.values);
late final Map<T, bool> _features = Map.fromEntries(
widget.items.map(
(item) => MapEntry(item.value, item.initialValue),
),
);
@override
Widget build(BuildContext context) {
@ -55,27 +72,15 @@ class _DialogSwitchState<T> extends State<DialogSwitch<T>> {
],
ListView(
shrinkWrap: true,
children: _features.entries.map(
(entry) {
final isEnabled = widget.enabledAdapter?.call(entry.key) ?? true;
return Disable(
disable: !isEnabled,
child: SwitchListTile(
contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left),
title: Text(widget.titleAdapter(context, entry.key)),
subtitle: widget.subtitleAdapter != null
? Text(
widget.subtitleAdapter!.call(context, entry.key),
style: Theme.of(context).listTileTheme.subtitleTextStyle,
)
: null,
value: isEnabled && _features[entry.key]!,
onChanged: (value) {
setState(() {
_features.update(entry.key, (_) => value);
});
},
),
children: widget.items.map(
(item) {
final value = _features[item.value]!;
return SwitchListTile(
contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left),
title: Text(item.title),
subtitle: item.subtitle != null ? Text(item.subtitle!) : null,
value: item.isProRequired ? context.isPro && value : value,
onChanged: item.isEnabled ? (value) => _setItem(item, value) : null,
);
},
).toList(),
@ -99,4 +104,18 @@ class _DialogSwitchState<T> extends State<DialogSwitch<T>> {
],
);
}
void _setItem(DialogSwitchListItem<T> item, bool value) {
void setItemState() {
setState(() {
_features.update(item.value, (_) => value);
});
}
if (item.isProRequired) {
guardProTap(context, setItemState);
} else {
setItemState();
}
}
}

View file

@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart';
import 'package:lightmeter/utils/context_utils.dart';
/// Depends on the product status and replaces [onTap] with purchase callback
/// if the product is purchasable.
class IAPListTile extends StatelessWidget {
final Icon leading;
final Text title;
final VoidCallback onTap;
final bool showPendingTrailing;
const IAPListTile({
required this.leading,
required this.title,
required this.onTap,
this.showPendingTrailing = false,
super.key,
});
@override
Widget build(BuildContext context) {
return Disable(
disable: !context.isPro,
child: ListTile(
leading: leading,
title: title,
onTap: onTap,
),
);
}
}

View file

@ -4,14 +4,10 @@ import 'package:lightmeter/res/dimens.dart';
class SettingsSection extends StatelessWidget {
final String title;
final List<Widget> children;
final Color? backgroundColor;
final Color? foregroundColor;
const SettingsSection({
required this.title,
required this.children,
this.backgroundColor,
this.foregroundColor,
super.key,
});
@ -25,33 +21,22 @@ class SettingsSection extends StatelessWidget {
Dimens.paddingM,
),
child: Card(
color: backgroundColor,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Dimens.paddingM),
child: Theme(
data: Theme.of(context).copyWith(
listTileTheme: Theme.of(context).listTileTheme.copyWith(
iconColor: foregroundColor,
textColor: foregroundColor,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Text(
title,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: foregroundColor ?? Theme.of(context).colorScheme.onSurface),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM),
child: Text(
title,
style:
Theme.of(context).textTheme.labelLarge?.copyWith(color: Theme.of(context).colorScheme.onSurface),
),
...children,
],
),
),
...children,
],
),
),
),

View file

@ -3,12 +3,10 @@ import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/screens/settings/components/about/widget_settings_section_about.dart';
import 'package:lightmeter/screens/settings/components/camera/widget_settings_section_camera.dart';
import 'package:lightmeter/screens/settings/components/general/widget_settings_section_general.dart';
import 'package:lightmeter/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart';
import 'package:lightmeter/screens/settings/components/metering/widget_settings_section_metering.dart';
import 'package:lightmeter/screens/settings/components/theme/widget_settings_section_theme.dart';
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 {
@ -41,7 +39,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
SliverList(
delegate: SliverChildListDelegate(
<Widget>[
if (!context.isPro) const LightmeterProSettingsSection(),
const MeteringSettingsSection(),
const CameraSettingsSection(),
const GeneralSettingsSection(),

View file

@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/navigation/routes.dart';
import 'package:lightmeter/utils/context_utils.dart';
Future<void> guardProTap(BuildContext context, VoidCallback callback) async {
if (context.isPro) {
callback();
} else {
final isPro = await Navigator.of(context).pushNamed(NavigationRoutes.proFeaturesScreen.name);
if (isPro == true) {
callback();
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

After

Width:  |  Height:  |  Size: 474 KiB