From f3b08868be5f32be78a2418bdefd1d6e9c3183ee Mon Sep 17 00:00:00 2001
From: Vadim <44135514+vodemn@users.noreply.github.com>
Date: Fri, 20 Oct 2023 16:12:43 +0200
Subject: [PATCH 01/13] ML-62 Providers tests + Platform & Application mocks
(#131)
- Fixed test coverage calculation
- Removed `mockito` from the application mock
- Implemented platform channel mocks to mimic incident light metering
- Covered providers with unit tests
- Covered metering screen pickers with widget tests
- Laid foundation for integration tests
---
.github/workflows/pr_check.yml | 2 +-
.gitignore | 3 +-
.vscode/settings.json | 3 +-
README.md | 3 +
iap/lib/m3_lightmeter_iap.dart | 31 +-
iap/lib/src/data/iap_storage_service.dart | 17 +
.../providers/equipment_profile_provider.dart | 61 ---
iap/lib/src/providers/films_provider.dart | 65 ---
integration_test/generate_screenshots.dart | 294 --------------
.../mocks/paid_features_mock.dart | 105 +++++
.../utils/platform_channel_mock.dart | 59 +++
.../utils/widget_tester_actions.dart | 84 ++++
lib/application_wrapper.dart | 35 +-
lib/data/light_sensor_service.dart | 4 +-
lib/main_dev.dart | 11 +-
lib/main_prod.dart | 10 +-
lib/main_release.dart | 10 +-
lib/providers/equipment_profile_provider.dart | 130 ++++++
lib/providers/films_provider.dart | 107 +++++
lib/providers/services_provider.dart | 2 +
lib/providers/user_preferences_provider.dart | 97 +++--
.../widget_list_exposure_pairs.dart | 4 +-
.../widget_picker_equipment_profiles.dart | 2 +-
...dget_container_extreme_exposure_pairs.dart | 2 +-
.../film_picker/widget_picker_film.dart | 2 +-
.../nd_picker/widget_picker_nd.dart | 7 +-
.../widget_container_readings.dart | 2 +-
lib/screens/metering/screen_metering.dart | 14 +-
.../listener_metering_layout_feature.dart | 52 ---
.../utils/listsner_equipment_profiles.dart | 2 +-
.../screen_equipment_profile.dart | 3 +-
.../films/widget_list_tile_films.dart | 2 +-
...ialog_metering_screen_layout_features.dart | 13 +-
.../utils}/selectable_provider.dart | 0
pubspec.yaml | 4 +-
screenshots/README.md | 37 ++
screenshots/generate_screenshots.dart | 136 +++++++
.../generate_screenshots.sh | 2 +-
test/application_mock.dart | 31 ++
test/data/light_sensor_service_test.dart | 90 ++---
test/data/models/exposure_pair_test.dart | 38 ++
test/data/models/supported_locale_test.dart | 2 +
test/data/shared_prefs_service_test.dart | 57 +--
test/data/volume_events_service_test.dart | 18 +-
test/event_channel_mock.dart | 10 +
.../equipment_profile_provider_test.dart | 353 ++++++++++++++++
test/providers/films_provider_test.dart | 275 +++++++++++++
.../user_preferences_provider_test.dart | 376 ++++++++++++++++++
.../equipment_profile_picker_test.dart | 116 ++++++
...extreme_exposure_pairs_container_test.dart | 72 ++++
.../readings_container/film_picker_test.dart | 110 +++++
.../readings_container/iso_picker_test.dart | 61 +++
.../readings_container/nd_picker_test.dart | 71 ++++
.../shared/animated_dialog_test.dart | 122 ++++++
.../shared/dialog_picker_test.dart | 95 +++++
.../shared/readings_container/utils.dart | 27 ++
test_coverage.sh | 8 +
test_driver/integration_driver.dart | 8 +
test_driver/screenshot_driver.dart | 47 +--
.../utils/grant_camera_permission.dart | 34 ++
60 files changed, 2714 insertions(+), 724 deletions(-)
create mode 100644 iap/lib/src/data/iap_storage_service.dart
delete mode 100644 iap/lib/src/providers/equipment_profile_provider.dart
delete mode 100644 iap/lib/src/providers/films_provider.dart
delete mode 100644 integration_test/generate_screenshots.dart
create mode 100644 integration_test/mocks/paid_features_mock.dart
create mode 100644 integration_test/utils/platform_channel_mock.dart
create mode 100644 integration_test/utils/widget_tester_actions.dart
create mode 100644 lib/providers/equipment_profile_provider.dart
create mode 100644 lib/providers/films_provider.dart
delete mode 100644 lib/screens/metering/utils/listener_metering_layout_feature.dart
rename {iap/lib/src/providers => lib/utils}/selectable_provider.dart (100%)
create mode 100644 screenshots/README.md
create mode 100644 screenshots/generate_screenshots.dart
rename {integration_test => screenshots}/generate_screenshots.sh (83%)
create mode 100644 test/application_mock.dart
create mode 100644 test/data/models/exposure_pair_test.dart
create mode 100644 test/event_channel_mock.dart
create mode 100644 test/providers/equipment_profile_provider_test.dart
create mode 100644 test/providers/films_provider_test.dart
create mode 100644 test/providers/user_preferences_provider_test.dart
create mode 100644 test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart
create mode 100644 test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart
create mode 100644 test/screens/metering/components/shared/readings_container/film_picker_test.dart
create mode 100644 test/screens/metering/components/shared/readings_container/iso_picker_test.dart
create mode 100644 test/screens/metering/components/shared/readings_container/nd_picker_test.dart
create mode 100644 test/screens/metering/components/shared/readings_container/shared/animated_dialog_test.dart
create mode 100644 test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart
create mode 100644 test/screens/metering/components/shared/readings_container/utils.dart
create mode 100644 test_driver/integration_driver.dart
create mode 100644 test_driver/utils/grant_camera_permission.dart
diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml
index caf7a97..113bba0 100644
--- a/.github/workflows/pr_check.yml
+++ b/.github/workflows/pr_check.yml
@@ -15,7 +15,7 @@ jobs:
analyze_and_test:
name: Analyze & test
runs-on: macos-11
- timeout-minutes: 5
+ timeout-minutes: 10
steps:
- uses: 8BitJonny/gh-get-current-pr@2.2.0
id: PR
diff --git a/.gitignore b/.gitignore
index 703a8b6..4904e66 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,4 +60,5 @@ ios/Runner/GoogleService-Info.plist
/lib/firebase_options.dart
coverage/
-screenshots/
\ No newline at end of file
+test/coverage_helper_test.dart
+screenshots/*.png
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 97cf9fc..0ddf5c0 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -7,10 +7,9 @@
"files.watcherExclude": {
"**/.fvm": true
},
- "dart.lineLength": 100,
+ "dart.lineLength": 120,
"[dart]": {
"editor.rulers": [
- 100,
120,
],
"editor.selectionHighlight": true,
diff --git a/README.md b/README.md
index dae7291..ef38b93 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,8 @@
+![](https://github.com/vodemn/m3_lightmeter/actions/workflows/pr_check.yml/badge.svg)
+![](https://github.com/vodemn/m3_lightmeter/actions/workflows/create_release.yml/badge.svg)
+
# Table of contents
- [Table of contents](#table-of-contents)
diff --git a/iap/lib/m3_lightmeter_iap.dart b/iap/lib/m3_lightmeter_iap.dart
index 171fe47..43e69aa 100644
--- a/iap/lib/m3_lightmeter_iap.dart
+++ b/iap/lib/m3_lightmeter_iap.dart
@@ -1,34 +1,9 @@
library m3_lightmeter_iap;
-import 'package:flutter/material.dart';
-import 'package:m3_lightmeter_iap/src/providers/equipment_profile_provider.dart';
-import 'package:m3_lightmeter_iap/src/providers/films_provider.dart';
-import 'package:m3_lightmeter_iap/src/providers/iap_products_provider.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
export 'src/data/models/iap_product.dart';
-
-export 'src/providers/equipment_profile_provider.dart';
-export 'src/providers/films_provider.dart';
export 'src/providers/iap_products_provider.dart';
+export 'src/data/iap_storage_service.dart';
-class IAPProviders extends StatelessWidget {
- final Object sharedPreferences;
- final Widget child;
-
- const IAPProviders({
- required this.sharedPreferences,
- required this.child,
- super.key,
- });
-
- @override
- Widget build(BuildContext context) {
- return IAPProductsProvider(
- child: FilmsProvider(
- child: EquipmentProfileProvider(
- child: child,
- ),
- ),
- );
- }
-}
+const List films = [];
diff --git a/iap/lib/src/data/iap_storage_service.dart b/iap/lib/src/data/iap_storage_service.dart
new file mode 100644
index 0000000..f62f622
--- /dev/null
+++ b/iap/lib/src/data/iap_storage_service.dart
@@ -0,0 +1,17 @@
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+
+class IAPStorageService {
+ const IAPStorageService(Object _);
+
+ String get selectedEquipmentProfileId => '';
+ set selectedEquipmentProfileId(String id) {}
+
+ List get equipmentProfiles => [];
+ set equipmentProfiles(List profiles) {}
+
+ Film get selectedFilm => const Film.other();
+ set selectedFilm(Film value) {}
+
+ List get filmsInUse => [];
+ set filmsInUse(List profiles) {}
+}
diff --git a/iap/lib/src/providers/equipment_profile_provider.dart b/iap/lib/src/providers/equipment_profile_provider.dart
deleted file mode 100644
index 0a037a9..0000000
--- a/iap/lib/src/providers/equipment_profile_provider.dart
+++ /dev/null
@@ -1,61 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:m3_lightmeter_iap/src/providers/selectable_provider.dart';
-import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
-
-class EquipmentProfileProvider extends StatefulWidget {
- final Widget child;
-
- const EquipmentProfileProvider({required this.child, super.key});
-
- static EquipmentProfileProviderState of(BuildContext context) {
- return context.findAncestorStateOfType()!;
- }
-
- @override
- State createState() => EquipmentProfileProviderState();
-}
-
-class EquipmentProfileProviderState extends State {
- static const EquipmentProfile _defaultProfile = EquipmentProfile(
- id: '',
- name: '',
- apertureValues: ApertureValue.values,
- ndValues: NdValue.values,
- shutterSpeedValues: ShutterSpeedValue.values,
- isoValues: IsoValue.values,
- );
-
- @override
- Widget build(BuildContext context) {
- return EquipmentProfiles(
- values: const [_defaultProfile],
- selected: _defaultProfile,
- child: widget.child,
- );
- }
-
- void setProfile(EquipmentProfile data) {}
-
- void addProfile(String name, [EquipmentProfile? copyFrom]) {}
-
- void updateProdile(EquipmentProfile data) {}
-
- void deleteProfile(EquipmentProfile data) {}
-}
-
-class EquipmentProfiles extends SelectableInheritedModel {
- const EquipmentProfiles({
- super.key,
- required super.values,
- required super.selected,
- required super.child,
- });
-
- static List of(BuildContext context) {
- return InheritedModel.inheritFrom(context, aspect: SelectableAspect.list)!.values;
- }
-
- static EquipmentProfile selectedOf(BuildContext context) {
- return InheritedModel.inheritFrom(context, aspect: SelectableAspect.selected)!.selected;
- }
-}
diff --git a/iap/lib/src/providers/films_provider.dart b/iap/lib/src/providers/films_provider.dart
deleted file mode 100644
index e75ccd3..0000000
--- a/iap/lib/src/providers/films_provider.dart
+++ /dev/null
@@ -1,65 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:m3_lightmeter_iap/src/providers/selectable_provider.dart';
-import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
-
-class FilmsProvider extends StatefulWidget {
- final Widget child;
-
- const FilmsProvider({
- required this.child,
- super.key,
- });
-
- static FilmsProviderState of(BuildContext context) {
- return context.findAncestorStateOfType()!;
- }
-
- @override
- State createState() => FilmsProviderState();
-}
-
-class FilmsProviderState extends State {
- @override
- Widget build(BuildContext context) {
- return Films(
- values: const [Film.other()],
- filmsInUse: const [Film.other()],
- selected: const Film.other(),
- child: widget.child,
- );
- }
-
- void setFilm(Film film) {}
-
- void saveFilms(List films) {}
-}
-
-class Films extends SelectableInheritedModel {
- final List filmsInUse;
-
- const Films({
- super.key,
- required super.values,
- required this.filmsInUse,
- required super.selected,
- required super.child,
- });
-
- /// [Film.other()] + all the custom fields with actual reciprocity formulas
- static List of(BuildContext context) {
- return InheritedModel.inheritFrom(context)!.values;
- }
-
- /// [Film.other()] + films in use selected by user
- static List inUseOf(BuildContext context) {
- return InheritedModel.inheritFrom(
- context,
- aspect: SelectableAspect.list,
- )!
- .filmsInUse;
- }
-
- static Film selectedOf(BuildContext context) {
- return InheritedModel.inheritFrom(context, aspect: SelectableAspect.selected)!.selected;
- }
-}
diff --git a/integration_test/generate_screenshots.dart b/integration_test/generate_screenshots.dart
deleted file mode 100644
index 2c28d42..0000000
--- a/integration_test/generate_screenshots.dart
+++ /dev/null
@@ -1,294 +0,0 @@
-import 'dart:io';
-
-import 'package:flutter/material.dart';
-import 'package:flutter_test/flutter_test.dart';
-import 'package:integration_test/integration_test.dart';
-import 'package:lightmeter/application.dart';
-import 'package:lightmeter/data/caffeine_service.dart';
-import 'package:lightmeter/data/haptics_service.dart';
-import 'package:lightmeter/data/light_sensor_service.dart';
-import 'package:lightmeter/data/models/ev_source_type.dart';
-import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
-import 'package:lightmeter/data/models/supported_locale.dart';
-import 'package:lightmeter/data/models/theme_type.dart';
-import 'package:lightmeter/data/models/volume_action.dart';
-import 'package:lightmeter/data/permissions_service.dart';
-import 'package:lightmeter/data/shared_prefs_service.dart';
-import 'package:lightmeter/data/volume_events_service.dart';
-import 'package:lightmeter/environment.dart';
-import 'package:lightmeter/generated/l10n.dart';
-import 'package:lightmeter/providers/services_provider.dart';
-import 'package:lightmeter/providers/user_preferences_provider.dart';
-import 'package:lightmeter/res/dimens.dart';
-import 'package:lightmeter/res/theme.dart';
-import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.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/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart';
-import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/widget_container_equipment_profile.dart';
-import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart';
-import 'package:lightmeter/screens/settings/screen_settings.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
-import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
-import 'package:mocktail/mocktail.dart';
-import 'package:permission_handler/permission_handler.dart';
-import 'package:shared_preferences/shared_preferences.dart';
-
-class _MockSharedPreferences extends Mock implements SharedPreferences {}
-
-class _MockUserPreferencesService extends Mock implements UserPreferencesService {}
-
-class _MockCaffeineService extends Mock implements CaffeineService {}
-
-class _MockHapticsService extends Mock implements HapticsService {}
-
-class _MockPermissionsService extends Mock implements PermissionsService {}
-
-class _MockLightSensorService extends Mock implements LightSensorService {}
-
-class _MockVolumeEventsService extends Mock implements VolumeEventsService {}
-
-//https://stackoverflow.com/a/67186625/13167574
-void main() {
- late _MockUserPreferencesService mockUserPreferencesService;
- late _MockCaffeineService mockCaffeineService;
- late _MockHapticsService mockHapticsService;
- late _MockPermissionsService mockPermissionsService;
- late _MockLightSensorService mockLightSensorService;
- late _MockVolumeEventsService mockVolumeEventsService;
-
- final binding = IntegrationTestWidgetsFlutterBinding();
- IntegrationTestWidgetsFlutterBinding.ensureInitialized();
-
- setUpAll(() {
- mockUserPreferencesService = _MockUserPreferencesService();
- when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera);
- when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third);
- when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en);
- when(() => mockUserPreferencesService.caffeine).thenReturn(true);
- when(() => mockUserPreferencesService.volumeAction).thenReturn(VolumeAction.shutter);
- when(() => mockUserPreferencesService.cameraEvCalibration).thenReturn(0.0);
- when(() => mockUserPreferencesService.lightSensorEvCalibration).thenReturn(0.0);
- when(() => mockUserPreferencesService.iso).thenReturn(const IsoValue(400, StopType.full));
- when(() => mockUserPreferencesService.ndFilter).thenReturn(NdValue.values.first);
- when(() => mockUserPreferencesService.haptics).thenReturn(true);
- when(() => mockUserPreferencesService.meteringScreenLayout).thenReturn({
- MeteringScreenLayoutFeature.equipmentProfiles: true,
- MeteringScreenLayoutFeature.extremeExposurePairs: true,
- MeteringScreenLayoutFeature.filmPicker: true,
- MeteringScreenLayoutFeature.histogram: false,
- });
- when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light);
- when(() => mockUserPreferencesService.dynamicColor).thenReturn(false);
-
- mockCaffeineService = _MockCaffeineService();
- when(() => mockCaffeineService.isKeepScreenOn()).thenAnswer((_) async => false);
- when(() => mockCaffeineService.keepScreenOn(true)).thenAnswer((_) async => true);
- when(() => mockCaffeineService.keepScreenOn(false)).thenAnswer((_) async => false);
-
- mockHapticsService = _MockHapticsService();
- when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {});
- when(() => mockHapticsService.responseVibration()).thenAnswer((_) async {});
- when(() => mockHapticsService.errorVibration()).thenAnswer((_) async {});
-
- mockPermissionsService = _MockPermissionsService();
- when(() => mockPermissionsService.requestCameraPermission())
- .thenAnswer((_) async => PermissionStatus.granted);
- when(() => mockPermissionsService.checkCameraPermission())
- .thenAnswer((_) async => PermissionStatus.granted);
-
- mockLightSensorService = _MockLightSensorService();
- when(() => mockLightSensorService.hasSensor()).thenAnswer((_) async => true);
- when(() => mockLightSensorService.luxStream()).thenAnswer((_) => Stream.fromIterable([100]));
-
- mockVolumeEventsService = _MockVolumeEventsService();
- when(() => mockVolumeEventsService.setVolumeHandling(true)).thenAnswer((_) async => true);
- when(() => mockVolumeEventsService.setVolumeHandling(false)).thenAnswer((_) async => false);
- when(() => mockVolumeEventsService.volumeButtonsEventStream())
- .thenAnswer((_) => const Stream.empty());
-
- when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {});
- when(() => mockHapticsService.responseVibration()).thenAnswer((_) async {});
- });
-
- Future pumpApplication(WidgetTester tester) async {
- await tester.pumpWidget(
- IAPProviders(
- sharedPreferences: _MockSharedPreferences(),
- child: EquipmentProfiles(
- selected: _mockEquipmentProfiles[0],
- values: _mockEquipmentProfiles,
- child: Films(
- selected: const Film('Ilford HP5+', 400),
- values: const [Film.other(), Film('Ilford HP5+', 400)],
- filmsInUse: const [Film.other(), Film('Ilford HP5+', 400)],
- child: ServicesProvider(
- environment: const Environment.prod().copyWith(hasLightSensor: true),
- userPreferencesService: mockUserPreferencesService,
- caffeineService: mockCaffeineService,
- hapticsService: mockHapticsService,
- permissionsService: mockPermissionsService,
- lightSensorService: mockLightSensorService,
- volumeEventsService: mockVolumeEventsService,
- child: const UserPreferencesProvider(
- child: Application(),
- ),
- ),
- ),
- ),
- ),
- );
- await tester.pumpAndSettle();
- }
-
- /// Generates several screenshots with the light theme
- /// and the additionally the first one with the dark theme
- void generateScreenshots(Color color) {
- testWidgets('${color.value}_light', (tester) async {
- when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light);
- when(() => mockUserPreferencesService.primaryColor).thenReturn(color);
- await pumpApplication(tester);
-
- await tester.takePhoto();
- await tester.takeScreenshot(binding, '${color.value}_metering_reflected');
-
- await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor));
- await tester.pumpAndSettle();
- await tester.tap(find.byType(MeteringMeasureButton));
- await tester.tap(find.byType(MeteringMeasureButton));
- await tester.takeScreenshot(binding, '${color.value}_metering_incident');
-
- expect(find.byType(IsoValuePicker), findsOneWidget);
- await tester.tap(find.byType(IsoValuePicker));
- await tester.pumpAndSettle(Dimens.durationL);
- expect(find.byType(DialogPicker), findsOneWidget);
- await tester.takeScreenshot(binding, '${color.value}_metering_iso_picker');
-
- await tester.tapCancelButton();
- expect(find.byType(DialogPicker), findsNothing);
- expect(find.byTooltip(S.current.tooltipOpenSettings), findsOneWidget);
- await tester.tap(find.byTooltip(S.current.tooltipOpenSettings));
- await tester.pumpAndSettle();
- expect(find.byType(SettingsScreen), findsOneWidget);
- await tester.takeScreenshot(binding, '${color.value}_settings');
-
- await tester.tapListTile(S.current.meteringScreenLayout);
- await tester.takeScreenshot(binding, '${color.value}_settings_metering_screen_layout');
-
- await tester.tapCancelButton();
- await tester.tapListTile(S.current.equipmentProfiles);
- expect(find.byType(EquipmentProfilesScreen), findsOneWidget);
- await tester.tap(find.byType(EquipmentProfileContainer).first);
- await tester.pumpAndSettle();
- await tester.takeScreenshot(binding, '${color.value}-equipment_profiles');
-
- await tester.tap(find.byIcon(Icons.iso).first);
- await tester.pumpAndSettle();
- await tester.takeScreenshot(binding, '${color.value}_equipment_profiles_iso_picker');
- });
-
- testWidgets(
- '${color.value}_dark',
- (tester) async {
- when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.dark);
- when(() => mockUserPreferencesService.primaryColor).thenReturn(color);
- await pumpApplication(tester);
-
- await tester.takePhoto();
- await tester.takeScreenshot(binding, '${color.value}_metering_reflected_dark');
-
- await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor));
- await tester.pumpAndSettle();
- await tester.tap(find.byType(MeteringMeasureButton));
- await tester.tap(find.byType(MeteringMeasureButton));
- await tester.takeScreenshot(binding, '${color.value}_metering_incident_dark');
- },
- );
- }
-
- generateScreenshots(primaryColorsList[5]);
- generateScreenshots(primaryColorsList[3]);
- generateScreenshots(primaryColorsList[9]);
-}
-
-extension on WidgetTester {
- Future takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name) async {
- if (Platform.isAndroid) {
- await binding.convertFlutterSurfaceToImage();
- await pumpAndSettle();
- }
- await binding.takeScreenshot(name);
- await pumpAndSettle();
- }
-
- Future takePhoto() async {
- await tap(find.byType(MeteringMeasureButton));
- await pump(const Duration(seconds: 2)); // wait for circular progress indicator
- await pump(const Duration(seconds: 1)); // wait for circular progress indicator
- await pumpAndSettle();
- }
-
- Future tapCancelButton() async {
- final cancelButton = find.byWidgetPredicate(
- (widget) =>
- widget is TextButton &&
- widget.child is Text &&
- (widget.child as Text?)?.data == S.current.cancel,
- );
- expect(cancelButton, findsOneWidget);
- await tap(cancelButton);
- await pumpAndSettle();
- }
-
- Future tapListTile(String title) async {
- final listTile = find.byWidgetPredicate(
- (widget) =>
- widget is ListTile && widget.title is Text && (widget.title as Text?)?.data == title,
- );
- expect(listTile, findsOneWidget);
- await tap(listTile);
- await pumpAndSettle();
- }
-}
-
-final _mockEquipmentProfiles = [
- const EquipmentProfile(
- id: '',
- name: '',
- apertureValues: ApertureValue.values,
- ndValues: NdValue.values,
- shutterSpeedValues: ShutterSpeedValue.values,
- isoValues: IsoValue.values,
- ),
- EquipmentProfile(
- id: '1',
- name: 'Praktica + Zenitar',
- apertureValues: ApertureValue.values.sublist(
- ApertureValue.values.indexOf(const ApertureValue(1.7, StopType.half)),
- ApertureValue.values.indexOf(const ApertureValue(16, StopType.full)) + 1,
- ),
- ndValues: NdValue.values.sublist(0, 3),
- shutterSpeedValues: ShutterSpeedValue.values.sublist(
- ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)),
- ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1,
- ),
- isoValues: const [
- IsoValue(50, StopType.full),
- IsoValue(100, StopType.full),
- IsoValue(200, StopType.full),
- IsoValue(250, StopType.third),
- IsoValue(400, StopType.full),
- IsoValue(500, StopType.third),
- IsoValue(800, StopType.full),
- IsoValue(1600, StopType.full),
- IsoValue(3200, StopType.full),
- ],
- ),
- const EquipmentProfile(
- id: '2',
- name: 'Praktica + Jupiter',
- apertureValues: ApertureValue.values,
- ndValues: NdValue.values,
- shutterSpeedValues: ShutterSpeedValue.values,
- isoValues: IsoValue.values,
- ),
-];
diff --git a/integration_test/mocks/paid_features_mock.dart b/integration_test/mocks/paid_features_mock.dart
new file mode 100644
index 0000000..5d38c52
--- /dev/null
+++ b/integration_test/mocks/paid_features_mock.dart
@@ -0,0 +1,105 @@
+import 'package:flutter/material.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
+import 'package:lightmeter/providers/films_provider.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:mocktail/mocktail.dart';
+
+class _MockIAPStorageService extends Mock implements IAPStorageService {}
+
+class MockIAPProviders extends StatefulWidget {
+ final String selectedEquipmentProfileId;
+ final Film selectedFilm;
+ final Widget child;
+
+ const MockIAPProviders({
+ this.selectedEquipmentProfileId = '',
+ this.selectedFilm = const Film.other(),
+ required this.child,
+ super.key,
+ });
+
+ @override
+ State createState() => _MockIAPProvidersState();
+}
+
+class _MockIAPProvidersState extends State {
+ late final _MockIAPStorageService mockIAPStorageService;
+
+ @override
+ void initState() {
+ super.initState();
+ mockIAPStorageService = _MockIAPStorageService();
+ when(() => mockIAPStorageService.equipmentProfiles).thenReturn(mockEquipmentProfiles);
+ when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId);
+ when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms);
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(widget.selectedFilm);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return EquipmentProfileProvider(
+ storageService: mockIAPStorageService,
+ child: FilmsProvider(
+ storageService: mockIAPStorageService,
+ availableFilms: mockFilms,
+ child: widget.child,
+ ),
+ );
+ }
+}
+
+const defaultEquipmentProfile = EquipmentProfile(
+ id: '',
+ name: '',
+ apertureValues: ApertureValue.values,
+ ndValues: NdValue.values,
+ shutterSpeedValues: ShutterSpeedValue.values,
+ isoValues: IsoValue.values,
+);
+
+final mockEquipmentProfiles = [
+ EquipmentProfile(
+ id: '1',
+ name: 'Praktica + Zenitar',
+ apertureValues: ApertureValue.values.sublist(
+ ApertureValue.values.indexOf(const ApertureValue(1.7, StopType.half)),
+ ApertureValue.values.indexOf(const ApertureValue(16, StopType.full)) + 1,
+ ),
+ ndValues: NdValue.values.sublist(0, 3),
+ shutterSpeedValues: ShutterSpeedValue.values.sublist(
+ ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)),
+ ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1,
+ ),
+ isoValues: const [
+ IsoValue(50, StopType.full),
+ IsoValue(100, StopType.full),
+ IsoValue(200, StopType.full),
+ IsoValue(250, StopType.third),
+ IsoValue(400, StopType.full),
+ IsoValue(500, StopType.third),
+ IsoValue(800, StopType.full),
+ IsoValue(1600, StopType.full),
+ IsoValue(3200, StopType.full),
+ ],
+ ),
+ const EquipmentProfile(
+ id: '2',
+ name: 'Praktica + Jupiter',
+ apertureValues: ApertureValue.values,
+ ndValues: NdValue.values,
+ shutterSpeedValues: ShutterSpeedValue.values,
+ isoValues: IsoValue.values,
+ ),
+];
+
+const mockFilms = [_MockFilm(100, 2), _MockFilm(400, 2), _MockFilm(3, 800), _MockFilm(400, 1.5)];
+
+class _MockFilm extends Film {
+ final double reciprocityMultiplier;
+
+ const _MockFilm(int iso, this.reciprocityMultiplier) : super('Mock film $iso x$reciprocityMultiplier', iso);
+
+ @override
+ double reciprocityFormula(double t) => t * reciprocityMultiplier;
+}
diff --git a/integration_test/utils/platform_channel_mock.dart b/integration_test/utils/platform_channel_mock.dart
new file mode 100644
index 0000000..383f243
--- /dev/null
+++ b/integration_test/utils/platform_channel_mock.dart
@@ -0,0 +1,59 @@
+import 'dart:math';
+
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:light_sensor/light_sensor.dart';
+
+void setLightSensorAvilability({required bool hasSensor}) {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
+ LightSensor.methodChannel,
+ (methodCall) async {
+ switch (methodCall.method) {
+ case "sensor":
+ return hasSensor;
+ default:
+ return null;
+ }
+ },
+ );
+}
+
+void resetLightSensorAvilability() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
+ LightSensor.methodChannel,
+ null,
+ );
+}
+
+Future sendMockIncidentEv(double ev) => sendMockLux((2.5 * pow(2, ev)).toInt());
+
+Future sendMockLux([int lux = 100]) async {
+ await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
+ LightSensor.eventChannel.name,
+ const StandardMethodCodec().encodeSuccessEnvelope(lux),
+ (ByteData? data) {},
+ );
+}
+
+void setupLightSensorStreamHandler() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
+ MethodChannel(LightSensor.eventChannel.name),
+ (methodCall) async {
+ switch (methodCall.method) {
+ case "listen":
+ return;
+ case "cancel":
+ return;
+ default:
+ return null;
+ }
+ },
+ );
+}
+
+void resetLightSensorStreamHandler() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
+ MethodChannel(LightSensor.eventChannel.name),
+ null,
+ );
+}
diff --git a/integration_test/utils/widget_tester_actions.dart b/integration_test/utils/widget_tester_actions.dart
new file mode 100644
index 0000000..8411634
--- /dev/null
+++ b/integration_test/utils/widget_tester_actions.dart
@@ -0,0 +1,84 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/application.dart';
+import 'package:lightmeter/application_wrapper.dart';
+import 'package:lightmeter/environment.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/res/dimens.dart';
+import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+
+import '../mocks/paid_features_mock.dart';
+import 'platform_channel_mock.dart';
+
+extension WidgetTesterCommonActions on WidgetTester {
+ Future pumpApplication({
+ IAPProductStatus productStatus = IAPProductStatus.purchased,
+ String selectedEquipmentProfileId = '',
+ Film selectedFilm = const Film.other(),
+ }) async {
+ await pumpWidget(
+ IAPProducts(
+ products: [
+ IAPProduct(
+ storeId: IAPProductType.paidFeatures.storeId,
+ status: productStatus,
+ ),
+ ],
+ child: ApplicationWrapper(
+ const Environment.dev(),
+ child: MockIAPProviders(
+ selectedEquipmentProfileId: selectedEquipmentProfileId,
+ selectedFilm: selectedFilm,
+ child: const Application(),
+ ),
+ ),
+ ),
+ );
+ await pumpAndSettle();
+ }
+
+ Future takePhoto() async {
+ await tap(find.byType(MeteringMeasureButton));
+ await pump(const Duration(seconds: 2)); // wait for circular progress indicator
+ await pump(const Duration(seconds: 1)); // wait for circular progress indicator
+ await pumpAndSettle();
+ }
+
+ Future toggleIncidentMetering(double ev) async {
+ await tap(find.byType(MeteringMeasureButton));
+ await sendMockIncidentEv(ev);
+ await tap(find.byType(MeteringMeasureButton));
+ await pumpAndSettle();
+ }
+
+ Future openAnimatedPicker() async {
+ await tap(find.byType(T));
+ await pumpAndSettle(Dimens.durationL);
+ }
+}
+
+extension WidgetTesterListTileActions on WidgetTester {
+ /// Useful for tapping a specific [ListTile] inside a specific screen or dialog
+ Future tapDescendantTextOf(String text) async {
+ await tap(find.descendant(of: find.byType(T), matching: find.text(text)));
+ }
+}
+
+extension WidgetTesterTextButtonActions on WidgetTester {
+ Future tapSelectButton() => _tapTextButton(S.current.select);
+
+ Future tapCancelButton() => _tapTextButton(S.current.cancel);
+
+ Future tapSaveButton() => _tapTextButton(S.current.save);
+
+ Future _tapTextButton(String text) async {
+ final button = find.byWidgetPredicate(
+ (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == text,
+ );
+ expect(button, findsOneWidget);
+ await tap(button);
+ await pumpAndSettle();
+ }
+}
diff --git a/lib/application_wrapper.dart b/lib/application_wrapper.dart
index f8627f9..d2975d6 100644
--- a/lib/application_wrapper.dart
+++ b/lib/application_wrapper.dart
@@ -6,6 +6,8 @@ import 'package:lightmeter/data/permissions_service.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/data/volume_events_service.dart';
import 'package:lightmeter/environment.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
+import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
@@ -27,19 +29,26 @@ class ApplicationWrapper extends StatelessWidget {
]),
builder: (_, snapshot) {
if (snapshot.data != null) {
- return IAPProviders(
- sharedPreferences: snapshot.data![0] as SharedPreferences,
- child: ServicesProvider(
- caffeineService: const CaffeineService(),
- environment: env.copyWith(hasLightSensor: snapshot.data![1] as bool),
- hapticsService: const HapticsService(),
- lightSensorService: const LightSensorService(LocalPlatform()),
- permissionsService: const PermissionsService(),
- userPreferencesService:
- UserPreferencesService(snapshot.data![0] as SharedPreferences),
- volumeEventsService: const VolumeEventsService(LocalPlatform()),
- child: UserPreferencesProvider(
- child: child,
+ final iapService = IAPStorageService(snapshot.data![0] as SharedPreferences);
+ final userPreferencesService = UserPreferencesService(snapshot.data![0] as SharedPreferences);
+ final hasLightSensor = snapshot.data![1] as bool;
+ return ServicesProvider(
+ caffeineService: const CaffeineService(),
+ environment: env.copyWith(hasLightSensor: hasLightSensor),
+ hapticsService: const HapticsService(),
+ lightSensorService: const LightSensorService(LocalPlatform()),
+ permissionsService: const PermissionsService(),
+ userPreferencesService: userPreferencesService,
+ volumeEventsService: const VolumeEventsService(LocalPlatform()),
+ child: EquipmentProfileProvider(
+ storageService: iapService,
+ child: FilmsProvider(
+ storageService: iapService,
+ child: UserPreferencesProvider(
+ hasLightSensor: hasLightSensor,
+ userPreferencesService: userPreferencesService,
+ child: child,
+ ),
),
),
);
diff --git a/lib/data/light_sensor_service.dart b/lib/data/light_sensor_service.dart
index c837dde..a4b95d7 100644
--- a/lib/data/light_sensor_service.dart
+++ b/lib/data/light_sensor_service.dart
@@ -11,7 +11,7 @@ class LightSensorService {
return false;
}
try {
- return await LightSensor.hasSensor ?? false;
+ return await LightSensor.hasSensor();
} catch (_) {
return false;
}
@@ -21,6 +21,6 @@ class LightSensorService {
if (!localPlatform.isAndroid) {
return const Stream.empty();
}
- return LightSensor.lightSensorStream;
+ return LightSensor.luxStream();
}
}
diff --git a/lib/main_dev.dart b/lib/main_dev.dart
index 9ef3faf..b43352f 100644
--- a/lib/main_dev.dart
+++ b/lib/main_dev.dart
@@ -2,8 +2,17 @@ import 'package:flutter/material.dart';
import 'package:lightmeter/application.dart';
import 'package:lightmeter/application_wrapper.dart';
import 'package:lightmeter/environment.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
- runApp(const ApplicationWrapper(Environment.dev(), child: Application()));
+ runApp(
+ IAPProducts(
+ products: [IAPProduct(storeId: IAPProductType.paidFeatures.storeId)],
+ child: const ApplicationWrapper(
+ Environment.dev(),
+ child: Application(),
+ ),
+ ),
+ );
}
diff --git a/lib/main_prod.dart b/lib/main_prod.dart
index b75513e..3460f32 100644
--- a/lib/main_prod.dart
+++ b/lib/main_prod.dart
@@ -3,9 +3,17 @@ import 'package:lightmeter/application.dart';
import 'package:lightmeter/application_wrapper.dart';
import 'package:lightmeter/environment.dart';
import 'package:lightmeter/firebase.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeFirebase(handleErrors: true);
- runApp(const ApplicationWrapper(Environment.prod(), child: Application()));
+ runApp(
+ const IAPProductsProvider(
+ child: ApplicationWrapper(
+ Environment.prod(),
+ child: Application(),
+ ),
+ ),
+ );
}
diff --git a/lib/main_release.dart b/lib/main_release.dart
index bb6384a..eea83e2 100644
--- a/lib/main_release.dart
+++ b/lib/main_release.dart
@@ -3,9 +3,17 @@ import 'package:lightmeter/application.dart';
import 'package:lightmeter/application_wrapper.dart';
import 'package:lightmeter/environment.dart';
import 'package:lightmeter/firebase.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
Future main() async {
WidgetsFlutterBinding.ensureInitialized();
await initializeFirebase(handleErrors: false);
- runApp(const ApplicationWrapper(Environment.prod(), child: Application()));
+ runApp(
+ const IAPProductsProvider(
+ child: ApplicationWrapper(
+ Environment.prod(),
+ child: Application(),
+ ),
+ ),
+ );
}
diff --git a/lib/providers/equipment_profile_provider.dart b/lib/providers/equipment_profile_provider.dart
new file mode 100644
index 0000000..a5e0999
--- /dev/null
+++ b/lib/providers/equipment_profile_provider.dart
@@ -0,0 +1,130 @@
+import 'package:flutter/material.dart';
+import 'package:lightmeter/utils/selectable_provider.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:uuid/uuid.dart';
+
+class EquipmentProfileProvider extends StatefulWidget {
+ final IAPStorageService storageService;
+ final Widget child;
+
+ const EquipmentProfileProvider({
+ required this.storageService,
+ required this.child,
+ super.key,
+ });
+
+ static EquipmentProfileProviderState of(BuildContext context) {
+ return context.findAncestorStateOfType()!;
+ }
+
+ @override
+ State createState() => EquipmentProfileProviderState();
+}
+
+class EquipmentProfileProviderState extends State {
+ static const EquipmentProfile _defaultProfile = EquipmentProfile(
+ id: '',
+ name: '',
+ apertureValues: ApertureValue.values,
+ ndValues: NdValue.values,
+ shutterSpeedValues: ShutterSpeedValue.values,
+ isoValues: IsoValue.values,
+ );
+
+ List _customProfiles = [];
+ String _selectedId = '';
+
+ EquipmentProfile get _selectedProfile => _customProfiles.firstWhere(
+ (e) => e.id == _selectedId,
+ orElse: () => _defaultProfile,
+ );
+
+ @override
+ void initState() {
+ super.initState();
+ _selectedId = widget.storageService.selectedEquipmentProfileId;
+ _customProfiles = widget.storageService.equipmentProfiles;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return EquipmentProfiles(
+ values: [
+ _defaultProfile,
+ if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ..._customProfiles,
+ ],
+ selected: IAPProducts.isPurchased(context, IAPProductType.paidFeatures)
+ ? _selectedProfile
+ : _defaultProfile,
+ child: widget.child,
+ );
+ }
+
+ void setProfile(EquipmentProfile data) {
+ if (_selectedId != data.id) {
+ setState(() {
+ _selectedId = data.id;
+ });
+ widget.storageService.selectedEquipmentProfileId = _selectedProfile.id;
+ }
+ }
+
+ /// Creates a default equipment profile
+ void addProfile(String name, [EquipmentProfile? copyFrom]) {
+ _customProfiles.add(
+ EquipmentProfile(
+ id: const Uuid().v1(),
+ name: name,
+ apertureValues: copyFrom?.apertureValues ?? ApertureValue.values,
+ ndValues: copyFrom?.ndValues ?? NdValue.values,
+ shutterSpeedValues: copyFrom?.shutterSpeedValues ?? ShutterSpeedValue.values,
+ isoValues: copyFrom?.isoValues ?? IsoValue.values,
+ ),
+ );
+ _refreshSavedProfiles();
+ }
+
+ void updateProdile(EquipmentProfile data) {
+ final indexToUpdate = _customProfiles.indexWhere((element) => element.id == data.id);
+ if (indexToUpdate >= 0) {
+ _customProfiles[indexToUpdate] = data;
+ _refreshSavedProfiles();
+ }
+ }
+
+ void deleteProfile(EquipmentProfile data) {
+ if (data.id == _selectedId) {
+ _selectedId = _defaultProfile.id;
+ widget.storageService.selectedEquipmentProfileId = _defaultProfile.id;
+ }
+ _customProfiles.remove(data);
+ _refreshSavedProfiles();
+ }
+
+ void _refreshSavedProfiles() {
+ widget.storageService.equipmentProfiles = _customProfiles;
+ setState(() {});
+ }
+}
+
+class EquipmentProfiles extends SelectableInheritedModel {
+ const EquipmentProfiles({
+ super.key,
+ required super.values,
+ required super.selected,
+ required super.child,
+ });
+
+ /// [_defaultProfile] + profiles created by the user
+ static List of(BuildContext context) {
+ return InheritedModel.inheritFrom(context, aspect: SelectableAspect.list)!
+ .values;
+ }
+
+ static EquipmentProfile selectedOf(BuildContext context) {
+ return InheritedModel.inheritFrom(context,
+ aspect: SelectableAspect.selected,)!
+ .selected;
+ }
+}
diff --git a/lib/providers/films_provider.dart b/lib/providers/films_provider.dart
new file mode 100644
index 0000000..aff6d01
--- /dev/null
+++ b/lib/providers/films_provider.dart
@@ -0,0 +1,107 @@
+import 'package:flutter/material.dart';
+import 'package:lightmeter/utils/selectable_provider.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+
+class FilmsProvider extends StatefulWidget {
+ final IAPStorageService storageService;
+ final List? availableFilms;
+ final Widget child;
+
+ const FilmsProvider({
+ required this.storageService,
+ this.availableFilms,
+ required this.child,
+ super.key,
+ });
+
+ static FilmsProviderState of(BuildContext context) {
+ return context.findAncestorStateOfType()!;
+ }
+
+ @override
+ State createState() => FilmsProviderState();
+}
+
+class FilmsProviderState extends State {
+ late List _filmsInUse;
+ late Film _selected;
+
+ @override
+ void initState() {
+ super.initState();
+ _filmsInUse = widget.storageService.filmsInUse;
+ _selected = widget.storageService.selectedFilm;
+ _discardSelectedIfNotIncluded();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Films(
+ values: [
+ const Film.other(),
+ ...widget.availableFilms ?? films,
+ ],
+ filmsInUse: [
+ const Film.other(),
+ if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ..._filmsInUse,
+ ],
+ selected: IAPProducts.isPurchased(context, IAPProductType.paidFeatures)
+ ? _selected
+ : const Film.other(),
+ child: widget.child,
+ );
+ }
+
+ void setFilm(Film film) {
+ if (_selected != film) {
+ _selected = film;
+ widget.storageService.selectedFilm = film;
+ setState(() {});
+ }
+ }
+
+ void saveFilms(List films) {
+ _filmsInUse = films;
+ widget.storageService.filmsInUse = films;
+ _discardSelectedIfNotIncluded();
+ setState(() {});
+ }
+
+ void _discardSelectedIfNotIncluded() {
+ if (_selected != const Film.other() && !_filmsInUse.contains(_selected)) {
+ _selected = const Film.other();
+ widget.storageService.selectedFilm = const Film.other();
+ }
+ }
+}
+
+class Films extends SelectableInheritedModel {
+ final List filmsInUse;
+
+ const Films({
+ super.key,
+ required super.values,
+ required this.filmsInUse,
+ required super.selected,
+ required super.child,
+ });
+
+ /// [Film.other()] + all the custom fields with actual reciprocity formulas
+ static List of(BuildContext context) {
+ return InheritedModel.inheritFrom(context)!.values;
+ }
+
+ /// [Film.other()] + films in use selected by user
+ static List inUseOf(BuildContext context) {
+ return InheritedModel.inheritFrom(
+ context,
+ aspect: SelectableAspect.list,
+ )!
+ .filmsInUse;
+ }
+
+ static Film selectedOf(BuildContext context) {
+ return InheritedModel.inheritFrom(context, aspect: SelectableAspect.selected)!.selected;
+ }
+}
diff --git a/lib/providers/services_provider.dart b/lib/providers/services_provider.dart
index c2c548f..e65aa96 100644
--- a/lib/providers/services_provider.dart
+++ b/lib/providers/services_provider.dart
@@ -7,6 +7,7 @@ import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/data/volume_events_service.dart';
import 'package:lightmeter/environment.dart';
+// coverage:ignore-start
class ServicesProvider extends InheritedWidget {
final CaffeineService caffeineService;
final Environment environment;
@@ -34,3 +35,4 @@ class ServicesProvider extends InheritedWidget {
@override
bool updateShouldNotify(ServicesProvider oldWidget) => false;
}
+// coverage:ignore-end
diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart
index af644d1..3a4111c 100644
--- a/lib/providers/user_preferences_provider.dart
+++ b/lib/providers/user_preferences_provider.dart
@@ -8,14 +8,20 @@ import 'package:lightmeter/data/models/supported_locale.dart';
import 'package:lightmeter/data/models/theme_type.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/generated/l10n.dart';
-import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/res/theme.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class UserPreferencesProvider extends StatefulWidget {
+ final bool hasLightSensor;
+ final UserPreferencesService userPreferencesService;
final Widget child;
- const UserPreferencesProvider({required this.child, super.key});
+ const UserPreferencesProvider({
+ required this.hasLightSensor,
+ required this.userPreferencesService,
+ required this.child,
+ super.key,
+ });
static _UserPreferencesProviderState of(BuildContext context) {
return context.findAncestorStateOfType<_UserPreferencesProviderState>()!;
@@ -38,8 +44,7 @@ class UserPreferencesProvider extends StatefulWidget {
}
static bool meteringScreenFeatureOf(BuildContext context, MeteringScreenLayoutFeature feature) {
- return InheritedModel.inheritFrom<_MeteringScreenLayoutModel>(context, aspect: feature)!
- .data[feature]!;
+ return InheritedModel.inheritFrom<_MeteringScreenLayoutModel>(context, aspect: feature)!.data[feature]!;
}
static StopType stopTypeOf(BuildContext context) {
@@ -65,28 +70,20 @@ class UserPreferencesProvider extends StatefulWidget {
State createState() => _UserPreferencesProviderState();
}
-class _UserPreferencesProviderState extends State
- with WidgetsBindingObserver {
- UserPreferencesService get userPreferencesService =>
- ServicesProvider.of(context).userPreferencesService;
-
- late bool dynamicColor = userPreferencesService.dynamicColor;
- late EvSourceType evSourceType;
- late MeteringScreenLayoutConfig meteringScreenLayout =
- userPreferencesService.meteringScreenLayout;
- late Color primaryColor = userPreferencesService.primaryColor;
- late StopType stopType = userPreferencesService.stopType;
- late SupportedLocale locale = userPreferencesService.locale;
- late ThemeType themeType = userPreferencesService.themeType;
+class _UserPreferencesProviderState extends State with WidgetsBindingObserver {
+ late EvSourceType _evSourceType;
+ late StopType _stopType = widget.userPreferencesService.stopType;
+ late MeteringScreenLayoutConfig _meteringScreenLayout = widget.userPreferencesService.meteringScreenLayout;
+ late SupportedLocale _locale = widget.userPreferencesService.locale;
+ late ThemeType _themeType = widget.userPreferencesService.themeType;
+ late Color _primaryColor = widget.userPreferencesService.primaryColor;
+ late bool _dynamicColor = widget.userPreferencesService.dynamicColor;
@override
void initState() {
super.initState();
- evSourceType = userPreferencesService.evSourceType;
- evSourceType = evSourceType == EvSourceType.sensor &&
- !ServicesProvider.of(context).environment.hasLightSensor
- ? EvSourceType.camera
- : evSourceType;
+ _evSourceType = widget.userPreferencesService.evSourceType;
+ _evSourceType = _evSourceType == EvSourceType.sensor && !widget.hasLightSensor ? EvSourceType.camera : _evSourceType;
WidgetsBinding.instance.addObserver(this);
}
@@ -109,9 +106,8 @@ class _UserPreferencesProviderState extends State
late final DynamicColorState state;
late final Color? dynamicPrimaryColor;
if (lightDynamic != null && darkDynamic != null) {
- if (dynamicColor) {
- dynamicPrimaryColor =
- (_themeBrightness == Brightness.light ? lightDynamic : darkDynamic).primary;
+ if (_dynamicColor) {
+ dynamicPrimaryColor = (_themeBrightness == Brightness.light ? lightDynamic : darkDynamic).primary;
state = DynamicColorState.enabled;
} else {
dynamicPrimaryColor = null;
@@ -124,13 +120,13 @@ class _UserPreferencesProviderState extends State
return _UserPreferencesModel(
brightness: _themeBrightness,
dynamicColorState: state,
- evSourceType: evSourceType,
- locale: locale,
- primaryColor: dynamicPrimaryColor ?? primaryColor,
- stopType: stopType,
- themeType: themeType,
+ evSourceType: _evSourceType,
+ locale: _locale,
+ primaryColor: dynamicPrimaryColor ?? _primaryColor,
+ stopType: _stopType,
+ themeType: _themeType,
child: _MeteringScreenLayoutModel(
- data: meteringScreenLayout,
+ data: _meteringScreenLayout,
child: widget.child,
),
);
@@ -140,65 +136,65 @@ class _UserPreferencesProviderState extends State
void enableDynamicColor(bool enable) {
setState(() {
- dynamicColor = enable;
+ _dynamicColor = enable;
});
- userPreferencesService.dynamicColor = enable;
+ widget.userPreferencesService.dynamicColor = enable;
}
void toggleEvSourceType() {
- if (!ServicesProvider.of(context).environment.hasLightSensor) {
+ if (!widget.hasLightSensor) {
return;
}
setState(() {
- switch (evSourceType) {
+ switch (_evSourceType) {
case EvSourceType.camera:
- evSourceType = EvSourceType.sensor;
+ _evSourceType = EvSourceType.sensor;
case EvSourceType.sensor:
- evSourceType = EvSourceType.camera;
+ _evSourceType = EvSourceType.camera;
}
});
- userPreferencesService.evSourceType = evSourceType;
+ widget.userPreferencesService.evSourceType = _evSourceType;
}
void setLocale(SupportedLocale locale) {
S.load(Locale(locale.intlName)).then((value) {
setState(() {
- this.locale = locale;
+ _locale = locale;
});
- userPreferencesService.locale = locale;
+ widget.userPreferencesService.locale = locale;
});
}
void setMeteringScreenLayout(MeteringScreenLayoutConfig config) {
setState(() {
- meteringScreenLayout = config;
+ _meteringScreenLayout = config;
});
- userPreferencesService.meteringScreenLayout = meteringScreenLayout;
+ widget.userPreferencesService.meteringScreenLayout = _meteringScreenLayout;
}
void setPrimaryColor(Color primaryColor) {
setState(() {
- this.primaryColor = primaryColor;
+ _primaryColor = primaryColor;
});
- userPreferencesService.primaryColor = primaryColor;
+ widget.userPreferencesService.primaryColor = primaryColor;
}
void setStopType(StopType stopType) {
setState(() {
- this.stopType = stopType;
+ _stopType = stopType;
});
- userPreferencesService.stopType = stopType;
+ widget.userPreferencesService.stopType = stopType;
}
void setThemeType(ThemeType themeType) {
setState(() {
- this.themeType = themeType;
+ _themeType = themeType;
});
- userPreferencesService.themeType = themeType;
+ widget.userPreferencesService.themeType = themeType;
}
Brightness get _themeBrightness {
- switch (themeType) {
+ switch (_themeType) {
case ThemeType.light:
return Brightness.light;
case ThemeType.dark:
@@ -258,8 +254,7 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> {
_UserPreferencesModel oldWidget,
Set<_Aspect> dependencies,
) {
- return (dependencies.contains(_Aspect.dynamicColorState) &&
- dynamicColorState != oldWidget.dynamicColorState) ||
+ return (dependencies.contains(_Aspect.dynamicColorState) && dynamicColorState != oldWidget.dynamicColorState) ||
(dependencies.contains(_Aspect.evSourceType) && evSourceType != oldWidget.evSourceType) ||
(dependencies.contains(_Aspect.locale) && locale != oldWidget.locale) ||
(dependencies.contains(_Aspect.stopType) && stopType != oldWidget.stopType) ||
diff --git a/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart b/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart
index 4051c5e..cb78275 100644
--- a/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart
+++ b/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/exposure_pair.dart';
import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/res/dimens.dart';
-
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/components/exposure_pairs_list_item/widget_item_list_exposure_pairs.dart';
import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class ExposurePairsList extends StatelessWidget {
final List exposurePairs;
@@ -33,6 +32,7 @@ class ExposurePairsList extends StatelessWidget {
alignment: Alignment.center,
children: [
Row(
+ key: ValueKey(index),
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
diff --git a/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart b/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart
index f47cd53..adbe1d2 100644
--- a/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart
+++ b/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class EquipmentProfilePicker extends StatelessWidget {
diff --git a/lib/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart b/lib/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart
index 54c786c..5c701e2 100644
--- a/lib/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart
+++ b/lib/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/exposure_pair.dart';
import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class ExtremeExposurePairsContainer extends StatelessWidget {
final ExposurePair? fastest;
diff --git a/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart b/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart
index ae1e6fe..13a9366 100644
--- a/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart
+++ b/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class FilmPicker extends StatelessWidget {
diff --git a/lib/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart b/lib/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart
index eda016c..fdd8f0a 100644
--- a/lib/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart
+++ b/lib/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart
@@ -27,14 +27,13 @@ class NdValuePicker extends StatelessWidget {
value.value == 0 ? S.of(context).none : value.value.toString(),
),
// using descending order, because ND filter darkens image & lowers EV
- itemTrailingBuilder: (selected, value) => value.value != selected.value
- ? Text(S.of(context).evValue(value.toStringDifference(selected)))
- : null,
+ itemTrailingBuilder: (selected, value) =>
+ value.value != selected.value ? Text(S.of(context).evValue(value.toStringDifference(selected))) : null,
onChanged: onChanged,
closedChild: ReadingValueContainer.singleValue(
value: ReadingValue(
label: S.of(context).nd,
- value: selectedValue.value.toString(),
+ value: selectedValue.value == 0 ? S.of(context).none : selectedValue.value.toString(),
),
),
);
diff --git a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart
index f10546d..cb8af05 100644
--- a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart
+++ b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/exposure_pair.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart';
@@ -8,7 +9,6 @@ import 'package:lightmeter/screens/metering/components/shared/readings_container
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/nd_picker/widget_picker_nd.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class ReadingsContainer extends StatelessWidget {
diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart
index f1d11fb..6900159 100644
--- a/lib/screens/metering/screen_metering.dart
+++ b/lib/screens/metering/screen_metering.dart
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/exposure_pair.dart';
-import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:lightmeter/screens/metering/bloc_metering.dart';
@@ -13,9 +13,7 @@ import 'package:lightmeter/screens/metering/components/camera_container/provider
import 'package:lightmeter/screens/metering/components/light_sensor_container/provider_container_light_sensor.dart';
import 'package:lightmeter/screens/metering/event_metering.dart';
import 'package:lightmeter/screens/metering/state_metering.dart';
-import 'package:lightmeter/screens/metering/utils/listener_metering_layout_feature.dart';
import 'package:lightmeter/screens/metering/utils/listsner_equipment_profiles.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class MeteringScreen extends StatelessWidget {
@@ -73,15 +71,7 @@ class _InheritedListeners extends StatelessWidget {
onDidChangeDependencies: (value) {
context.read().add(EquipmentProfileChangedEvent(value));
},
- child: MeteringScreenLayoutFeatureListener(
- feature: MeteringScreenLayoutFeature.filmPicker,
- onDidChangeDependencies: (value) {
- if (!value) {
- FilmsProvider.of(context).setFilm(const Film.other());
- }
- },
- child: child,
- ),
+ child: child,
);
}
}
diff --git a/lib/screens/metering/utils/listener_metering_layout_feature.dart b/lib/screens/metering/utils/listener_metering_layout_feature.dart
deleted file mode 100644
index c245ec3..0000000
--- a/lib/screens/metering/utils/listener_metering_layout_feature.dart
+++ /dev/null
@@ -1,52 +0,0 @@
-import 'package:flutter/material.dart';
-import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
-import 'package:lightmeter/providers/user_preferences_provider.dart';
-
-/// Listening to multiple dependencies at the same time causes firing an event for all dependencies
-/// even though some of them didn't change:
-/// ```dart
-/// @override
-/// void didChangeDependencies() {
-/// super.didChangeDependencies();
-/// _bloc.add(EquipmentProfileChangedEvent(EquipmentProfile.of(context)));
-/// if (!MeteringScreenLayout.featureStatusOf(context, MeteringScreenLayoutFeature.filmPicker)) {
-/// _bloc.add(const FilmChangedEvent(Film.other()));
-/// }
-/// }
-/// ```
-/// To overcome this issue I've decided to create a generic listener,
-/// that will listen to each dependency separately.
-class MeteringScreenLayoutFeatureListener extends StatefulWidget {
- final MeteringScreenLayoutFeature feature;
- final ValueChanged onDidChangeDependencies;
- final Widget child;
-
- const MeteringScreenLayoutFeatureListener({
- required this.feature,
- required this.onDidChangeDependencies,
- required this.child,
- super.key,
- });
-
- @override
- State createState() =>
- _MeteringScreenLayoutFeatureListenerState();
-}
-
-class _MeteringScreenLayoutFeatureListenerState extends State {
- @override
- void didChangeDependencies() {
- super.didChangeDependencies();
- widget.onDidChangeDependencies(
- UserPreferencesProvider.meteringScreenFeatureOf(
- context,
- widget.feature,
- ),
- );
- }
-
- @override
- Widget build(BuildContext context) {
- return widget.child;
- }
-}
diff --git a/lib/screens/metering/utils/listsner_equipment_profiles.dart b/lib/screens/metering/utils/listsner_equipment_profiles.dart
index 68d03dc..ec604ce 100644
--- a/lib/screens/metering/utils/listsner_equipment_profiles.dart
+++ b/lib/screens/metering/utils/listsner_equipment_profiles.dart
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class EquipmentProfileListener extends StatefulWidget {
diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart
index 3c72918..b10190f 100644
--- a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart
+++ b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart
@@ -1,12 +1,11 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
-
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/widget_container_equipment_profile.dart';
import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_name_dialog/widget_dialog_equipment_profile_name.dart';
import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart';
import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class EquipmentProfilesScreen extends StatefulWidget {
diff --git a/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart b/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart
index c343e2b..72ff433 100644
--- a/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart
+++ b/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/films_provider.dart';
import 'package:lightmeter/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart';
import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class FilmsListTile extends StatelessWidget {
diff --git a/lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart b/lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart
index 57aaf24..2529e2e 100644
--- a/lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart
+++ b/lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart
@@ -1,21 +1,21 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/generated/l10n.dart';
+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/res/dimens.dart';
-import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class MeteringScreenLayoutFeaturesDialog extends StatefulWidget {
const MeteringScreenLayoutFeaturesDialog({super.key});
@override
- State createState() =>
- _MeteringScreenLayoutFeaturesDialogState();
+ State createState() => _MeteringScreenLayoutFeaturesDialogState();
}
class _MeteringScreenLayoutFeaturesDialogState extends State {
- late final _features =
- MeteringScreenLayoutConfig.from(UserPreferencesProvider.meteringScreenConfigOf(context));
+ late final _features = MeteringScreenLayoutConfig.from(UserPreferencesProvider.meteringScreenConfigOf(context));
@override
Widget build(BuildContext context) {
@@ -57,6 +57,9 @@ class _MeteringScreenLayoutFeaturesDialogState extends State *also in dark mode
+
+> **Android only
+
+## Run the generator
+
+```console
+sh screenshots/generate_screenshots.sh
+```
+
+Screenshots will be stored in the _screenshots/_ folder.
diff --git a/screenshots/generate_screenshots.dart b/screenshots/generate_screenshots.dart
new file mode 100644
index 0000000..8c8e7e9
--- /dev/null
+++ b/screenshots/generate_screenshots.dart
@@ -0,0 +1,136 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:integration_test/integration_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/models/theme_type.dart';
+import 'package:lightmeter/data/models/volume_action.dart';
+import 'package:lightmeter/data/shared_prefs_service.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/res/theme.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
+import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart';
+import 'package:lightmeter/screens/settings/screen_settings.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../integration_test/mocks/paid_features_mock.dart';
+import '../integration_test/utils/widget_tester_actions.dart';
+
+//https://stackoverflow.com/a/67186625/13167574
+
+/// Just a screenshot generator. No expectations here.
+void main() {
+ final binding = IntegrationTestWidgetsFlutterBinding();
+ IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+ final Color lightThemeColor = primaryColorsList[5];
+ final Color darkThemeColor = primaryColorsList[3];
+
+ void mockSharedPrefs(ThemeType theme, Color color) {
+ // ignore: invalid_use_of_visible_for_testing_member
+ SharedPreferences.setMockInitialValues({
+ /// Metering values
+ UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
+ UserPreferencesService.isoKey: 400,
+ UserPreferencesService.ndFilterKey: 0,
+
+ /// Metering settings
+ UserPreferencesService.stopTypeKey: StopType.third.index,
+ UserPreferencesService.cameraEvCalibrationKey: 0.0,
+ UserPreferencesService.lightSensorEvCalibrationKey: 0.0,
+ UserPreferencesService.meteringScreenLayoutKey: json.encode(
+ {
+ MeteringScreenLayoutFeature.equipmentProfiles: true,
+ MeteringScreenLayoutFeature.extremeExposurePairs: true,
+ MeteringScreenLayoutFeature.filmPicker: true,
+ MeteringScreenLayoutFeature.histogram: false,
+ }.toJson(),
+ ),
+
+ /// General settings
+ UserPreferencesService.caffeineKey: true,
+ UserPreferencesService.hapticsKey: true,
+ UserPreferencesService.volumeActionKey: VolumeAction.shutter.toString(),
+ UserPreferencesService.localeKey: 'en',
+
+ /// Theme settings
+ UserPreferencesService.themeTypeKey: theme.index,
+ UserPreferencesService.primaryColorKey: color.value,
+ UserPreferencesService.dynamicColorKey: false,
+ });
+ }
+
+ /// Generates several screenshots with the light theme
+ testWidgets(
+ 'Generate light theme screenshots',
+ (tester) async {
+ mockSharedPrefs(ThemeType.light, lightThemeColor);
+ await tester.pumpApplication();
+
+ await tester.takePhoto();
+ await tester.takeScreenshot(binding, '${lightThemeColor.value}_metering_reflected');
+
+ if (Platform.isAndroid) {
+ await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor));
+ await tester.pumpAndSettle();
+ await tester.toggleIncidentMetering(7.3);
+ await tester.takeScreenshot(binding, '${lightThemeColor.value}_metering_incident');
+ }
+
+ await tester.openAnimatedPicker();
+ await tester.takeScreenshot(binding, '${lightThemeColor.value}_metering_iso_picker');
+
+ await tester.tapCancelButton();
+ await tester.tap(find.byTooltip(S.current.tooltipOpenSettings));
+ await tester.pumpAndSettle();
+ await tester.takeScreenshot(binding, '${lightThemeColor.value}_settings');
+
+ await tester.tapDescendantTextOf(S.current.meteringScreenLayout);
+ await tester.takeScreenshot(binding, '${lightThemeColor.value}_settings_metering_screen_layout');
+
+ await tester.tapCancelButton();
+ await tester.tapDescendantTextOf(S.current.equipmentProfiles);
+ await tester.pumpAndSettle();
+ await tester.tapDescendantTextOf(mockEquipmentProfiles.first.name);
+ await tester.pumpAndSettle();
+ await tester.takeScreenshot(binding, '${lightThemeColor.value}-equipment_profiles');
+
+ await tester.tap(find.byIcon(Icons.iso).first);
+ await tester.pumpAndSettle();
+ await tester.takeScreenshot(binding, '${lightThemeColor.value}_equipment_profiles_iso_picker');
+ },
+ );
+
+ /// and the additionally the first one with the dark theme
+ testWidgets(
+ 'Generate dark theme screenshots',
+ (tester) async {
+ mockSharedPrefs(ThemeType.dark, darkThemeColor);
+ await tester.pumpApplication();
+
+ await tester.takePhoto();
+ await tester.takeScreenshot(binding, '${darkThemeColor.value}_metering_reflected');
+
+ if (Platform.isAndroid) {
+ await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor));
+ await tester.pumpAndSettle();
+ await tester.toggleIncidentMetering(7.3);
+ await tester.takeScreenshot(binding, '${darkThemeColor.value}_metering_incident');
+ }
+ },
+ );
+}
+
+extension on WidgetTester {
+ Future takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name) async {
+ if (Platform.isAndroid) {
+ await binding.convertFlutterSurfaceToImage();
+ await pumpAndSettle();
+ }
+ await binding.takeScreenshot(name);
+ await pumpAndSettle();
+ }
+}
diff --git a/integration_test/generate_screenshots.sh b/screenshots/generate_screenshots.sh
similarity index 83%
rename from integration_test/generate_screenshots.sh
rename to screenshots/generate_screenshots.sh
index 8296e96..c95568e 100644
--- a/integration_test/generate_screenshots.sh
+++ b/screenshots/generate_screenshots.sh
@@ -2,7 +2,7 @@ flutter drive \
--dart-define="cameraPreviewAspectRatio=240/320" \
--dart-define="cameraStubImage=assets/camera_stub_image.jpg" \
--driver=test_driver/screenshot_driver.dart \
- --target=integration_test/generate_screenshots.dart \
+ --target=screenshots/generate_screenshots.dart \
--profile \
--flavor=dev \
--no-dds \
diff --git a/test/application_mock.dart b/test/application_mock.dart
new file mode 100644
index 0000000..dbcf260
--- /dev/null
+++ b/test/application_mock.dart
@@ -0,0 +1,31 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/res/theme.dart';
+
+/// Provides [MaterialApp] with default theme and "en" localization
+class WidgetTestApplicationMock extends StatelessWidget {
+ final Widget child;
+
+ const WidgetTestApplicationMock({required this.child, super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ theme: themeFrom(primaryColorsList[5], Brightness.light),
+ locale: const Locale('en'),
+ localizationsDelegates: const [
+ S.delegate,
+ GlobalMaterialLocalizations.delegate,
+ GlobalWidgetsLocalizations.delegate,
+ GlobalCupertinoLocalizations.delegate,
+ ],
+ supportedLocales: S.delegate.supportedLocales,
+ builder: (context, child) => MediaQuery(
+ data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0),
+ child: child!,
+ ),
+ home: Scaffold(body: child),
+ );
+ }
+}
diff --git a/test/data/light_sensor_service_test.dart b/test/data/light_sensor_service_test.dart
index d29b50f..f8be45b 100644
--- a/test/data/light_sensor_service_test.dart
+++ b/test/data/light_sensor_service_test.dart
@@ -1,9 +1,11 @@
-import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:light_sensor/light_sensor.dart';
import 'package:lightmeter/data/light_sensor_service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:platform/platform.dart';
+import '../event_channel_mock.dart';
+
class _MockLocalPlatform extends Mock implements LocalPlatform {}
void main() {
@@ -12,68 +14,44 @@ void main() {
late _MockLocalPlatform localPlatform;
late LightSensorService service;
- const methodChannel = MethodChannel('system_feature');
- // TODO: add event channel mock
- //const eventChannel = EventChannel('light.eventChannel');
-
setUp(() {
localPlatform = _MockLocalPlatform();
service = LightSensorService(localPlatform);
});
- tearDown(() {
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(methodChannel, null);
- });
-
group(
'hasSensor()',
() {
+ void setMockSensorAvailability({required bool hasSensor}) {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
+ LightSensor.methodChannel,
+ (methodCall) async {
+ switch (methodCall.method) {
+ case "sensor":
+ return hasSensor;
+ default:
+ return null;
+ }
+ },
+ );
+ }
+
+ tearDown(() {
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
+ LightSensor.methodChannel,
+ null,
+ );
+ });
+
test('true - Android', () async {
when(() => localPlatform.isAndroid).thenReturn(true);
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(methodChannel, null);
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(methodChannel, (methodCall) async {
- switch (methodCall.method) {
- case "sensor":
- return true;
- default:
- return null;
- }
- });
+ setMockSensorAvailability(hasSensor: true);
expectLater(service.hasSensor(), completion(true));
});
test('false - Android', () async {
when(() => localPlatform.isAndroid).thenReturn(true);
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(methodChannel, null);
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(methodChannel, (methodCall) async {
- switch (methodCall.method) {
- case "sensor":
- return false;
- default:
- return null;
- }
- });
- expectLater(service.hasSensor(), completion(false));
- });
-
- test('null - Android', () async {
- when(() => localPlatform.isAndroid).thenReturn(true);
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(methodChannel, null);
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(methodChannel, (methodCall) async {
- switch (methodCall.method) {
- case "sensor":
- return null;
- default:
- return null;
- }
- });
+ setMockSensorAvailability(hasSensor: false);
expectLater(service.hasSensor(), completion(false));
});
@@ -85,10 +63,18 @@ void main() {
);
group('luxStream', () {
- // test('Android', () async {
- // when(() => localPlatform.isAndroid).thenReturn(true);
- // expect(service.luxStream(), const Stream.empty());
- // });
+ test('Android', () async {
+ when(() => localPlatform.isAndroid).thenReturn(true);
+ final stream = service.luxStream();
+ final List result = [];
+ final subscription = stream.listen(result.add);
+ await sendMockVolumeAction(LightSensor.eventChannel.name, 100);
+ await sendMockVolumeAction(LightSensor.eventChannel.name, 150);
+ await sendMockVolumeAction(LightSensor.eventChannel.name, 150);
+ await sendMockVolumeAction(LightSensor.eventChannel.name, 200);
+ expect(result, [100, 150, 150, 200]);
+ subscription.cancel();
+ });
test('iOS', () async {
when(() => localPlatform.isAndroid).thenReturn(false);
diff --git a/test/data/models/exposure_pair_test.dart b/test/data/models/exposure_pair_test.dart
new file mode 100644
index 0000000..9143750
--- /dev/null
+++ b/test/data/models/exposure_pair_test.dart
@@ -0,0 +1,38 @@
+import 'package:lightmeter/data/models/exposure_pair.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:test/test.dart';
+
+void main() {
+ test('toString()', () {
+ expect(
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first).toString(),
+ '${ApertureValue.values.first} - ${ShutterSpeedValue.values.first}',
+ );
+ });
+
+ test('==', () {
+ expect(
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first) ==
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first),
+ true,
+ );
+ expect(
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first) ==
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.last),
+ false,
+ );
+ });
+
+ test('hashCode', () {
+ expect(
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first).hashCode ==
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first).hashCode,
+ true,
+ );
+ expect(
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first).hashCode ==
+ ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.last).hashCode,
+ false,
+ );
+ });
+}
diff --git a/test/data/models/supported_locale_test.dart b/test/data/models/supported_locale_test.dart
index 6d92154..83f7489 100644
--- a/test/data/models/supported_locale_test.dart
+++ b/test/data/models/supported_locale_test.dart
@@ -6,11 +6,13 @@ void main() {
expect(SupportedLocale.en.intlName, 'en');
expect(SupportedLocale.fr.intlName, 'fr');
expect(SupportedLocale.ru.intlName, 'ru');
+ expect(SupportedLocale.zh.intlName, 'zh');
});
test('localizedName', () {
expect(SupportedLocale.en.localizedName, 'English');
expect(SupportedLocale.fr.localizedName, 'Français');
expect(SupportedLocale.ru.localizedName, 'Русский');
+ expect(SupportedLocale.zh.localizedName, '简体中文');
});
}
diff --git a/test/data/shared_prefs_service_test.dart b/test/data/shared_prefs_service_test.dart
index 0896626..8ec42e1 100644
--- a/test/data/shared_prefs_service_test.dart
+++ b/test/data/shared_prefs_service_test.dart
@@ -4,6 +4,7 @@ import 'package:lightmeter/data/models/ev_source_type.dart';
import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
import 'package:lightmeter/data/models/supported_locale.dart';
import 'package:lightmeter/data/models/theme_type.dart';
+import 'package:lightmeter/data/models/volume_action.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/res/theme.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
@@ -99,8 +100,7 @@ void main() {
});
test('set', () {
- when(() => sharedPreferences.setInt(UserPreferencesService.isoKey, 200))
- .thenAnswer((_) => Future.value(true));
+ when(() => sharedPreferences.setInt(UserPreferencesService.isoKey, 200)).thenAnswer((_) => Future.value(true));
service.iso = const IsoValue(200, StopType.full);
verify(() => sharedPreferences.setInt(UserPreferencesService.isoKey, 200)).called(1);
});
@@ -118,8 +118,7 @@ void main() {
});
test('set', () {
- when(() => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, 0))
- .thenAnswer((_) => Future.value(true));
+ when(() => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, 0)).thenAnswer((_) => Future.value(true));
service.ndFilter = const NdValue(0);
verify(() => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, 0)).called(1);
});
@@ -175,8 +174,7 @@ void main() {
});
test('set', () {
- when(() => sharedPreferences.setInt(UserPreferencesService.stopTypeKey, 0))
- .thenAnswer((_) => Future.value(true));
+ when(() => sharedPreferences.setInt(UserPreferencesService.stopTypeKey, 0)).thenAnswer((_) => Future.value(true));
service.stopType = StopType.full;
verify(() => sharedPreferences.setInt(UserPreferencesService.stopTypeKey, 0)).called(1);
});
@@ -253,6 +251,26 @@ void main() {
verify(() => sharedPreferences.setBool(UserPreferencesService.hapticsKey, false)).called(1);
});
});
+ group('volumeAction', () {
+ test('get default', () {
+ when(() => sharedPreferences.getBool(UserPreferencesService.volumeActionKey)).thenReturn(null);
+ expect(service.volumeAction, VolumeAction.shutter);
+ });
+
+ test('get', () {
+ when(() => sharedPreferences.getString(UserPreferencesService.volumeActionKey))
+ .thenReturn(VolumeAction.shutter.toString());
+ expect(service.volumeAction, VolumeAction.shutter);
+ });
+
+ test('set', () {
+ when(() => sharedPreferences.setString(UserPreferencesService.volumeActionKey, VolumeAction.shutter.toString()))
+ .thenAnswer((_) => Future.value(true));
+ service.volumeAction = VolumeAction.shutter;
+ verify(() => sharedPreferences.setString(UserPreferencesService.volumeActionKey, VolumeAction.shutter.toString()))
+ .called(1);
+ });
+ });
group('locale', () {
test('get default', () {
@@ -261,8 +279,7 @@ void main() {
});
test('get', () {
- when(() => sharedPreferences.getString(UserPreferencesService.localeKey))
- .thenReturn('SupportedLocale.ru');
+ when(() => sharedPreferences.getString(UserPreferencesService.localeKey)).thenReturn('SupportedLocale.ru');
expect(service.locale, SupportedLocale.ru);
});
@@ -279,14 +296,12 @@ void main() {
group('cameraEvCalibration', () {
test('get default', () {
- when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey))
- .thenReturn(null);
+ when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey)).thenReturn(null);
expect(service.cameraEvCalibration, 0.0);
});
test('get', () {
- when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey))
- .thenReturn(2.0);
+ when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey)).thenReturn(2.0);
expect(service.cameraEvCalibration, 2.0);
});
@@ -303,14 +318,12 @@ void main() {
group('lightSensorEvCalibration', () {
test('get default', () {
- when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey))
- .thenReturn(null);
+ when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey)).thenReturn(null);
expect(service.lightSensorEvCalibration, 0.0);
});
test('get', () {
- when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey))
- .thenReturn(2.0);
+ when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey)).thenReturn(2.0);
expect(service.lightSensorEvCalibration, 2.0);
});
@@ -354,8 +367,7 @@ void main() {
});
test('get', () {
- when(() => sharedPreferences.getInt(UserPreferencesService.primaryColorKey))
- .thenReturn(0xff9c27b0);
+ when(() => sharedPreferences.getInt(UserPreferencesService.primaryColorKey)).thenReturn(0xff9c27b0);
expect(service.primaryColor, primaryColorsList[2]);
});
@@ -372,14 +384,12 @@ void main() {
group('dynamicColor', () {
test('get default', () {
- when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey))
- .thenReturn(null);
+ when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey)).thenReturn(null);
expect(service.dynamicColor, false);
});
test('get', () {
- when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey))
- .thenReturn(true);
+ when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey)).thenReturn(true);
expect(service.dynamicColor, true);
});
@@ -387,8 +397,7 @@ void main() {
when(() => sharedPreferences.setBool(UserPreferencesService.dynamicColorKey, false))
.thenAnswer((_) => Future.value(true));
service.dynamicColor = false;
- verify(() => sharedPreferences.setBool(UserPreferencesService.dynamicColorKey, false))
- .called(1);
+ verify(() => sharedPreferences.setBool(UserPreferencesService.dynamicColorKey, false)).called(1);
});
});
}
diff --git a/test/data/volume_events_service_test.dart b/test/data/volume_events_service_test.dart
index f9ef3d6..f574e50 100644
--- a/test/data/volume_events_service_test.dart
+++ b/test/data/volume_events_service_test.dart
@@ -4,6 +4,8 @@ import 'package:lightmeter/data/volume_events_service.dart';
import 'package:mocktail/mocktail.dart';
import 'package:platform/platform.dart';
+import '../event_channel_mock.dart';
+
class _MockLocalPlatform extends Mock implements LocalPlatform {}
void main() {
@@ -60,10 +62,18 @@ void main() {
});
group('volumeButtonsEventStream', () {
- // test('Android', () async {
- // when(() => localPlatform.isAndroid).thenReturn(true);
- // expect(service.volumeButtonsEventStream(), const Stream.empty());
- // });
+ test('Android', () async {
+ when(() => localPlatform.isAndroid).thenReturn(true);
+ final stream = service.volumeButtonsEventStream();
+ final List result = [];
+ final subscription = stream.listen(result.add);
+ await sendMockVolumeAction(VolumeEventsService.volumeEventsChannel.name, 24);
+ await sendMockVolumeAction(VolumeEventsService.volumeEventsChannel.name, 25);
+ await sendMockVolumeAction(VolumeEventsService.volumeEventsChannel.name, 20);
+ await sendMockVolumeAction(VolumeEventsService.volumeEventsChannel.name, 24);
+ expect(result, [24, 25, 24]);
+ subscription.cancel();
+ });
test('iOS', () async {
when(() => localPlatform.isAndroid).thenReturn(false);
diff --git a/test/event_channel_mock.dart b/test/event_channel_mock.dart
new file mode 100644
index 0000000..ee8cbc7
--- /dev/null
+++ b/test/event_channel_mock.dart
@@ -0,0 +1,10 @@
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+Future sendMockVolumeAction(String channelName, int keyCode) async {
+ await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage(
+ channelName,
+ const StandardMethodCodec().encodeSuccessEnvelope(keyCode),
+ (ByteData? data) {},
+ );
+}
diff --git a/test/providers/equipment_profile_provider_test.dart b/test/providers/equipment_profile_provider_test.dart
new file mode 100644
index 0000000..83f04f7
--- /dev/null
+++ b/test/providers/equipment_profile_provider_test.dart
@@ -0,0 +1,353 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:mocktail/mocktail.dart';
+
+class _MockIAPStorageService extends Mock implements IAPStorageService {}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ late _MockIAPStorageService storageService;
+
+ setUpAll(() {
+ storageService = _MockIAPStorageService();
+ });
+
+ tearDown(() {
+ reset(storageService);
+ });
+
+ Future pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async {
+ await tester.pumpWidget(
+ IAPProducts(
+ products: [
+ IAPProduct(
+ storeId: IAPProductType.paidFeatures.storeId,
+ status: productStatus,
+ ),
+ ],
+ child: EquipmentProfileProvider(
+ storageService: storageService,
+ child: const _Application(),
+ ),
+ ),
+ );
+ }
+
+ void expectEquipmentProfilesCount(int count) {
+ expect(find.text('Equipment profiles count: $count'), findsOneWidget);
+ }
+
+ void expectSelectedEquipmentProfileName(String name) {
+ expect(find.text('Selected equipment profile: $name'), findsOneWidget);
+ }
+
+ group(
+ 'EquipmentProfileProvider dependency on IAPProductStatus',
+ () {
+ setUp(() {
+ when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id);
+ when(() => storageService.equipmentProfiles).thenReturn(_customProfiles);
+ });
+
+ testWidgets(
+ 'IAPProductStatus.purchased - show all saved profiles',
+ (tester) async {
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectEquipmentProfilesCount(3);
+ expectSelectedEquipmentProfileName(_customProfiles.first.name);
+ },
+ );
+
+ testWidgets(
+ 'IAPProductStatus.purchasable - 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);
+ expectEquipmentProfilesCount(1);
+ expectSelectedEquipmentProfileName('');
+ },
+ );
+ },
+ );
+
+ group('EquipmentProfileProvider CRUD', () {
+ testWidgets(
+ 'Add',
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn([]);
+ when(() => storageService.selectedEquipmentProfileId).thenReturn('');
+
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectEquipmentProfilesCount(1);
+ expectSelectedEquipmentProfileName('');
+
+ await tester.tap(find.byKey(_Application.addProfileButtonKey));
+ await tester.pump();
+ expectEquipmentProfilesCount(2);
+ expectSelectedEquipmentProfileName('');
+
+ verifyNever(() => storageService.selectedEquipmentProfileId = '');
+ verify(() => storageService.equipmentProfiles = any>()).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Add from',
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
+ when(() => storageService.selectedEquipmentProfileId).thenReturn('');
+
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectEquipmentProfilesCount(3);
+ expectSelectedEquipmentProfileName('');
+
+ await tester.tap(find.byKey(_Application.addFromProfileButtonKey(_customProfiles[0].id)));
+ await tester.pump();
+ expectEquipmentProfilesCount(4);
+ expectSelectedEquipmentProfileName('');
+
+ verifyNever(() => storageService.selectedEquipmentProfileId = '');
+ verify(() => storageService.equipmentProfiles = any>()).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Edit selected',
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
+ when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id);
+
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+
+ /// Change the name & limit ISO values of the both added profiles
+ await tester.tap(find.byKey(_Application.updateProfileButtonKey(_customProfiles[0].id)));
+ await tester.pumpAndSettle();
+ expectEquipmentProfilesCount(3);
+ expectSelectedEquipmentProfileName("${_customProfiles[0].name} updated");
+
+ verifyNever(() => storageService.selectedEquipmentProfileId = _customProfiles[0].id);
+ verify(() => storageService.equipmentProfiles = any>()).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Delete selected',
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
+ when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id);
+
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectEquipmentProfilesCount(3);
+ expectSelectedEquipmentProfileName(_customProfiles[0].name);
+
+ /// Delete the selected profile
+ await tester.tap(find.byKey(_Application.deleteProfileButtonKey(_customProfiles[0].id)));
+ await tester.pumpAndSettle();
+ expectEquipmentProfilesCount(2);
+ expectSelectedEquipmentProfileName('');
+
+ verify(() => storageService.selectedEquipmentProfileId = '').called(1);
+ verify(() => storageService.equipmentProfiles = any>()).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Delete not selected',
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
+ when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id);
+
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectEquipmentProfilesCount(3);
+ expectSelectedEquipmentProfileName(_customProfiles[0].name);
+
+ /// Delete the not selected profile
+ await tester.tap(find.byKey(_Application.deleteProfileButtonKey(_customProfiles[1].id)));
+ await tester.pumpAndSettle();
+ expectEquipmentProfilesCount(2);
+ expectSelectedEquipmentProfileName(_customProfiles[0].name);
+
+ verifyNever(() => storageService.selectedEquipmentProfileId = '');
+ verify(() => storageService.equipmentProfiles = any>()).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Select',
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
+ when(() => storageService.selectedEquipmentProfileId).thenReturn('');
+
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectEquipmentProfilesCount(3);
+ expectSelectedEquipmentProfileName('');
+
+ /// Select the 1st custom profile
+ await tester.tap(find.byKey(_Application.setProfileButtonKey(_customProfiles[0].id)));
+ await tester.pumpAndSettle();
+ expectEquipmentProfilesCount(3);
+ expectSelectedEquipmentProfileName(_customProfiles[0].name);
+
+ verify(() => storageService.selectedEquipmentProfileId = _customProfiles[0].id).called(1);
+ verifyNever(() => storageService.equipmentProfiles = any>());
+ },
+ );
+ });
+}
+
+class _Application extends StatelessWidget {
+ const _Application();
+
+ static ValueKey get addProfileButtonKey => const ValueKey('addProfileButtonKey');
+ static ValueKey addFromProfileButtonKey(String id) => ValueKey('addFromProfileButtonKey$id');
+ static ValueKey setProfileButtonKey(String id) => ValueKey('setProfileButtonKey$id');
+ static ValueKey updateProfileButtonKey(String id) => ValueKey('updateProfileButtonKey$id');
+ static ValueKey deleteProfileButtonKey(String id) => ValueKey('deleteProfileButtonKey$id');
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ home: Scaffold(
+ appBar: AppBar(title: const Text('IAPProviders test')),
+ body: Center(
+ child: Column(
+ children: [
+ Text("Equipment profiles count: ${EquipmentProfiles.of(context).length}"),
+ Text("Selected equipment profile: ${EquipmentProfiles.selectedOf(context).name}"),
+ ElevatedButton(
+ key: addProfileButtonKey,
+ onPressed: () {
+ EquipmentProfileProvider.of(context).addProfile('Test added');
+ },
+ child: const Text("Add"),
+ ),
+ ...EquipmentProfiles.of(context).map((e) => _equipmentProfilesCrudRow(context, e)),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _equipmentProfilesCrudRow(BuildContext context, EquipmentProfile profile) {
+ return Row(
+ children: [
+ ElevatedButton(
+ key: setProfileButtonKey(profile.id),
+ onPressed: () {
+ EquipmentProfileProvider.of(context).setProfile(profile);
+ },
+ child: const Text("Set"),
+ ),
+ ElevatedButton(
+ key: addFromProfileButtonKey(profile.id),
+ onPressed: () {
+ EquipmentProfileProvider.of(context).addProfile('Test from ${profile.name}', profile);
+ },
+ child: const Text("Add from"),
+ ),
+ ElevatedButton(
+ key: updateProfileButtonKey(profile.id),
+ onPressed: () {
+ EquipmentProfileProvider.of(context).updateProdile(
+ profile.copyWith(
+ name: '${profile.name} updated',
+ isoValues: _customProfiles.first.isoValues,
+ ),
+ );
+ },
+ child: const Text("Update"),
+ ),
+ ElevatedButton(
+ key: deleteProfileButtonKey(profile.id),
+ onPressed: () {
+ EquipmentProfileProvider.of(context).deleteProfile(profile);
+ },
+ child: const Text("Delete"),
+ ),
+ ],
+ );
+ }
+}
+
+final List _customProfiles = [
+ const EquipmentProfile(
+ id: '1',
+ name: 'Test 1',
+ apertureValues: [
+ ApertureValue(4.0, StopType.full),
+ ApertureValue(4.5, StopType.third),
+ ApertureValue(4.8, StopType.half),
+ ApertureValue(5.0, StopType.third),
+ ApertureValue(5.6, StopType.full),
+ ApertureValue(6.3, StopType.third),
+ ApertureValue(6.7, StopType.half),
+ ApertureValue(7.1, StopType.third),
+ ApertureValue(8, StopType.full),
+ ],
+ ndValues: [
+ NdValue(0),
+ NdValue(2),
+ NdValue(4),
+ NdValue(8),
+ NdValue(16),
+ NdValue(32),
+ NdValue(64),
+ ],
+ shutterSpeedValues: ShutterSpeedValue.values,
+ isoValues: [
+ IsoValue(100, StopType.full),
+ IsoValue(125, StopType.third),
+ IsoValue(160, StopType.third),
+ IsoValue(200, StopType.full),
+ IsoValue(250, StopType.third),
+ IsoValue(320, StopType.third),
+ IsoValue(400, StopType.full),
+ ],
+ ),
+ const EquipmentProfile(
+ id: '2',
+ name: 'Test 2',
+ apertureValues: [
+ ApertureValue(4.0, StopType.full),
+ ApertureValue(4.5, StopType.third),
+ ApertureValue(4.8, StopType.half),
+ ApertureValue(5.0, StopType.third),
+ ApertureValue(5.6, StopType.full),
+ ApertureValue(6.3, StopType.third),
+ ApertureValue(6.7, StopType.half),
+ ApertureValue(7.1, StopType.third),
+ ApertureValue(8, StopType.full),
+ ],
+ ndValues: [
+ NdValue(0),
+ NdValue(2),
+ NdValue(4),
+ NdValue(8),
+ NdValue(16),
+ NdValue(32),
+ NdValue(64),
+ ],
+ shutterSpeedValues: ShutterSpeedValue.values,
+ isoValues: [
+ IsoValue(100, StopType.full),
+ IsoValue(125, StopType.third),
+ IsoValue(160, StopType.third),
+ IsoValue(200, StopType.full),
+ IsoValue(250, StopType.third),
+ IsoValue(320, StopType.third),
+ IsoValue(400, StopType.full),
+ ],
+ ),
+];
diff --git a/test/providers/films_provider_test.dart b/test/providers/films_provider_test.dart
new file mode 100644
index 0000000..760ca43
--- /dev/null
+++ b/test/providers/films_provider_test.dart
@@ -0,0 +1,275 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/providers/films_provider.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:mocktail/mocktail.dart';
+
+class _MockIAPStorageService extends Mock implements IAPStorageService {}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ late _MockIAPStorageService mockIAPStorageService;
+
+ setUpAll(() {
+ mockIAPStorageService = _MockIAPStorageService();
+ });
+
+ tearDown(() {
+ reset(mockIAPStorageService);
+ });
+
+ Future pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async {
+ await tester.pumpWidget(
+ IAPProducts(
+ products: [
+ IAPProduct(
+ storeId: IAPProductType.paidFeatures.storeId,
+ status: productStatus,
+ )
+ ],
+ child: FilmsProvider(
+ storageService: mockIAPStorageService,
+ availableFilms: mockFilms,
+ child: const _Application(),
+ ),
+ ),
+ );
+ }
+
+ void expectFilmsCount(int count) {
+ expect(find.text('Films count: $count'), findsOneWidget);
+ }
+
+ void expectFilmsInUseCount(int count) {
+ expect(find.text('Films in use count: $count'), findsOneWidget);
+ }
+
+ void expectSelectedFilmName(String name) {
+ expect(find.text('Selected film: $name'), findsOneWidget);
+ }
+
+ group(
+ 'FilmsProvider dependency on IAPProductStatus',
+ () {
+ setUp(() {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first);
+ when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms);
+ });
+
+ testWidgets(
+ 'IAPProductStatus.purchased - show all saved films',
+ (tester) async {
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount(mockFilms.length + 1);
+ expectSelectedFilmName(mockFilms.first.name);
+ },
+ );
+
+ testWidgets(
+ 'IAPProductStatus.purchasable - show only default',
+ (tester) async {
+ await pumpTestWidget(tester, IAPProductStatus.purchasable);
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount(1);
+ expectSelectedFilmName('');
+ },
+ );
+
+ testWidgets(
+ 'IAPProductStatus.pending - show only default',
+ (tester) async {
+ await pumpTestWidget(tester, IAPProductStatus.pending);
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount(1);
+ expectSelectedFilmName('');
+ },
+ );
+ },
+ );
+
+ group(
+ 'FilmsProvider CRUD',
+ () {
+ testWidgets(
+ 'Select films in use',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other());
+ when(() => mockIAPStorageService.filmsInUse).thenReturn([]);
+
+ /// Init
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount(1);
+ expectSelectedFilmName('');
+
+ /// Select all filmsInUse
+ await tester.tap(find.byKey(_Application.saveFilmsButtonKey(0)));
+ await tester.pumpAndSettle();
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount(mockFilms.length + 1);
+ expectSelectedFilmName('');
+
+ verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(0).toList()).called(1);
+ verifyNever(() => mockIAPStorageService.selectedFilm = const Film.other());
+ },
+ );
+
+ testWidgets(
+ 'Select film',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other());
+ when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms);
+
+ /// Init
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount(mockFilms.length + 1);
+ expectSelectedFilmName('');
+
+ /// Select all filmsInUse
+ await tester.tap(find.byKey(_Application.setFilmButtonKey(0)));
+ await tester.pumpAndSettle();
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount(mockFilms.length + 1);
+ expectSelectedFilmName(mockFilms.first.name);
+
+ verifyNever(() => mockIAPStorageService.filmsInUse = any>());
+ verify(() => mockIAPStorageService.selectedFilm = mockFilms.first).called(1);
+ },
+ );
+
+ group(
+ 'Coming from free app',
+ () {
+ testWidgets(
+ 'Has selected film',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms[2]);
+ when(() => mockIAPStorageService.filmsInUse).thenReturn([]);
+
+ /// Init
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectFilmsInUseCount(1);
+ expectSelectedFilmName('');
+
+ verifyNever(() => mockIAPStorageService.filmsInUse = any>());
+ verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1);
+ },
+ );
+
+ testWidgets(
+ 'None film selected',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other());
+ when(() => mockIAPStorageService.filmsInUse).thenReturn([]);
+
+ /// Init
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectFilmsInUseCount(1);
+ expectSelectedFilmName('');
+
+ verifyNever(() => mockIAPStorageService.filmsInUse = any>());
+ verifyNever(() => mockIAPStorageService.selectedFilm = const Film.other());
+ },
+ );
+ },
+ );
+
+ testWidgets(
+ 'Discard selected (by filmsInUse list update)',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first);
+ when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms);
+
+ /// Init
+ await pumpTestWidget(tester, IAPProductStatus.purchased);
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount(mockFilms.length + 1);
+ expectSelectedFilmName(mockFilms.first.name);
+
+ /// Select all filmsInUse except the first one
+ await tester.tap(find.byKey(_Application.saveFilmsButtonKey(1)));
+ await tester.pumpAndSettle();
+ expectFilmsCount(mockFilms.length + 1);
+ expectFilmsInUseCount((mockFilms.length - 1) + 1);
+ expectSelectedFilmName('');
+
+ verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(1).toList()).called(1);
+ verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1);
+ },
+ );
+ },
+ );
+}
+
+class _Application extends StatelessWidget {
+ const _Application();
+
+ static ValueKey saveFilmsButtonKey(int index) => ValueKey('saveFilmsButtonKey$index');
+ static ValueKey setFilmButtonKey(int index) => ValueKey('setFilmButtonKey$index');
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: Column(
+ children: [
+ Text("Films count: ${Films.of(context).length}"),
+ Text("Films in use count: ${Films.inUseOf(context).length}"),
+ Text("Selected film: ${Films.selectedOf(context).name}"),
+ _filmRow(context, 0),
+ _filmRow(context, 1),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _filmRow(BuildContext context, int index) {
+ return Row(
+ children: [
+ ElevatedButton(
+ key: saveFilmsButtonKey(index),
+ onPressed: () {
+ FilmsProvider.of(context).saveFilms(mockFilms.skip(index).toList());
+ },
+ child: const Text("Save filmsInUse"),
+ ),
+ ElevatedButton(
+ key: setFilmButtonKey(index),
+ onPressed: () {
+ FilmsProvider.of(context).setFilm(mockFilms[index]);
+ },
+ child: const Text("Set film"),
+ ),
+ ],
+ );
+ }
+}
+
+const mockFilms = [_MockFilm2x(), _MockFilm3x(), _MockFilm4x()];
+
+class _MockFilm2x extends Film {
+ const _MockFilm2x() : super('Mock film 2x', 400);
+
+ @override
+ double reciprocityFormula(double t) => t * 2;
+}
+
+class _MockFilm3x extends Film {
+ const _MockFilm3x() : super('Mock film 3x', 800);
+
+ @override
+ double reciprocityFormula(double t) => t * 3;
+}
+
+class _MockFilm4x extends Film {
+ const _MockFilm4x() : super('Mock film 4x', 1600);
+
+ @override
+ double reciprocityFormula(double t) => t * 4;
+}
diff --git a/test/providers/user_preferences_provider_test.dart b/test/providers/user_preferences_provider_test.dart
new file mode 100644
index 0000000..ffa7993
--- /dev/null
+++ b/test/providers/user_preferences_provider_test.dart
@@ -0,0 +1,376 @@
+import 'package:dynamic_color/test_utils.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/data/models/dynamic_colors_state.dart';
+import 'package:lightmeter/data/models/ev_source_type.dart';
+import 'package:lightmeter/data/models/metering_screen_layout_config.dart';
+import 'package:lightmeter/data/models/supported_locale.dart';
+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/res/theme.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:material_color_utilities/material_color_utilities.dart';
+import 'package:mocktail/mocktail.dart';
+
+class _MockUserPreferencesService extends Mock implements UserPreferencesService {}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ late _MockUserPreferencesService mockUserPreferencesService;
+
+ setUpAll(() {
+ mockUserPreferencesService = _MockUserPreferencesService();
+ });
+
+ setUp(() {
+ when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera);
+ when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third);
+ when(() => mockUserPreferencesService.meteringScreenLayout).thenReturn({
+ MeteringScreenLayoutFeature.extremeExposurePairs: true,
+ MeteringScreenLayoutFeature.filmPicker: true,
+ MeteringScreenLayoutFeature.equipmentProfiles: true,
+ MeteringScreenLayoutFeature.histogram: true,
+ });
+ when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en);
+ when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light);
+ when(() => mockUserPreferencesService.primaryColor).thenReturn(primaryColorsList[5]);
+ when(() => mockUserPreferencesService.dynamicColor).thenReturn(false);
+ });
+
+ tearDown(() {
+ reset(mockUserPreferencesService);
+ });
+
+ Future pumpTestWidget(
+ WidgetTester tester, {
+ bool hasLightSensor = true,
+ required WidgetBuilder builder,
+ }) async {
+ await tester.pumpWidget(
+ UserPreferencesProvider(
+ hasLightSensor: hasLightSensor,
+ userPreferencesService: mockUserPreferencesService,
+ child: _Application(builder: builder),
+ ),
+ );
+ }
+
+ group('[evSourceType]', () {
+ Future pumpEvTestApplication(WidgetTester tester, {required bool hasLightSensor}) async {
+ await pumpTestWidget(
+ tester,
+ hasLightSensor: hasLightSensor,
+ builder: (context) => Column(
+ children: [
+ Text('EV source type: ${UserPreferencesProvider.evSourceTypeOf(context)}'),
+ ElevatedButton(
+ onPressed: UserPreferencesProvider.of(context).toggleEvSourceType,
+ child: const Text('toggleEvSourceType'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void expectEvSource(EvSourceType evSourceType) {
+ expect(find.text("EV source type: $evSourceType"), findsOneWidget);
+ }
+
+ testWidgets(
+ 'Init evSourceType when has sensor & stored sensor',
+ (tester) async {
+ when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.sensor);
+ await pumpEvTestApplication(tester, hasLightSensor: true);
+ expectEvSource(EvSourceType.sensor);
+ },
+ );
+
+ testWidgets(
+ 'Init evSourceType when has no sensor & stored camera',
+ (tester) async {
+ when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera);
+ await pumpEvTestApplication(tester, hasLightSensor: false);
+ expectEvSource(EvSourceType.camera);
+ },
+ );
+
+ testWidgets(
+ 'Init evSourceType when has no sensor & stored sensor -> Reset to camera',
+ (tester) async {
+ when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.sensor);
+ await pumpEvTestApplication(tester, hasLightSensor: false);
+ expectEvSource(EvSourceType.camera);
+ },
+ );
+
+ testWidgets(
+ 'Try toggleEvSourceType() when has no sensor',
+ (tester) async {
+ when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera);
+ await pumpEvTestApplication(tester, hasLightSensor: false);
+ await tester.tap(find.text('toggleEvSourceType'));
+ await tester.pumpAndSettle();
+ verifyNever(() => mockUserPreferencesService.evSourceType = EvSourceType.sensor);
+ },
+ );
+
+ testWidgets(
+ 'Try toggleEvSourceType() when has sensor',
+ (tester) async {
+ when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera);
+ await pumpEvTestApplication(tester, hasLightSensor: true);
+
+ await tester.tap(find.text('toggleEvSourceType'));
+ await tester.pumpAndSettle();
+ expectEvSource(EvSourceType.sensor);
+ verify(() => mockUserPreferencesService.evSourceType = EvSourceType.sensor).called(1);
+
+ await tester.tap(find.text('toggleEvSourceType'));
+ await tester.pumpAndSettle();
+ expectEvSource(EvSourceType.camera);
+ verify(() => mockUserPreferencesService.evSourceType = EvSourceType.camera).called(1);
+ },
+ );
+ });
+
+ testWidgets(
+ 'Set different stop type',
+ (tester) async {
+ when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third);
+ await pumpTestWidget(
+ tester,
+ builder: (context) => Column(
+ children: [
+ Text('Stop type: ${UserPreferencesProvider.stopTypeOf(context)}'),
+ ElevatedButton(
+ onPressed: () => UserPreferencesProvider.of(context).setStopType(StopType.full),
+ child: const Text('setStopType'),
+ ),
+ ],
+ ),
+ );
+ expect(find.text("Stop type: ${StopType.third}"), findsOneWidget);
+
+ await tester.tap(find.text('setStopType'));
+ await tester.pumpAndSettle();
+ expect(find.text("Stop type: ${StopType.full}"), findsOneWidget);
+ verify(() => mockUserPreferencesService.stopType = StopType.full).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Set metering screen layout config',
+ (tester) async {
+ await pumpTestWidget(
+ tester,
+ builder: (context) {
+ final config = UserPreferencesProvider.meteringScreenConfigOf(context);
+ return Column(
+ children: [
+ ...List.generate(
+ config.length,
+ (index) => Text('${config.keys.toList()[index]}: ${config.values.toList()[index]}'),
+ ),
+ ...List.generate(
+ MeteringScreenLayoutFeature.values.length,
+ (index) => Text(
+ '${MeteringScreenLayoutFeature.values[index]}: ${UserPreferencesProvider.meteringScreenFeatureOf(context, MeteringScreenLayoutFeature.values[index])}',
+ ),
+ ),
+ ElevatedButton(
+ onPressed: () => UserPreferencesProvider.of(context).setMeteringScreenLayout({
+ MeteringScreenLayoutFeature.equipmentProfiles: true,
+ MeteringScreenLayoutFeature.extremeExposurePairs: false,
+ MeteringScreenLayoutFeature.filmPicker: false,
+ MeteringScreenLayoutFeature.histogram: true,
+ }),
+ child: const Text(''),
+ ),
+ ],
+ );
+ },
+ );
+ // Match `findsNWidgets(2)` to verify that `meteringScreenFeatureOf` specific results are the same as the whole config
+ expect(find.text("${MeteringScreenLayoutFeature.equipmentProfiles}: true"), findsNWidgets(2));
+ expect(find.text("${MeteringScreenLayoutFeature.extremeExposurePairs}: true"), findsNWidgets(2));
+ expect(find.text("${MeteringScreenLayoutFeature.filmPicker}: true"), findsNWidgets(2));
+ expect(find.text("${MeteringScreenLayoutFeature.histogram}: true"), findsNWidgets(2));
+
+ await tester.tap(find.byType(ElevatedButton));
+ await tester.pumpAndSettle();
+ expect(find.text("${MeteringScreenLayoutFeature.equipmentProfiles}: true"), findsNWidgets(2));
+ expect(find.text("${MeteringScreenLayoutFeature.extremeExposurePairs}: false"), findsNWidgets(2));
+ expect(find.text("${MeteringScreenLayoutFeature.filmPicker}: false"), findsNWidgets(2));
+ expect(find.text("${MeteringScreenLayoutFeature.histogram}: true"), findsNWidgets(2));
+ verify(
+ () => mockUserPreferencesService.meteringScreenLayout = {
+ MeteringScreenLayoutFeature.extremeExposurePairs: false,
+ MeteringScreenLayoutFeature.filmPicker: false,
+ MeteringScreenLayoutFeature.equipmentProfiles: true,
+ MeteringScreenLayoutFeature.histogram: true,
+ },
+ ).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Set different locale',
+ (tester) async {
+ when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en);
+ await pumpTestWidget(
+ tester,
+ builder: (context) => ElevatedButton(
+ onPressed: () => UserPreferencesProvider.of(context).setLocale(SupportedLocale.fr),
+ child: Text('${UserPreferencesProvider.localeOf(context)}'),
+ ),
+ );
+ expect(find.text("${SupportedLocale.en}"), findsOneWidget);
+
+ await tester.tap(find.text("${SupportedLocale.en}"));
+ await tester.pumpAndSettle();
+ expect(find.text("${SupportedLocale.fr}"), findsOneWidget);
+ verify(() => mockUserPreferencesService.locale = SupportedLocale.fr).called(1);
+ },
+ );
+
+ group('[theme]', () {
+ testWidgets(
+ 'Set dark theme type',
+ (tester) async {
+ when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light);
+ await pumpTestWidget(
+ tester,
+ builder: (context) => Column(
+ children: [
+ ElevatedButton(
+ onPressed: () => UserPreferencesProvider.of(context).setThemeType(ThemeType.dark),
+ child: Text('${UserPreferencesProvider.themeTypeOf(context)}'),
+ ),
+ Text('${Theme.of(context).colorScheme.brightness}')
+ ],
+ ),
+ );
+ expect(find.text("${ThemeType.light}"), findsOneWidget);
+ expect(find.text("${Brightness.light}"), findsOneWidget);
+
+ await tester.tap(find.text("${ThemeType.light}"));
+ await tester.pumpAndSettle();
+ expect(find.text("${ThemeType.dark}"), findsOneWidget);
+ expect(find.text("${Brightness.dark}"), findsOneWidget);
+ verify(() => mockUserPreferencesService.themeType = ThemeType.dark).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Set systemDefault theme type and toggle platform brightness',
+ (tester) async {
+ when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light);
+ await pumpTestWidget(
+ tester,
+ builder: (context) => Column(
+ children: [
+ ElevatedButton(
+ onPressed: () => UserPreferencesProvider.of(context).setThemeType(ThemeType.systemDefault),
+ child: Text('${UserPreferencesProvider.themeTypeOf(context)}'),
+ ),
+ Text('${Theme.of(context).colorScheme.brightness}')
+ ],
+ ),
+ );
+ TestWidgetsFlutterBinding.instance.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
+ expect(find.text("${ThemeType.light}"), findsOneWidget);
+ expect(find.text("${Brightness.light}"), findsOneWidget);
+
+ await tester.tap(find.text("${ThemeType.light}"));
+ await tester.pumpAndSettle();
+ expect(find.text("${ThemeType.systemDefault}"), findsOneWidget);
+ expect(find.text("${Brightness.dark}"), findsOneWidget);
+ verify(() => mockUserPreferencesService.themeType = ThemeType.systemDefault).called(1);
+
+ TestWidgetsFlutterBinding.instance.platformDispatcher.platformBrightnessTestValue = Brightness.light;
+ await tester.pumpAndSettle();
+ expect(find.text("${ThemeType.systemDefault}"), findsOneWidget);
+ expect(find.text("${Brightness.light}"), findsOneWidget);
+ },
+ );
+
+ testWidgets(
+ 'Set primary color',
+ (tester) async {
+ when(() => mockUserPreferencesService.primaryColor).thenReturn(primaryColorsList[5]);
+ await pumpTestWidget(
+ tester,
+ builder: (context) => ElevatedButton(
+ onPressed: () => UserPreferencesProvider.of(context).setPrimaryColor(primaryColorsList[7]),
+ child: Text('${UserPreferencesProvider.themeOf(context).primaryColor}'),
+ ),
+ );
+ expect(find.text("${primaryColorsList[5]}"), findsOneWidget);
+
+ await tester.tap(find.text("${primaryColorsList[5]}"));
+ await tester.pumpAndSettle();
+ expect(find.text("${primaryColorsList[7]}"), findsOneWidget);
+ verify(() => mockUserPreferencesService.primaryColor = primaryColorsList[7]).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Dynamic colors not available',
+ (tester) async {
+ when(() => mockUserPreferencesService.dynamicColor).thenReturn(true);
+ await pumpTestWidget(
+ tester,
+ builder: (context) => ElevatedButton(
+ onPressed: () => UserPreferencesProvider.of(context).enableDynamicColor(false),
+ child: Text('${UserPreferencesProvider.dynamicColorStateOf(context)}'),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(
+ find.text("${DynamicColorState.unavailable}"),
+ findsOneWidget,
+ reason:
+ "Even though dynamic colors usage is enabled, the core palette can be unavailable. Therefore `DynamicColorState` is also unavailable.",
+ );
+ },
+ );
+
+ testWidgets(
+ 'Toggle dynamic color state',
+ (tester) async {
+ DynamicColorTestingUtils.setMockDynamicColors(corePalette: CorePalette.of(0xffffffff));
+ when(() => mockUserPreferencesService.dynamicColor).thenReturn(true);
+ await pumpTestWidget(
+ tester,
+ builder: (context) => ElevatedButton(
+ onPressed: () => UserPreferencesProvider.of(context).enableDynamicColor(false),
+ child: Text('${UserPreferencesProvider.dynamicColorStateOf(context)}'),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(find.text("${DynamicColorState.enabled}"), findsOneWidget);
+
+ await tester.tap(find.text("${DynamicColorState.enabled}"));
+ await tester.pumpAndSettle();
+ expect(find.text("${DynamicColorState.disabled}"), findsOneWidget);
+ verify(() => mockUserPreferencesService.dynamicColor = false).called(1);
+ },
+ );
+ });
+}
+
+class _Application extends StatelessWidget {
+ final WidgetBuilder builder;
+
+ const _Application({required this.builder});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ theme: UserPreferencesProvider.themeOf(context),
+ home: Scaffold(body: Center(child: Builder(builder: builder))),
+ );
+ }
+}
diff --git a/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart b/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart
new file mode 100644
index 0000000..6853168
--- /dev/null
+++ b/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart
@@ -0,0 +1,116 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../../../application_mock.dart';
+import 'utils.dart';
+
+class _MockIAPStorageService extends Mock implements IAPStorageService {}
+
+void main() {
+ late final _MockIAPStorageService mockIAPStorageService;
+
+ setUpAll(() {
+ mockIAPStorageService = _MockIAPStorageService();
+ when(() => mockIAPStorageService.equipmentProfiles).thenReturn(_mockEquipmentProfiles);
+ when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn('');
+ });
+
+ Future pumpApplication(WidgetTester tester) async {
+ await tester.pumpWidget(
+ IAPProducts(
+ products: [
+ IAPProduct(
+ storeId: IAPProductType.paidFeatures.storeId,
+ status: IAPProductStatus.purchased,
+ )
+ ],
+ child: EquipmentProfileProvider(
+ storageService: mockIAPStorageService,
+ child: const WidgetTestApplicationMock(
+ child: Row(children: [Expanded(child: EquipmentProfilePicker())]),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ testWidgets(
+ 'Check dialog icon and title consistency',
+ (tester) async {
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.equipmentProfile);
+ await tester.openAnimatedPicker();
+ expect(find.byIcon(Icons.camera), findsOneWidget);
+ expectDialogPickerText(S.current.equipmentProfile);
+ },
+ );
+
+ group(
+ 'Display selected value',
+ () {
+ testWidgets(
+ 'None',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn('');
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.none);
+ await tester.openAnimatedPicker();
+ expectRadioListTile(S.current.none, isSelected: true);
+ },
+ );
+
+ testWidgets(
+ 'Praktica + Zenitar',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(_mockEquipmentProfiles.first.id);
+ await pumpApplication(tester);
+ expectReadingValueContainerText(_mockEquipmentProfiles.first.name);
+ await tester.openAnimatedPicker();
+ expectRadioListTile(_mockEquipmentProfiles.first.name, isSelected: true);
+ },
+ );
+ },
+ );
+}
+
+final _mockEquipmentProfiles = [
+ EquipmentProfile(
+ id: '1',
+ name: 'Praktica + Zenitar',
+ apertureValues: ApertureValue.values.sublist(
+ ApertureValue.values.indexOf(const ApertureValue(1.7, StopType.half)),
+ ApertureValue.values.indexOf(const ApertureValue(16, StopType.full)) + 1,
+ ),
+ ndValues: NdValue.values.sublist(0, 3),
+ shutterSpeedValues: ShutterSpeedValue.values.sublist(
+ ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)),
+ ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1,
+ ),
+ isoValues: const [
+ IsoValue(50, StopType.full),
+ IsoValue(100, StopType.full),
+ IsoValue(200, StopType.full),
+ IsoValue(250, StopType.third),
+ IsoValue(400, StopType.full),
+ IsoValue(500, StopType.third),
+ IsoValue(800, StopType.full),
+ IsoValue(1600, StopType.full),
+ IsoValue(3200, StopType.full),
+ ],
+ ),
+ const EquipmentProfile(
+ id: '2',
+ name: 'Praktica + Jupiter',
+ apertureValues: ApertureValue.values,
+ ndValues: NdValue.values,
+ shutterSpeedValues: ShutterSpeedValue.values,
+ isoValues: IsoValue.values,
+ ),
+];
diff --git a/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart b/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart
new file mode 100644
index 0000000..819a9eb
--- /dev/null
+++ b/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart
@@ -0,0 +1,72 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/data/models/exposure_pair.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/films_provider.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+
+import '../../../../../application_mock.dart';
+
+void main() {
+ testWidgets(
+ 'No exposure pairs',
+ (tester) async {
+ await tester.pumpApplication(
+ fastest: null,
+ slowest: null,
+ );
+
+ final pickerFinder = find.byType(ExtremeExposurePairsContainer);
+ expect(pickerFinder, findsOneWidget);
+ expect(find.descendant(of: pickerFinder, matching: find.text(S.current.fastestExposurePair)), findsOneWidget);
+ expect(find.descendant(of: pickerFinder, matching: find.text(S.current.slowestExposurePair)), findsOneWidget);
+ expect(find.descendant(of: pickerFinder, matching: find.text('-')), findsNWidgets(2));
+ },
+ );
+
+ testWidgets(
+ 'Has pairs',
+ (tester) async {
+ await tester.pumpApplication(
+ fastest: ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first),
+ slowest: ExposurePair(ApertureValue.values.last, ShutterSpeedValue.values.last),
+ );
+
+ final pickerFinder = find.byType(ExtremeExposurePairsContainer);
+ expect(pickerFinder, findsOneWidget);
+ expect(find.descendant(of: pickerFinder, matching: find.text(S.current.fastestExposurePair)), findsOneWidget);
+ expect(find.descendant(of: pickerFinder, matching: find.text(S.current.slowestExposurePair)), findsOneWidget);
+ expect(find.descendant(of: pickerFinder, matching: find.text('f/1.0 - 1/2000')), findsOneWidget);
+ expect(find.descendant(of: pickerFinder, matching: find.text('f/45 - 16"')), findsOneWidget);
+ },
+ );
+}
+
+extension WidgetTesterActions on WidgetTester {
+ Future pumpApplication({
+ required ExposurePair? fastest,
+ required ExposurePair? slowest,
+ }) async {
+ await pumpWidget(
+ Films(
+ values: const [Film.other()],
+ filmsInUse: const [Film.other()],
+ selected: const Film.other(),
+ child: WidgetTestApplicationMock(
+ child: Row(
+ children: [
+ Expanded(
+ child: ExtremeExposurePairsContainer(
+ fastest: fastest,
+ slowest: slowest,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ await pumpAndSettle();
+ }
+}
diff --git a/test/screens/metering/components/shared/readings_container/film_picker_test.dart b/test/screens/metering/components/shared/readings_container/film_picker_test.dart
new file mode 100644
index 0000000..19f4840
--- /dev/null
+++ b/test/screens/metering/components/shared/readings_container/film_picker_test.dart
@@ -0,0 +1,110 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/films_provider.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../../../application_mock.dart';
+import 'utils.dart';
+
+class _MockIAPStorageService extends Mock implements IAPStorageService {}
+
+void main() {
+ late final _MockIAPStorageService mockIAPStorageService;
+
+ setUpAll(() {
+ mockIAPStorageService = _MockIAPStorageService();
+ when(() => mockIAPStorageService.filmsInUse).thenReturn(_films);
+ });
+
+ Future pumpApplication(WidgetTester tester) async {
+ await tester.pumpWidget(
+ IAPProducts(
+ products: [
+ IAPProduct(
+ storeId: IAPProductType.paidFeatures.storeId,
+ status: IAPProductStatus.purchased,
+ )
+ ],
+ child: FilmsProvider(
+ storageService: mockIAPStorageService,
+ child: const WidgetTestApplicationMock(
+ child: Row(
+ children: [
+ Expanded(
+ child: FilmPicker(selectedIso: IsoValue(400, StopType.full)),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ group('Film push/pull label', () {
+ testWidgets(
+ 'Film.other()',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other());
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.film);
+ expectReadingValueContainerText(S.current.none);
+ },
+ );
+
+ testWidgets(
+ 'Film with the same ISO',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[1]);
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.film);
+ expectReadingValueContainerText(_films[1].name);
+ },
+ );
+
+ testWidgets(
+ 'Film with greater ISO',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[2]);
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.filmPull);
+ expectReadingValueContainerText(_films[2].name);
+ },
+ );
+
+ testWidgets(
+ 'Film with lower ISO',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[0]);
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.filmPush);
+ expectReadingValueContainerText(_films[0].name);
+ },
+ );
+ });
+
+ testWidgets(
+ 'Film picker shows only films in use',
+ (tester) async {
+ when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[0]);
+ await pumpApplication(tester);
+ await tester.openAnimatedPicker();
+ expectRadioListTile(S.current.none, isSelected: true);
+ expectRadioListTile(_films[1].name);
+ expectRadioListTile(_films[2].name);
+ expectRadioListTile(_films[3].name);
+ },
+ );
+}
+
+const _films = [
+ Film('ISO 100 Film', 100),
+ Film('ISO 400 Film', 400),
+ Film('ISO 800 Film', 800),
+ Film('ISO 1600 Film', 1600),
+];
diff --git a/test/screens/metering/components/shared/readings_container/iso_picker_test.dart b/test/screens/metering/components/shared/readings_container/iso_picker_test.dart
new file mode 100644
index 0000000..9068df4
--- /dev/null
+++ b/test/screens/metering/components/shared/readings_container/iso_picker_test.dart
@@ -0,0 +1,61 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+
+import '../../../../../application_mock.dart';
+import 'utils.dart';
+
+void main() {
+ Future pumpApplication(
+ WidgetTester tester, {
+ List values = IsoValue.values,
+ IsoValue selectedValue = const IsoValue(100, StopType.full),
+ }) async {
+ assert(values.contains(selectedValue));
+ await tester.pumpWidget(
+ WidgetTestApplicationMock(
+ child: Row(
+ children: [
+ Expanded(
+ child: IsoValuePicker(
+ selectedValue: selectedValue,
+ values: values,
+ onChanged: (_) {},
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ testWidgets(
+ 'Check dialog icon and title consistency',
+ (tester) async {
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.iso);
+ await tester.openAnimatedPicker();
+ expect(find.byIcon(Icons.iso), findsOneWidget);
+ expectDialogPickerText(S.current.iso);
+ expectDialogPickerText(S.current.filmSpeed);
+ },
+ );
+
+ group(
+ 'Display selected value',
+ () {
+ testWidgets(
+ 'Any',
+ (tester) async {
+ await pumpApplication(tester);
+ expectReadingValueContainerText('100');
+ await tester.openAnimatedPicker();
+ expectRadioListTile('100', isSelected: true);
+ },
+ );
+ },
+ );
+}
diff --git a/test/screens/metering/components/shared/readings_container/nd_picker_test.dart b/test/screens/metering/components/shared/readings_container/nd_picker_test.dart
new file mode 100644
index 0000000..3deb824
--- /dev/null
+++ b/test/screens/metering/components/shared/readings_container/nd_picker_test.dart
@@ -0,0 +1,71 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+
+import '../../../../../application_mock.dart';
+import 'utils.dart';
+
+void main() {
+ Future pumpApplication(
+ WidgetTester tester, {
+ List values = NdValue.values,
+ NdValue selectedValue = const NdValue(0),
+ }) async {
+ assert(values.contains(selectedValue));
+ await tester.pumpWidget(
+ WidgetTestApplicationMock(
+ child: Row(
+ children: [
+ Expanded(
+ child: NdValuePicker(
+ selectedValue: selectedValue,
+ values: values,
+ onChanged: (_) {},
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ testWidgets(
+ 'Check dialog icon and title consistency',
+ (tester) async {
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.nd);
+ await tester.openAnimatedPicker();
+ expect(find.byIcon(Icons.filter_b_and_w), findsOneWidget);
+ expectDialogPickerText(S.current.nd);
+ expectDialogPickerText(S.current.ndFilterFactor);
+ },
+ );
+
+ group(
+ 'Display selected value',
+ () {
+ testWidgets(
+ 'None',
+ (tester) async {
+ await pumpApplication(tester);
+ expectReadingValueContainerText(S.current.none);
+ await tester.openAnimatedPicker();
+ expectRadioListTile(S.current.none, isSelected: true);
+ },
+ );
+
+ testWidgets(
+ 'ND2',
+ (tester) async {
+ await pumpApplication(tester, selectedValue: const NdValue(2));
+ expectReadingValueContainerText('2');
+ await tester.openAnimatedPicker();
+ expectRadioListTile('2', isSelected: true);
+ },
+ );
+ },
+ );
+}
diff --git a/test/screens/metering/components/shared/readings_container/shared/animated_dialog_test.dart b/test/screens/metering/components/shared/readings_container/shared/animated_dialog_test.dart
new file mode 100644
index 0000000..d4b83c6
--- /dev/null
+++ b/test/screens/metering/components/shared/readings_container/shared/animated_dialog_test.dart
@@ -0,0 +1,122 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/res/dimens.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
+
+import '../../../../../../application_mock.dart';
+import '../utils.dart';
+
+void main() {
+ group(
+ 'Open & close tests',
+ () {
+ testWidgets(
+ 'Open & close with select',
+ (tester) async {
+ await tester.pumpApplication();
+ await tester.openAnimatedPicker>();
+ expect(find.byType(DialogPicker), findsOneWidget);
+ await tester.tapSelectButton();
+ expect(find.byType(DialogPicker), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'Open & close with cancel',
+ (tester) async {
+ await tester.pumpApplication();
+ await tester.openAnimatedPicker>();
+ expect(find.byType(DialogPicker), findsOneWidget);
+ await tester.tapCancelButton();
+ expect(find.byType(DialogPicker), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'Open & close with tap outside',
+ (tester) async {
+ await tester.pumpApplication();
+ await tester.openAnimatedPicker>();
+ expect(find.byType(DialogPicker), findsOneWidget);
+
+ /// tester taps the center of the found widget,
+ /// which results in tap on the dialog instead of the underlying barrier
+ /// therefore just tap at offset outside the dialog
+ await tester.longPressAt(const Offset(16, 16));
+ await tester.pumpAndSettle(Dimens.durationML);
+ expect(find.byType(DialogPicker), findsNothing);
+ },
+ );
+
+ testWidgets(
+ 'Open & close with back gesture',
+ (tester) async {
+ await tester.pumpApplication();
+ await tester.openAnimatedPicker>();
+ expect(find.byType(DialogPicker), findsOneWidget);
+
+ //// https://github.com/flutter/flutter/blob/master/packages/flutter/test/widgets/router_test.dart#L970-L971
+ //// final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute'));
+ //// await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) {});
+ /// https://github.com/flutter/packages/blob/main/packages/animations/test/open_container_test.dart#L234
+ (tester.state(find.byType(Navigator)) as NavigatorState).pop();
+ await tester.pumpAndSettle(Dimens.durationML);
+ expect(find.byType(DialogPicker), findsNothing);
+ },
+ );
+ },
+ );
+}
+
+extension WidgetTesterActions on WidgetTester {
+ Future pumpApplication() async {
+ await pumpWidget(
+ WidgetTestApplicationMock(
+ child: Row(
+ children: [
+ Expanded(
+ child: AnimatedDialogPicker(
+ icon: Icons.iso,
+ title: '',
+ subtitle: '',
+ selectedValue: 0,
+ values: List.generate(10, (index) => index),
+ itemTitleBuilder: (_, value) => Text(value.toString()),
+ itemTrailingBuilder: (selected, value) => null,
+ onChanged: (_) {},
+ closedChild: ReadingValueContainer.singleValue(
+ value: ReadingValue(
+ label: '',
+ value: 0.toString(),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ await pumpAndSettle();
+ }
+
+ Future tapSelectButton() async {
+ final cancelButton = find.byWidgetPredicate(
+ (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == S.current.select,
+ );
+ expect(cancelButton, findsOneWidget);
+ await tap(cancelButton);
+ await pumpAndSettle(Dimens.durationML);
+ }
+
+ Future tapCancelButton() async {
+ final cancelButton = find.byWidgetPredicate(
+ (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == S.current.cancel,
+ );
+ expect(cancelButton, findsOneWidget);
+ await tap(cancelButton);
+ await pumpAndSettle(Dimens.durationML);
+ }
+}
diff --git a/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart b/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart
new file mode 100644
index 0000000..39c38ba
--- /dev/null
+++ b/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart
@@ -0,0 +1,95 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../../../../application_mock.dart';
+import '../utils.dart';
+
+class _ValueChanged {
+ void onChanged(T value) {}
+}
+
+class _MockValueChanged extends Mock implements _ValueChanged {}
+
+void main() {
+ final functions = _MockValueChanged();
+
+ group(
+ 'onChanged',
+ () {
+ testWidgets(
+ 'other',
+ (tester) async {
+ await tester.pumpApplication(functions.onChanged);
+ await tester.openAnimatedPicker>();
+ expect(find.byType(DialogPicker), findsOneWidget);
+ await tester.tapListTile(1);
+ await tester.tapSelectButton();
+ verify(() => functions.onChanged(1)).called(1);
+ },
+ );
+
+ testWidgets(
+ 'same',
+ (tester) async {
+ await tester.pumpApplication(functions.onChanged);
+ await tester.openAnimatedPicker>();
+ expect(find.byType(DialogPicker), findsOneWidget);
+ await tester.tapListTile(0);
+ await tester.tapSelectButton();
+ verify(() => functions.onChanged(0)).called(1);
+ },
+ );
+ },
+ );
+}
+
+extension WidgetTesterActions on WidgetTester {
+ Future pumpApplication(ValueChanged onChanged) async {
+ await pumpWidget(
+ WidgetTestApplicationMock(
+ child: Row(
+ children: [
+ Expanded(
+ child: AnimatedDialogPicker(
+ icon: Icons.iso,
+ title: '',
+ subtitle: '',
+ selectedValue: 0,
+ values: List.generate(10, (index) => index),
+ itemTitleBuilder: (_, value) => Text(value.toString()),
+ itemTrailingBuilder: (selected, value) => null,
+ onChanged: onChanged,
+ closedChild: ReadingValueContainer.singleValue(
+ value: ReadingValue(
+ label: '',
+ value: 0.toString(),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ await pumpAndSettle();
+ }
+
+ Future tapListTile(int iso) async {
+ expect(find.descendant(of: find.byType(RadioListTile), matching: find.text('$iso')), findsOneWidget);
+ await tap(find.descendant(of: find.byType(RadioListTile), matching: find.text('$iso')));
+ }
+
+ Future tapSelectButton() async {
+ final cancelButton = find.byWidgetPredicate(
+ (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == S.current.select,
+ );
+ expect(cancelButton, findsOneWidget);
+ await tap(cancelButton);
+ await pumpAndSettle();
+ }
+}
diff --git a/test/screens/metering/components/shared/readings_container/utils.dart b/test/screens/metering/components/shared/readings_container/utils.dart
new file mode 100644
index 0000000..0ffba27
--- /dev/null
+++ b/test/screens/metering/components/shared/readings_container/utils.dart
@@ -0,0 +1,27 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/res/dimens.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart';
+import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart';
+
+extension WidgetTesterActions on WidgetTester {
+ Future openAnimatedPicker() async {
+ await tap(find.byType(T));
+ await pumpAndSettle(Dimens.durationL);
+ }
+}
+
+void expectReadingValueContainerText(String text) => _expectTextDescendantOf(text);
+
+void expectDialogPickerText(String text) => _expectTextDescendantOf>(text);
+
+void _expectTextDescendantOf(String text) {
+ expect(find.descendant(of: find.byType(T), matching: find.text(text)), findsOneWidget);
+}
+
+void expectRadioListTile(String text, {bool isSelected = false}) {
+ expect(
+ find.descendant(of: find.byType(RadioListTile), matching: find.text(text)),
+ findsOneWidget,
+ );
+}
diff --git a/test_coverage.sh b/test_coverage.sh
index c88e397..12014e8 100644
--- a/test_coverage.sh
+++ b/test_coverage.sh
@@ -1,4 +1,12 @@
flutter test --coverage
+flutter test integration_test --flavor=dev --coverage
+
+file=test/coverage_helper_test.dart
+echo "// Helper file to make coverage work for all dart files\n" > $file
+echo "// ignore_for_file: unused_import, directives_ordering" >> $file
+find lib '!' -path '*generated*/*' '!' -name '*.g.dart' '!' -name '*.part.dart' -name '*.dart' | cut -c4- | awk -v package=$1 '{printf "import '\''package:lightmeter%s%s'\'';\n", package, $1}' >> $file
+echo "void main() {}" >> $file
+
lcov --remove coverage/lcov.info 'lib/generated/*' 'lib/l10n/*' -o coverage/new_lcov.info
genhtml coverage/new_lcov.info -o coverage/html
open coverage/html/index.html
\ No newline at end of file
diff --git a/test_driver/integration_driver.dart b/test_driver/integration_driver.dart
new file mode 100644
index 0000000..3d79aac
--- /dev/null
+++ b/test_driver/integration_driver.dart
@@ -0,0 +1,8 @@
+import 'package:integration_test/integration_test_driver_extended.dart';
+
+import 'utils/grant_camera_permission.dart';
+
+Future main() async {
+ await grantCameraPermission();
+ await integrationDriver();
+}
diff --git a/test_driver/screenshot_driver.dart b/test_driver/screenshot_driver.dart
index 3b11ee3..4348142 100644
--- a/test_driver/screenshot_driver.dart
+++ b/test_driver/screenshot_driver.dart
@@ -1,42 +1,15 @@
-import 'dart:developer';
import 'dart:io';
import 'package:integration_test/integration_test_driver_extended.dart';
+import 'utils/grant_camera_permission.dart';
+
Future main() async {
- try {
- final bool adbExists = Process.runSync('which', ['adb']).exitCode == 0;
- if (!adbExists) {
- log(r'This test needs ADB to exist on the $PATH. Skipping...');
- exit(0);
- }
- final deviceId = await Process.run('adb', ["-s", 'shell', 'devices']).then((value) {
- if (value.stdout is String) {
- return RegExp(r"(?:List of devices attached\n)([A-Z0-9]*)(?:\sdevice\n)")
- .firstMatch(value.stdout as String)!
- .group(1);
- }
- });
- if (deviceId == null) {
- log('This test needs at least one device connected');
- exit(0);
- }
- await Process.run('adb', [
- "-s",
- deviceId, // https://github.com/flutter/flutter/issues/86295#issuecomment-1192766368
- 'shell',
- 'pm',
- 'grant',
- 'com.vodemn.lightmeter.dev',
- 'android.permission.CAMERA'
- ]);
- await integrationDriver(
- onScreenshot: (name, bytes, [args]) async {
- final File image = await File('screenshots/$name.png').create(recursive: true);
- image.writeAsBytesSync(bytes);
- return true;
- },
- );
- } catch (e) {
- log('Error occured: $e');
- }
+ await grantCameraPermission();
+ await integrationDriver(
+ onScreenshot: (name, bytes, [args]) async {
+ final File image = await File('screenshots/$name.png').create(recursive: true);
+ image.writeAsBytesSync(bytes);
+ return true;
+ },
+ );
}
diff --git a/test_driver/utils/grant_camera_permission.dart b/test_driver/utils/grant_camera_permission.dart
new file mode 100644
index 0000000..c6f56d7
--- /dev/null
+++ b/test_driver/utils/grant_camera_permission.dart
@@ -0,0 +1,34 @@
+import 'dart:developer';
+import 'dart:io';
+
+Future grantCameraPermission() async {
+ try {
+ final bool adbExists = Process.runSync('which', ['adb']).exitCode == 0;
+ if (!adbExists) {
+ log(r'This test needs ADB to exist on the $PATH. Skipping...');
+ exit(0);
+ }
+ final deviceId = await Process.run('adb', ["-s", 'shell', 'devices']).then((value) {
+ if (value.stdout is String) {
+ return RegExp(r"(?:List of devices attached\n)([A-Z0-9]*)(?:\sdevice\n)")
+ .firstMatch(value.stdout as String)!
+ .group(1);
+ }
+ });
+ if (deviceId == null) {
+ log('This test needs at least one device connected');
+ exit(0);
+ }
+ await Process.run('adb', [
+ "-s",
+ deviceId, // https://github.com/flutter/flutter/issues/86295#issuecomment-1192766368
+ 'shell',
+ 'pm',
+ 'grant',
+ 'com.vodemn.lightmeter.dev',
+ 'android.permission.CAMERA'
+ ]);
+ } catch (e) {
+ log('Error occured: $e');
+ }
+}
From a52efcd3413b09b373e5e6aadc16302d0692d62c Mon Sep 17 00:00:00 2001
From: Vadim <44135514+vodemn@users.noreply.github.com>
Date: Tue, 31 Oct 2023 18:42:25 +0100
Subject: [PATCH 02/13] ML-130 Integrate Firebase Remote Config (#132)
* implemented `RemoteConfigService`
* added alternative translations
* typo
* added `firebase_analytics`
* dim paid features list tiles
* log list tile tap instead of dialog
* implemented `RemoteConfigProvider`
* typo
---
lib/application_wrapper.dart | 25 +++--
lib/data/analytics/analytics.dart | 34 ++++++
.../api/analytics_api_interface.dart | 8 ++
.../analytics/api/analytics_firebase.dart | 26 +++++
.../analytics/entity/analytics_event.dart | 3 +
lib/data/models/feature.dart | 5 +
lib/data/remote_config_service.dart | 81 ++++++++++++++
lib/l10n/intl_en.arb | 6 +-
lib/l10n/intl_fr.arb | 4 +
lib/l10n/intl_ru.arb | 4 +
lib/l10n/intl_zh.arb | 4 +
lib/providers/remote_config_provider.dart | 78 +++++++++++++
lib/providers/services_provider.dart | 3 +
.../buy_pro/widget_list_tile_buy_pro.dart | 24 +++-
...idget_settings_section_lightmeter_pro.dart | 6 +-
.../iap_list_tile/widget_list_tile_iap.dart | 27 ++---
.../components/utils/show_buy_pro_dialog.dart | 13 ++-
pubspec.yaml | 10 +-
.../remote_config_provider_test.dart | 104 ++++++++++++++++++
19 files changed, 425 insertions(+), 40 deletions(-)
create mode 100644 lib/data/analytics/analytics.dart
create mode 100644 lib/data/analytics/api/analytics_api_interface.dart
create mode 100644 lib/data/analytics/api/analytics_firebase.dart
create mode 100644 lib/data/analytics/entity/analytics_event.dart
create mode 100644 lib/data/models/feature.dart
create mode 100644 lib/data/remote_config_service.dart
create mode 100644 lib/providers/remote_config_provider.dart
create mode 100644 test/providers/remote_config_provider_test.dart
diff --git a/lib/application_wrapper.dart b/lib/application_wrapper.dart
index d2975d6..d28fbcf 100644
--- a/lib/application_wrapper.dart
+++ b/lib/application_wrapper.dart
@@ -1,13 +1,17 @@
import 'package:flutter/material.dart';
+import 'package:lightmeter/data/analytics/analytics.dart';
+import 'package:lightmeter/data/analytics/api/analytics_firebase.dart';
import 'package:lightmeter/data/caffeine_service.dart';
import 'package:lightmeter/data/haptics_service.dart';
import 'package:lightmeter/data/light_sensor_service.dart';
import 'package:lightmeter/data/permissions_service.dart';
+import 'package:lightmeter/data/remote_config_service.dart';
import 'package:lightmeter/data/shared_prefs_service.dart';
import 'package:lightmeter/data/volume_events_service.dart';
import 'package:lightmeter/environment.dart';
import 'package:lightmeter/providers/equipment_profile_provider.dart';
import 'package:lightmeter/providers/films_provider.dart';
+import 'package:lightmeter/providers/remote_config_provider.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/providers/user_preferences_provider.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
@@ -23,9 +27,10 @@ class ApplicationWrapper extends StatelessWidget {
@override
Widget build(BuildContext context) {
return FutureBuilder(
- future: Future.wait([
+ future: Future.wait([
SharedPreferences.getInstance(),
const LightSensorService(LocalPlatform()).hasSensor(),
+ const RemoteConfigService().activeAndFetchFeatures(),
]),
builder: (_, snapshot) {
if (snapshot.data != null) {
@@ -33,6 +38,7 @@ class ApplicationWrapper extends StatelessWidget {
final userPreferencesService = UserPreferencesService(snapshot.data![0] as SharedPreferences);
final hasLightSensor = snapshot.data![1] as bool;
return ServicesProvider(
+ analytics: const LightmeterAnalytics(api: LightmeterAnalyticsFirebase()),
caffeineService: const CaffeineService(),
environment: env.copyWith(hasLightSensor: hasLightSensor),
hapticsService: const HapticsService(),
@@ -40,14 +46,17 @@ class ApplicationWrapper extends StatelessWidget {
permissionsService: const PermissionsService(),
userPreferencesService: userPreferencesService,
volumeEventsService: const VolumeEventsService(LocalPlatform()),
- child: EquipmentProfileProvider(
- storageService: iapService,
- child: FilmsProvider(
+ child: RemoteConfigProvider(
+ remoteConfigService: const RemoteConfigService(),
+ child: EquipmentProfileProvider(
storageService: iapService,
- child: UserPreferencesProvider(
- hasLightSensor: hasLightSensor,
- userPreferencesService: userPreferencesService,
- child: child,
+ child: FilmsProvider(
+ storageService: iapService,
+ child: UserPreferencesProvider(
+ hasLightSensor: hasLightSensor,
+ userPreferencesService: userPreferencesService,
+ child: child,
+ ),
),
),
),
diff --git a/lib/data/analytics/analytics.dart b/lib/data/analytics/analytics.dart
new file mode 100644
index 0000000..1bd6496
--- /dev/null
+++ b/lib/data/analytics/analytics.dart
@@ -0,0 +1,34 @@
+import 'dart:async';
+import 'dart:developer';
+
+import 'package:flutter/foundation.dart';
+import 'package:lightmeter/data/analytics/api/analytics_api_interface.dart';
+import 'package:lightmeter/data/analytics/entity/analytics_event.dart';
+
+class LightmeterAnalytics {
+ final ILightmeterAnalyticsApi _api;
+
+ const LightmeterAnalytics({required ILightmeterAnalyticsApi api}) : _api = api;
+
+ Future logEvent(
+ LightmeterAnalyticsEvent event, {
+ Map? parameters,
+ }) async {
+ if (kDebugMode) {
+ log(' logEvent: ${event.name} / $parameters');
+ return;
+ }
+
+ return _api.logEvent(
+ event: event,
+ parameters: parameters,
+ );
+ }
+
+ Future logUnlockProFeatures(String listTileTitle) async {
+ return logEvent(
+ LightmeterAnalyticsEvent.unlockProFeatures,
+ parameters: {"listTileTitle": listTileTitle},
+ );
+ }
+}
diff --git a/lib/data/analytics/api/analytics_api_interface.dart b/lib/data/analytics/api/analytics_api_interface.dart
new file mode 100644
index 0000000..1aa007f
--- /dev/null
+++ b/lib/data/analytics/api/analytics_api_interface.dart
@@ -0,0 +1,8 @@
+import 'package:lightmeter/data/analytics/entity/analytics_event.dart';
+
+abstract class ILightmeterAnalyticsApi {
+ Future logEvent({
+ required LightmeterAnalyticsEvent event,
+ Map? parameters,
+ });
+}
diff --git a/lib/data/analytics/api/analytics_firebase.dart b/lib/data/analytics/api/analytics_firebase.dart
new file mode 100644
index 0000000..fb11d02
--- /dev/null
+++ b/lib/data/analytics/api/analytics_firebase.dart
@@ -0,0 +1,26 @@
+import 'package:firebase_analytics/firebase_analytics.dart';
+import 'package:firebase_core/firebase_core.dart';
+import 'package:flutter/foundation.dart';
+import 'package:lightmeter/data/analytics/api/analytics_api_interface.dart';
+import 'package:lightmeter/data/analytics/entity/analytics_event.dart';
+
+class LightmeterAnalyticsFirebase implements ILightmeterAnalyticsApi {
+ const LightmeterAnalyticsFirebase();
+
+ @override
+ Future logEvent({
+ required LightmeterAnalyticsEvent event,
+ Map? parameters,
+ }) async {
+ try {
+ await FirebaseAnalytics.instance.logEvent(
+ name: event.name,
+ parameters: parameters,
+ );
+ } on FirebaseException catch (e) {
+ debugPrint('Firebase Analytics Exception: $e');
+ } catch (e) {
+ debugPrint(e.toString());
+ }
+ }
+}
diff --git a/lib/data/analytics/entity/analytics_event.dart b/lib/data/analytics/entity/analytics_event.dart
new file mode 100644
index 0000000..8275869
--- /dev/null
+++ b/lib/data/analytics/entity/analytics_event.dart
@@ -0,0 +1,3 @@
+enum LightmeterAnalyticsEvent {
+ unlockProFeatures,
+}
diff --git a/lib/data/models/feature.dart b/lib/data/models/feature.dart
new file mode 100644
index 0000000..e4dc30e
--- /dev/null
+++ b/lib/data/models/feature.dart
@@ -0,0 +1,5 @@
+enum Feature { unlockProFeaturesText }
+
+const featuresDefaultValues = {
+ Feature.unlockProFeaturesText: false,
+};
diff --git a/lib/data/remote_config_service.dart b/lib/data/remote_config_service.dart
new file mode 100644
index 0000000..9fc83fc
--- /dev/null
+++ b/lib/data/remote_config_service.dart
@@ -0,0 +1,81 @@
+import 'dart:async';
+import 'dart:developer';
+
+import 'package:firebase_core/firebase_core.dart';
+import 'package:firebase_crashlytics/firebase_crashlytics.dart';
+import 'package:firebase_remote_config/firebase_remote_config.dart';
+import 'package:flutter/foundation.dart';
+import 'package:lightmeter/data/models/feature.dart';
+
+class RemoteConfigService {
+ const RemoteConfigService();
+
+ Future activeAndFetchFeatures() async {
+ final FirebaseRemoteConfig remoteConfig = FirebaseRemoteConfig.instance;
+ const cacheStaleDuration = kDebugMode ? Duration(minutes: 1) : Duration(hours: 12);
+
+ try {
+ await remoteConfig.setConfigSettings(
+ RemoteConfigSettings(
+ fetchTimeout: const Duration(seconds: 15),
+ minimumFetchInterval: cacheStaleDuration,
+ ),
+ );
+ await remoteConfig.setDefaults(featuresDefaultValues.map((key, value) => MapEntry(key.name, value)));
+ await remoteConfig.activate();
+ await remoteConfig.ensureInitialized();
+ unawaited(remoteConfig.fetch());
+
+ log('Firebase remote config initialized successfully');
+ } on FirebaseException catch (e) {
+ _logError('Firebase exception during Firebase Remote Config initialization: $e');
+ } on Exception catch (e) {
+ _logError('Error during Firebase Remote Config initialization: $e');
+ }
+ }
+
+ dynamic getValue(Feature feature) => FirebaseRemoteConfig.instance.getValue(feature.name).toValue(feature);
+
+ Map getAll() {
+ final Map result = {};
+ for (final value in FirebaseRemoteConfig.instance.getAll().entries) {
+ try {
+ final feature = Feature.values.firstWhere((f) => f.name == value.key);
+ result[feature] = value.value.toValue(feature);
+ } catch (e) {
+ log(e.toString());
+ }
+ }
+ return result;
+ }
+
+ Stream> onConfigUpdated() => FirebaseRemoteConfig.instance.onConfigUpdated.asyncMap(
+ (event) async {
+ await FirebaseRemoteConfig.instance.activate();
+ final Set updatedFeatures = {};
+ for (final key in event.updatedKeys) {
+ try {
+ updatedFeatures.add(Feature.values.firstWhere((element) => element.name == key));
+ } catch (e) {
+ log(e.toString());
+ }
+ }
+ return updatedFeatures;
+ },
+ );
+
+ bool isEnabled(Feature feature) => FirebaseRemoteConfig.instance.getBool(feature.name);
+
+ void _logError(dynamic throwable, {StackTrace? stackTrace}) {
+ FirebaseCrashlytics.instance.recordError(throwable, stackTrace);
+ }
+}
+
+extension on RemoteConfigValue {
+ dynamic toValue(Feature feature) {
+ switch (feature) {
+ case Feature.unlockProFeaturesText:
+ return asBool();
+ }
+ }
+}
diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb
index 2f2fa27..57e91d2 100644
--- a/lib/l10n/intl_en.arb
+++ b/lib/l10n/intl_en.arb
@@ -92,10 +92,14 @@
}
}
},
- "buyLightmeterPro": "Buy Lightmeter Pro",
"lightmeterPro": "Lightmeter Pro",
+ "buyLightmeterPro": "Buy Lightmeter Pro",
"lightmeterProDescription": "Unlocks extra features, such as equipment profiles containing filters for aperture, shutter speed, and more; and a list of films with compensation for what's known as reciprocity failure.\n\nThe source code of Lightmeter is available on GitHub. You are welcome to compile it yourself. However, if you want to support the development and receive new features and updates, consider purchasing Lightmeter Pro.",
"buy": "Buy",
+ "proFeatures": "Pro features",
+ "unlockProFeatures": "Unlock Pro features",
+ "unlockProFeaturesDescription": "Unlock professional features, such as equipment profiles containing filters for aperture, shutter speed, and more; and a list of films with compensation for what's known as reciprocity failure.\n\nBy unlocking Pro features you support the development and make it possible to add new features to the app.",
+ "unlock": "Unlock",
"tooltipAdd": "Add",
"tooltipClose": "Close",
"tooltipExpand": "Expand",
diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb
index 895b8e2..b1cd7fb 100644
--- a/lib/l10n/intl_fr.arb
+++ b/lib/l10n/intl_fr.arb
@@ -96,6 +96,10 @@
"lightmeterPro": "Lightmeter Pro",
"lightmeterProDescription": "Déverrouille des fonctionnalités supplémentaires, telles que des profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore, ainsi qu'une liste de films avec une compensation pour ce que l'on appelle l'échec de réciprocité.\n\nLe code source du Lightmeter est disponible sur GitHub. Vous pouvez le compiler vous-même. Cependant, si vous souhaitez soutenir le développement et recevoir de nouvelles fonctionnalités et mises à jour, envisagez d'acheter Lightmeter Pro.",
"buy": "Acheter",
+ "proFeatures": "Fonctionnalités professionnelles",
+ "unlockProFeatures": "Déverrouiller les fonctionnalités professionnelles",
+ "unlockProFeaturesDescription": "Déverrouillez des fonctions professionnelles, telles que des profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore, ainsi qu'une liste de films avec compensation pour ce que l'on appelle l'échec de réciprocité.\n\nEn débloquant les fonctionnalités Pro, vous soutenez le développement et permettez d'ajouter de nouvelles fonctionnalités à l'application.",
+ "unlock": "Déverrouiller",
"tooltipAdd": "Ajouter",
"tooltipClose": "Fermer",
"tooltipExpand": "Élargir",
diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb
index f4c7b0b..a18cc9c 100644
--- a/lib/l10n/intl_ru.arb
+++ b/lib/l10n/intl_ru.arb
@@ -96,6 +96,10 @@
"lightmeterPro": "Lightmeter Pro",
"lightmeterProDescription": "Даёт доступ к таким функциям как профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений, а также набору пленок с компенсацией эффекта Шварцшильда.\n\nИсходный код Lightmeter доступен на GitHub. Вы можете собрать его самостоятельно. Однако если вы хотите поддержать разработку и получать новые функции и обновления, то приобретите Lightmeter Pro.",
"buy": "Купить",
+ "proFeatures": "Профессиональные настройки",
+ "unlockProFeatures": "Разблокировать профессиональные настройки",
+ "unlockProFeaturesDescription": "Вы можете разблокировать профессиональные настройки, такие как профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений, а также набору пленок с компенсацией эффекта Шварцшильда.\n\nПолучая доступ к профессиональным настройкам, вы поддерживаете разработку и делаете возможным появление новых функций в приложении.",
+ "unlock": "Разблокировать",
"tooltipAdd": "Добавить",
"tooltipClose": "Закрыть",
"tooltipExpand": "Развернуть",
diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb
index 0701781..5788ea9 100644
--- a/lib/l10n/intl_zh.arb
+++ b/lib/l10n/intl_zh.arb
@@ -96,6 +96,10 @@
"lightmeterPro": "Lightmeter Pro",
"lightmeterProDescription": "购买以解锁额外功能。例如包含光圈、快门速度等参数的配置文件;以及一个胶卷预设列表来提供倒易率失效时的曝光补偿。\n\n您可以在 GitHub 上获取 Lightmeter 的源代码,欢迎自行编译。不过,如果您想支持开发并获得新功能和更新,请考虑购买 Lightmeter Pro。",
"buy": "购买",
+ "proFeatures": "专业功能",
+ "unlockProFeatures": "解锁专业功能",
+ "unlockProFeaturesDescription": "解锁专业功能。例如包含光圈、快门速度等参数的配置文件;以及一个胶卷预设列表来提供倒易率失效时的曝光补偿。\n\n通过解锁专业版功能,您可以支持开发工作,帮助为应用程序添加新功能。",
+ "unlock": "解锁",
"tooltipAdd": "添加",
"tooltipClose": "关闭",
"tooltipExpand": "展开",
diff --git a/lib/providers/remote_config_provider.dart b/lib/providers/remote_config_provider.dart
new file mode 100644
index 0000000..9736e1d
--- /dev/null
+++ b/lib/providers/remote_config_provider.dart
@@ -0,0 +1,78 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:lightmeter/data/models/feature.dart';
+import 'package:lightmeter/data/remote_config_service.dart';
+
+class RemoteConfigProvider extends StatefulWidget {
+ final RemoteConfigService remoteConfigService;
+ final Widget child;
+
+ const RemoteConfigProvider({
+ required this.remoteConfigService,
+ required this.child,
+ super.key,
+ });
+
+ @override
+ State createState() => RemoteConfigProviderState();
+}
+
+class RemoteConfigProviderState extends State {
+ late final Map _config = widget.remoteConfigService.getAll();
+ late final StreamSubscription> _updatesSubscription;
+
+ @override
+ void initState() {
+ super.initState();
+ _updatesSubscription = widget.remoteConfigService.onConfigUpdated().listen(_updateFeatures);
+ }
+
+ @override
+ void dispose() {
+ _updatesSubscription.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return RemoteConfig(
+ config: _config,
+ child: widget.child,
+ );
+ }
+
+ void _updateFeatures(Set updatedFeatures) {
+ for (final feature in updatedFeatures) {
+ _config[feature] = widget.remoteConfigService.getValue(feature);
+ }
+ setState(() {});
+ }
+}
+
+class RemoteConfig extends InheritedModel {
+ final Map _config;
+
+ const RemoteConfig({
+ super.key,
+ required Map config,
+ required super.child,
+ }) : _config = config;
+
+ static bool isEnabled(BuildContext context, Feature feature) {
+ return InheritedModel.inheritFrom(context)!._config[feature] as bool;
+ }
+
+ @override
+ bool updateShouldNotify(RemoteConfig oldWidget) => true;
+
+ @override
+ bool updateShouldNotifyDependent(RemoteConfig oldWidget, Set features) {
+ for (final feature in features) {
+ if (oldWidget._config[feature] != _config[feature]) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/lib/providers/services_provider.dart b/lib/providers/services_provider.dart
index e65aa96..36f5674 100644
--- a/lib/providers/services_provider.dart
+++ b/lib/providers/services_provider.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:lightmeter/data/analytics/analytics.dart';
import 'package:lightmeter/data/caffeine_service.dart';
import 'package:lightmeter/data/haptics_service.dart';
import 'package:lightmeter/data/light_sensor_service.dart';
@@ -9,6 +10,7 @@ import 'package:lightmeter/environment.dart';
// coverage:ignore-start
class ServicesProvider extends InheritedWidget {
+ final LightmeterAnalytics analytics;
final CaffeineService caffeineService;
final Environment environment;
final HapticsService hapticsService;
@@ -18,6 +20,7 @@ class ServicesProvider extends InheritedWidget {
final VolumeEventsService volumeEventsService;
const ServicesProvider({
+ required this.analytics,
required this.caffeineService,
required this.environment,
required this.hapticsService,
diff --git a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
index 11e8a4f..5f8adcd 100644
--- a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
+++ b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
@@ -1,20 +1,36 @@
import 'package:flutter/material.dart';
+import 'package:lightmeter/data/models/feature.dart';
import 'package:lightmeter/generated/l10n.dart';
-import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart';
+import 'package:lightmeter/providers/remote_config_provider.dart';
+import 'package:lightmeter/providers/services_provider.dart';
+import 'package:lightmeter/res/dimens.dart';
import 'package:lightmeter/screens/settings/components/utils/show_buy_pro_dialog.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class BuyProListTile extends StatelessWidget {
const BuyProListTile({super.key});
@override
Widget build(BuildContext context) {
- return IAPListTile(
+ final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText);
+ final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status;
+ final isPending = status == IAPProductStatus.purchased || status == null;
+ return ListTile(
leading: const Icon(Icons.star),
- title: Text(S.of(context).buyLightmeterPro),
+ title: Text(unlockFeaturesEnabled ? S.of(context).unlockProFeatures : S.of(context).buyLightmeterPro),
onTap: () {
showBuyProDialog(context);
+ ServicesProvider.of(context)
+ .analytics
+ .logUnlockProFeatures(unlockFeaturesEnabled ? 'Unlock Pro features' : 'Buy Lightmeter Pro');
},
- showPendingTrailing: true,
+ trailing: isPending
+ ? const SizedBox(
+ height: Dimens.grid24,
+ width: Dimens.grid24,
+ child: CircularProgressIndicator(),
+ )
+ : null,
);
}
}
diff --git a/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart b/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart
index c060dc1..7050ae2 100644
--- a/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart
+++ b/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
+import 'package:lightmeter/data/models/feature.dart';
import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/remote_config_provider.dart';
import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart';
import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart';
@@ -9,7 +11,9 @@ class LightmeterProSettingsSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SettingsSection(
- title: S.of(context).lightmeterPro,
+ title: RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText)
+ ? S.of(context).proFeatures
+ : S.of(context).lightmeterPro,
children: const [BuyProListTile()],
);
}
diff --git a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart
index cf65ade..a2b980f 100644
--- a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart
+++ b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/res/dimens.dart';
-import 'package:lightmeter/screens/settings/components/utils/show_buy_pro_dialog.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
/// Depends on the product status and replaces [onTap] with purchase callback
@@ -23,24 +22,14 @@ class IAPListTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status;
- final isPending = status == IAPProductStatus.purchased || status == null;
- return ListTile(
- leading: leading,
- title: title,
- onTap: switch (status) {
- IAPProductStatus.purchasable => () => showBuyProDialog(context),
- IAPProductStatus.pending => null,
- IAPProductStatus.purchased => onTap,
- null => null,
- },
- trailing: showPendingTrailing && isPending
- ? const SizedBox(
- height: Dimens.grid24,
- width: Dimens.grid24,
- child: CircularProgressIndicator(),
- )
- : null,
+ final isPurchased = IAPProducts.isPurchased(context, IAPProductType.paidFeatures);
+ return Opacity(
+ opacity: isPurchased ? Dimens.enabledOpacity : Dimens.disabledOpacity,
+ child: ListTile(
+ leading: leading,
+ title: title,
+ onTap: isPurchased ? onTap : null,
+ ),
);
}
}
diff --git a/lib/screens/settings/components/utils/show_buy_pro_dialog.dart b/lib/screens/settings/components/utils/show_buy_pro_dialog.dart
index 0333570..50edbd1 100644
--- a/lib/screens/settings/components/utils/show_buy_pro_dialog.dart
+++ b/lib/screens/settings/components/utils/show_buy_pro_dialog.dart
@@ -1,17 +1,24 @@
import 'package:flutter/material.dart';
+import 'package:lightmeter/data/models/feature.dart';
import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/remote_config_provider.dart';
import 'package:lightmeter/res/dimens.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
Future showBuyProDialog(BuildContext context) {
+ final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText);
return showDialog(
context: context,
builder: (_) => AlertDialog(
icon: const Icon(Icons.star),
titlePadding: Dimens.dialogIconTitlePadding,
- title: Text(S.of(context).lightmeterPro),
+ title: Text(unlockFeaturesEnabled ? S.of(context).proFeatures : S.of(context).lightmeterPro),
contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL),
- content: SingleChildScrollView(child: Text(S.of(context).lightmeterProDescription)),
+ content: SingleChildScrollView(
+ child: Text(
+ unlockFeaturesEnabled ? S.of(context).unlockProFeaturesDescription : S.of(context).lightmeterProDescription,
+ ),
+ ),
actionsPadding: Dimens.dialogActionsPadding,
actions: [
TextButton(
@@ -23,7 +30,7 @@ Future showBuyProDialog(BuildContext context) {
Navigator.of(context).pop();
IAPProductsProvider.of(context).buy(IAPProductType.paidFeatures);
},
- child: Text(S.of(context).buy),
+ child: Text(unlockFeaturesEnabled ? S.of(context).unlock : S.of(context).buy),
),
],
),
diff --git a/pubspec.yaml b/pubspec.yaml
index 215f802..a7eb3a2 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -13,8 +13,10 @@ dependencies:
clipboard: 0.1.3
dynamic_color: 1.6.6
exif: 3.1.4
- firebase_core: 2.14.0
- firebase_crashlytics: 3.3.3
+ firebase_analytics: 10.6.2
+ firebase_core: 2.20.0
+ firebase_crashlytics: 3.4.2
+ firebase_remote_config: 4.3.2
flutter:
sdk: flutter
flutter_bloc: 8.1.3
@@ -26,7 +28,7 @@ dependencies:
m3_lightmeter_iap:
git:
url: "https://github.com/vodemn/m3_lightmeter_iap"
- ref: v0.5.0
+ ref: v0.6.2
m3_lightmeter_resources:
git:
url: "https://github.com/vodemn/m3_lightmeter_resources"
@@ -56,7 +58,7 @@ dev_dependencies:
flutter:
uses-material-design: true
- assets:
+ assets:
- assets/camera_stub_image.jpg
flutter_intl:
diff --git a/test/providers/remote_config_provider_test.dart b/test/providers/remote_config_provider_test.dart
new file mode 100644
index 0000000..aa6815d
--- /dev/null
+++ b/test/providers/remote_config_provider_test.dart
@@ -0,0 +1,104 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/data/models/feature.dart';
+import 'package:lightmeter/data/remote_config_service.dart';
+import 'package:lightmeter/providers/remote_config_provider.dart';
+import 'package:mocktail/mocktail.dart';
+
+class _MockRemoteConfigService extends Mock implements RemoteConfigService {}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ late _MockRemoteConfigService mockRemoteConfigService;
+
+ setUpAll(() {
+ mockRemoteConfigService = _MockRemoteConfigService();
+ });
+
+ setUp(() {
+ when(() => mockRemoteConfigService.getValue(Feature.unlockProFeaturesText)).thenReturn(false);
+ when(() => mockRemoteConfigService.getAll()).thenReturn({Feature.unlockProFeaturesText: false});
+ });
+
+ tearDown(() {
+ reset(mockRemoteConfigService);
+ });
+
+ Future pumpTestWidget(WidgetTester tester) async {
+ await tester.pumpWidget(
+ RemoteConfigProvider(
+ remoteConfigService: mockRemoteConfigService,
+ child: const _Application(),
+ ),
+ );
+ }
+
+ testWidgets(
+ 'RemoteConfigProvider init',
+ (tester) async {
+ when(() => mockRemoteConfigService.onConfigUpdated()).thenAnswer((_) => const Stream.empty());
+
+ await pumpTestWidget(tester);
+ expect(find.text('unlockProFeaturesText: false'), findsOneWidget);
+ },
+ );
+
+ testWidgets(
+ 'RemoteConfigProvider updates stream',
+ (tester) async {
+ final StreamController> remoteConfigUpdateController = StreamController>();
+ when(() => mockRemoteConfigService.onConfigUpdated()).thenAnswer((_) => remoteConfigUpdateController.stream);
+
+ await pumpTestWidget(tester);
+ expect(find.text('unlockProFeaturesText: false'), findsOneWidget);
+
+ when(() => mockRemoteConfigService.getValue(Feature.unlockProFeaturesText)).thenReturn(true);
+ remoteConfigUpdateController.add({Feature.unlockProFeaturesText});
+ await tester.pumpAndSettle();
+ expect(find.text('unlockProFeaturesText: true'), findsOneWidget);
+
+ await remoteConfigUpdateController.close();
+ },
+ );
+
+ test('RemoteConfig.updateShouldNotifyDependent', () {
+ const config = RemoteConfig(config: {Feature.unlockProFeaturesText: false}, child: SizedBox());
+ expect(
+ config.updateShouldNotifyDependent(config, {}),
+ false,
+ );
+ expect(
+ config.updateShouldNotifyDependent(
+ const RemoteConfig(config: {Feature.unlockProFeaturesText: false}, child: SizedBox()),
+ {Feature.unlockProFeaturesText},
+ ),
+ false,
+ );
+ expect(
+ config.updateShouldNotifyDependent(
+ const RemoteConfig(config: {Feature.unlockProFeaturesText: true}, child: SizedBox()),
+ {Feature.unlockProFeaturesText},
+ ),
+ true,
+ );
+ });
+}
+
+class _Application extends StatelessWidget {
+ const _Application();
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: Text(
+ "${Feature.unlockProFeaturesText.name}: ${RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText)}",
+ ),
+ ),
+ ),
+ );
+ }
+}
From d36db97959ad8a337166ff53ff9ddfb34cee9711 Mon Sep 17 00:00:00 2001
From: Vadim <44135514+vodemn@users.noreply.github.com>
Date: Tue, 31 Oct 2023 22:32:02 +0100
Subject: [PATCH 03/13] Updated IAP version to fix network issue
https://github.com/flutter/flutter/issues/135540
---
pubspec.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pubspec.yaml b/pubspec.yaml
index a7eb3a2..2d7730e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -28,7 +28,7 @@ dependencies:
m3_lightmeter_iap:
git:
url: "https://github.com/vodemn/m3_lightmeter_iap"
- ref: v0.6.2
+ ref: v0.6.3
m3_lightmeter_resources:
git:
url: "https://github.com/vodemn/m3_lightmeter_resources"
From 37a3b79f04f4323ea26b9c3e7d0821b4daca5354 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 1 Nov 2023 10:16:41 +0000
Subject: [PATCH 04/13] Version bump
---
pubspec.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pubspec.yaml b/pubspec.yaml
index 2d7730e..6fd97bc 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: lightmeter
description: Lightmeter app inspired by Material 3 design system.
publish_to: "none"
-version: 0.15.1+42
+version: 0.15.2+43
environment:
sdk: ">=3.0.0 <4.0.0"
From 3bb3f12641398dc9274cc6f1fc9374f786ed1fce Mon Sep 17 00:00:00 2001
From: Vadim <44135514+vodemn@users.noreply.github.com>
Date: Thu, 2 Nov 2023 17:40:47 +0100
Subject: [PATCH 05/13] ML-62 Utils tests (#133)
* removed redundant `UserPreferencesService` from `MeteringBloc`
* wip
* post-merge fixes
* `MeasureEvent` tests
* `MeasureEvent` tests revision
* `MeasureEvent` tests added timeout
* added stubs for other `MeteringBloc` events
* rewritten `MeteringBloc` logic
* wip
* `IsoChangedEvent` tests
* refined `IsoChangedEvent` tests
* `NdChangedEvent` tests
* `FilmChangedEvent` tests
* `MeteringCommunicationBloc` tests
* added test run to ci
* overriden `==` for `MeasuredState`
* `LuxMeteringEvent` tests
* refined `LuxMeteringEvent` tests
* rename
* wip
* wip
* `InitializeEvent`/`DeinitializeEvent` tests
* clamp minZoomLevel
* fixed `MeteringCommunicationBloc` tests
* wip
* `ZoomChangedEvent` tests
* `ExposureOffsetChangedEvent`/`ExposureOffsetResetEvent` tests
* renamed test groups
* added test coverage script
* improved `CameraContainerBloc` test coverage
* `EquipmentProfileChangedEvent` tests
* verify response vibration
* fixed running all tests
* `MeteringCommunicationBloc` equality tests
* `CameraContainerBloc` equality tests
* removed generated code from coverage
* `MeteringScreenLayoutFeature` tests
* `SupportedLocale` tests
* `Film` tests
* `CaffeineService` tests
* `UserPreferencesService` tests (wip)
* `LightSensorService` tests (wip)
* `migrateOldKeys()` tests
* ignore currently unused getters & setters
* gradle upgrade
* `reset(sharedPreferences);` calls count
* typo
* `MeteringInteractor` tests
* `SettingsInteractor` tests (wip)
* `MeteringInteractor` tests (wip)
* `SettingsInteractor` tests
* AnimatedDialog picker standalone tests
* Moved Animated dialog picker to widget tests
* `ExtremeExposurePairsContainer` widget test
* dialog picker test
* Match extreme exposure pairs & pairs list edge values
* `FilmPicker` widget tests
* fixed animated dialog picker tests
* add not hit files to coverage percentage
* Moved `EquipmentProfileProvider` & `FilmsProvider` to the main repo
* Synced _iap_ stub with repo
* `FilmsProvider` tests
* `EquipmentProfileProvider` tests
* Pass `availableFilms` to `FilmsProvider`
* `FilmPicker` tests
* removed unnecessary imports
* Metering layout features tests
* split integration tests by screens
* Films in use test
* mock light meter lux stream
* removed mockito mocks for integration tests
From no on these are the only mocks in use:
- Mock shared prefs initial values
- Mock platform responses (camera/light sensor)
* set sharedprefs mock without redundant group
* unified granting camera permission on Android
* fixed metering screen tests
* extracted common values
* `FilmPicker` integration tests
* fixed light sensor platform mocks
* wip
* removed integration tests for now
* moved screenshots generator to screenshots folder
* typo
* removed `MockIAPProductsProvider`
* implemented platform mocks for unit tests
* data/models/ 100% coverage
* `IsoValuePicker` tests
* `EquipmentProfileProvider` tests
* extended PR check timeout
* typo
* added storage action verification for `FilmsProvider` tests
* `UserPreferencesProvider` tests
* Update README.md
* added //coverage:ignore to `ServicesProvider`
* typo
* typo
* `toStringSignedAsFixed` tests
* `SelectableInheritedModel` tests
* removed unused `TextLineHeight` util
* `VolumeKeysNotifier` tests
* import
* `EquipmentProfileListener` tests
* typo
* split `EquipmentProfileListener` tests
* `showBuyProDialog` tests
* added `maybeOf` getter for iap stub
---
.../src/providers/iap_products_provider.dart | 9 +-
lib/data/volume_events_service.dart | 8 +-
lib/providers/equipment_profile_provider.dart | 15 +-
lib/screens/metering/bloc_metering.dart | 2 +-
.../widget_slider_exposure_offset.dart | 2 +-
lib/screens/metering/flow_metering.dart | 2 +-
lib/screens/metering/screen_metering.dart | 2 +-
....dart => listener_equipment_profiles.dart} | 0
.../notifier_volume_keys.dart | 8 +-
.../buy_pro/widget_list_tile_buy_pro.dart | 2 +-
.../screen_equipment_profile.dart | 2 +-
.../utils/show_buy_pro_dialog.dart | 2 +-
lib/utils/text_line_height.dart | 5 -
lib/utils/to_string_signed.dart | 11 +-
pubspec.yaml | 2 +-
test/function_mock.dart | 7 +
.../interactors/metering_interactor_test.dart | 2 +-
.../equipment_profile_provider_test.dart | 2 +-
test/screens/metering/bloc_metering_test.dart | 2 +-
.../shared/dialog_picker_test.dart | 9 +-
.../listener_equipment_profiles_test.dart | 138 ++++++++++++++++++
.../utils/notifier_volume_keys_test.dart | 46 ++++++
.../utils/show_buy_pro_dialog_test.dart | 61 ++++++++
test/utils/selectable_provider_test.dart | 76 ++++++++++
test/utils/to_string_signed_test.dart | 16 ++
25 files changed, 379 insertions(+), 52 deletions(-)
rename lib/screens/metering/utils/{listsner_equipment_profiles.dart => listener_equipment_profiles.dart} (100%)
rename lib/screens/metering/{components/shared/volume_keys_notifier => utils}/notifier_volume_keys.dart (82%)
rename lib/screens/settings/{components => }/utils/show_buy_pro_dialog.dart (94%)
delete mode 100644 lib/utils/text_line_height.dart
create mode 100644 test/function_mock.dart
create mode 100644 test/screens/metering/utils/listener_equipment_profiles_test.dart
create mode 100644 test/screens/metering/utils/notifier_volume_keys_test.dart
create mode 100644 test/screens/settings/utils/show_buy_pro_dialog_test.dart
create mode 100644 test/utils/selectable_provider_test.dart
create mode 100644 test/utils/to_string_signed_test.dart
diff --git a/iap/lib/src/providers/iap_products_provider.dart b/iap/lib/src/providers/iap_products_provider.dart
index 4895fdf..9d381ae 100644
--- a/iap/lib/src/providers/iap_products_provider.dart
+++ b/iap/lib/src/providers/iap_products_provider.dart
@@ -6,8 +6,10 @@ class IAPProductsProvider extends StatefulWidget {
const IAPProductsProvider({required this.child, super.key});
- static IAPProductsProviderState of(BuildContext context) {
- return context.findAncestorStateOfType()!;
+ static IAPProductsProviderState of(BuildContext context) => IAPProductsProvider.maybeOf(context)!;
+
+ static IAPProductsProviderState? maybeOf(BuildContext context) {
+ return context.findAncestorStateOfType();
}
@override
@@ -54,8 +56,7 @@ class IAPProducts extends InheritedModel {
bool updateShouldNotify(IAPProducts oldWidget) => false;
@override
- bool updateShouldNotifyDependent(IAPProducts oldWidget, Set dependencies) =>
- false;
+ bool updateShouldNotifyDependent(IAPProducts oldWidget, Set dependencies) => false;
IAPProduct? _findProduct(IAPProductType type) {
try {
diff --git a/lib/data/volume_events_service.dart b/lib/data/volume_events_service.dart
index d57936a..360de75 100644
--- a/lib/data/volume_events_service.dart
+++ b/lib/data/volume_events_service.dart
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:platform/platform.dart';
class VolumeEventsService {
- final LocalPlatform localPlatform;
+ final LocalPlatform _localPlatform;
@visibleForTesting
static const volumeHandlingChannel = MethodChannel("com.vodemn.lightmeter/volumeHandling");
@@ -11,12 +11,12 @@ class VolumeEventsService {
@visibleForTesting
static const volumeEventsChannel = EventChannel("com.vodemn.lightmeter/volumeEvents");
- const VolumeEventsService(this.localPlatform);
+ const VolumeEventsService(this._localPlatform);
/// If set to `false` we allow system to handle key events.
/// Returns current status of volume handling.
Future setVolumeHandling(bool enableHandling) async {
- if (!localPlatform.isAndroid) {
+ if (!_localPlatform.isAndroid) {
return false;
}
return volumeHandlingChannel
@@ -29,7 +29,7 @@ class VolumeEventsService {
/// KEYCODE_VOLUME_DOWN = 25;
/// pressed
Stream volumeButtonsEventStream() {
- if (!localPlatform.isAndroid) {
+ if (!_localPlatform.isAndroid) {
return const Stream.empty();
}
return volumeEventsChannel
diff --git a/lib/providers/equipment_profile_provider.dart b/lib/providers/equipment_profile_provider.dart
index a5e0999..564c5ef 100644
--- a/lib/providers/equipment_profile_provider.dart
+++ b/lib/providers/equipment_profile_provider.dart
@@ -54,9 +54,7 @@ class EquipmentProfileProviderState extends State {
_defaultProfile,
if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ..._customProfiles,
],
- selected: IAPProducts.isPurchased(context, IAPProductType.paidFeatures)
- ? _selectedProfile
- : _defaultProfile,
+ selected: IAPProducts.isPurchased(context, IAPProductType.paidFeatures) ? _selectedProfile : _defaultProfile,
child: widget.child,
);
}
@@ -85,7 +83,7 @@ class EquipmentProfileProviderState extends State {
_refreshSavedProfiles();
}
- void updateProdile(EquipmentProfile data) {
+ void updateProfile(EquipmentProfile data) {
final indexToUpdate = _customProfiles.indexWhere((element) => element.id == data.id);
if (indexToUpdate >= 0) {
_customProfiles[indexToUpdate] = data;
@@ -118,13 +116,14 @@ class EquipmentProfiles extends SelectableInheritedModel {
/// [_defaultProfile] + profiles created by the user
static List of(BuildContext context) {
- return InheritedModel.inheritFrom(context, aspect: SelectableAspect.list)!
- .values;
+ return InheritedModel.inheritFrom(context, aspect: SelectableAspect.list)!.values;
}
static EquipmentProfile selectedOf(BuildContext context) {
- return InheritedModel.inheritFrom(context,
- aspect: SelectableAspect.selected,)!
+ return InheritedModel.inheritFrom(
+ context,
+ aspect: SelectableAspect.selected,
+ )!
.selected;
}
}
diff --git a/lib/screens/metering/bloc_metering.dart b/lib/screens/metering/bloc_metering.dart
index cbb0356..753a7e7 100644
--- a/lib/screens/metering/bloc_metering.dart
+++ b/lib/screens/metering/bloc_metering.dart
@@ -10,9 +10,9 @@ import 'package:lightmeter/screens/metering/communication/event_communication_me
as communication_events;
import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart'
as communication_states;
-import 'package:lightmeter/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart';
import 'package:lightmeter/screens/metering/event_metering.dart';
import 'package:lightmeter/screens/metering/state_metering.dart';
+import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class MeteringBloc extends Bloc {
diff --git a/lib/screens/metering/components/camera_container/components/camera_controls/components/exposure_offset_slider/widget_slider_exposure_offset.dart b/lib/screens/metering/components/camera_container/components/camera_controls/components/exposure_offset_slider/widget_slider_exposure_offset.dart
index 484ee4b..40ec1f0 100644
--- a/lib/screens/metering/components/camera_container/components/camera_controls/components/exposure_offset_slider/widget_slider_exposure_offset.dart
+++ b/lib/screens/metering/components/camera_container/components/camera_controls/components/exposure_offset_slider/widget_slider_exposure_offset.dart
@@ -72,7 +72,7 @@ class _Ruler extends StatelessWidget {
children: [
if (showValue)
Text(
- (index + min).toStringSigned(),
+ (index + min).toStringSignedAsFixed(0),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(width: Dimens.grid8),
diff --git a/lib/screens/metering/flow_metering.dart b/lib/screens/metering/flow_metering.dart
index cca5675..f78482d 100644
--- a/lib/screens/metering/flow_metering.dart
+++ b/lib/screens/metering/flow_metering.dart
@@ -4,8 +4,8 @@ import 'package:lightmeter/interactors/metering_interactor.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/screens/metering/bloc_metering.dart';
import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart';
-import 'package:lightmeter/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart';
import 'package:lightmeter/screens/metering/screen_metering.dart';
+import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart';
class MeteringFlow extends StatefulWidget {
const MeteringFlow({super.key});
diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart
index 6900159..380411f 100644
--- a/lib/screens/metering/screen_metering.dart
+++ b/lib/screens/metering/screen_metering.dart
@@ -13,7 +13,7 @@ import 'package:lightmeter/screens/metering/components/camera_container/provider
import 'package:lightmeter/screens/metering/components/light_sensor_container/provider_container_light_sensor.dart';
import 'package:lightmeter/screens/metering/event_metering.dart';
import 'package:lightmeter/screens/metering/state_metering.dart';
-import 'package:lightmeter/screens/metering/utils/listsner_equipment_profiles.dart';
+import 'package:lightmeter/screens/metering/utils/listener_equipment_profiles.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
class MeteringScreen extends StatelessWidget {
diff --git a/lib/screens/metering/utils/listsner_equipment_profiles.dart b/lib/screens/metering/utils/listener_equipment_profiles.dart
similarity index 100%
rename from lib/screens/metering/utils/listsner_equipment_profiles.dart
rename to lib/screens/metering/utils/listener_equipment_profiles.dart
diff --git a/lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart b/lib/screens/metering/utils/notifier_volume_keys.dart
similarity index 82%
rename from lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart
rename to lib/screens/metering/utils/notifier_volume_keys.dart
index df64fdf..0c19955 100644
--- a/lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart
+++ b/lib/screens/metering/utils/notifier_volume_keys.dart
@@ -5,12 +5,12 @@ import 'package:lightmeter/data/models/volume_action.dart';
import 'package:lightmeter/data/volume_events_service.dart';
class VolumeKeysNotifier extends ChangeNotifier with RouteAware {
- final VolumeEventsService volumeEventsService;
+ final VolumeEventsService _volumeEventsService;
late final StreamSubscription _volumeKeysSubscription;
VolumeKey _value = VolumeKey.up;
- VolumeKeysNotifier(this.volumeEventsService) {
- _volumeKeysSubscription = volumeEventsService
+ VolumeKeysNotifier(this._volumeEventsService) {
+ _volumeKeysSubscription = _volumeEventsService
.volumeButtonsEventStream()
.map((event) => event == 24 ? VolumeKey.up : VolumeKey.down)
.listen((event) {
@@ -19,6 +19,8 @@ class VolumeKeysNotifier extends ChangeNotifier with RouteAware {
}
VolumeKey get value => _value;
+
+ @protected
set value(VolumeKey newValue) {
_value = newValue;
notifyListeners();
diff --git a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
index 5f8adcd..854a914 100644
--- a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
+++ b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
@@ -4,7 +4,7 @@ import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/providers/remote_config_provider.dart';
import 'package:lightmeter/providers/services_provider.dart';
import 'package:lightmeter/res/dimens.dart';
-import 'package:lightmeter/screens/settings/components/utils/show_buy_pro_dialog.dart';
+import 'package:lightmeter/screens/settings/utils/show_buy_pro_dialog.dart';
import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
class BuyProListTile extends StatelessWidget {
diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart
index b10190f..6879a2c 100644
--- a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart
+++ b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart
@@ -90,7 +90,7 @@ class _EquipmentProfilesScreenState extends State {
}
void _updateProfileAt(EquipmentProfile data) {
- EquipmentProfileProvider.of(context).updateProdile(data);
+ EquipmentProfileProvider.of(context).updateProfile(data);
}
void _removeProfileAt(EquipmentProfile data) {
diff --git a/lib/screens/settings/components/utils/show_buy_pro_dialog.dart b/lib/screens/settings/utils/show_buy_pro_dialog.dart
similarity index 94%
rename from lib/screens/settings/components/utils/show_buy_pro_dialog.dart
rename to lib/screens/settings/utils/show_buy_pro_dialog.dart
index 50edbd1..7ce3ff6 100644
--- a/lib/screens/settings/components/utils/show_buy_pro_dialog.dart
+++ b/lib/screens/settings/utils/show_buy_pro_dialog.dart
@@ -28,7 +28,7 @@ Future showBuyProDialog(BuildContext context) {
FilledButton(
onPressed: () {
Navigator.of(context).pop();
- IAPProductsProvider.of(context).buy(IAPProductType.paidFeatures);
+ IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures);
},
child: Text(unlockFeaturesEnabled ? S.of(context).unlock : S.of(context).buy),
),
diff --git a/lib/utils/text_line_height.dart b/lib/utils/text_line_height.dart
deleted file mode 100644
index a531074..0000000
--- a/lib/utils/text_line_height.dart
+++ /dev/null
@@ -1,5 +0,0 @@
-import 'package:flutter/widgets.dart';
-
-extension TextLineHeight on TextStyle {
- double get lineHeight => fontSize! * height!;
-}
diff --git a/lib/utils/to_string_signed.dart b/lib/utils/to_string_signed.dart
index 4122893..fcced8d 100644
--- a/lib/utils/to_string_signed.dart
+++ b/lib/utils/to_string_signed.dart
@@ -1,13 +1,4 @@
-extension SignedString on num {
- String toStringSigned() {
- if (this > 0) {
- return "+${toString()}";
- } else {
- return toString();
- }
- }
-}
-
+/// Returns value in form -1 or + 1. The only exception - 0.
extension SignedStringDouble on double {
String toStringSignedAsFixed(int fractionDigits) {
if (this > 0) {
diff --git a/pubspec.yaml b/pubspec.yaml
index 6fd97bc..ed5fc90 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -28,7 +28,7 @@ dependencies:
m3_lightmeter_iap:
git:
url: "https://github.com/vodemn/m3_lightmeter_iap"
- ref: v0.6.3
+ ref: v0.7.0
m3_lightmeter_resources:
git:
url: "https://github.com/vodemn/m3_lightmeter_resources"
diff --git a/test/function_mock.dart b/test/function_mock.dart
new file mode 100644
index 0000000..8a4abfe
--- /dev/null
+++ b/test/function_mock.dart
@@ -0,0 +1,7 @@
+import 'package:mocktail/mocktail.dart';
+
+class _ValueChanged {
+ void onChanged(T value) {}
+}
+
+class MockValueChanged extends Mock implements _ValueChanged {}
diff --git a/test/interactors/metering_interactor_test.dart b/test/interactors/metering_interactor_test.dart
index 1f0b05a..083a116 100644
--- a/test/interactors/metering_interactor_test.dart
+++ b/test/interactors/metering_interactor_test.dart
@@ -236,7 +236,7 @@ void main() {
);
group(
- 'Haptics',
+ 'Light sensor',
() {
test('hasAmbientLightSensor() - true', () async {
when(() => mockLightSensorService.hasSensor()).thenAnswer((_) async => true);
diff --git a/test/providers/equipment_profile_provider_test.dart b/test/providers/equipment_profile_provider_test.dart
index 83f04f7..fb329cd 100644
--- a/test/providers/equipment_profile_provider_test.dart
+++ b/test/providers/equipment_profile_provider_test.dart
@@ -260,7 +260,7 @@ class _Application extends StatelessWidget {
ElevatedButton(
key: updateProfileButtonKey(profile.id),
onPressed: () {
- EquipmentProfileProvider.of(context).updateProdile(
+ EquipmentProfileProvider.of(context).updateProfile(
profile.copyWith(
name: '${profile.name} updated',
isoValues: _customProfiles.first.isoValues,
diff --git a/test/screens/metering/bloc_metering_test.dart b/test/screens/metering/bloc_metering_test.dart
index fbffbbc..f07d35c 100644
--- a/test/screens/metering/bloc_metering_test.dart
+++ b/test/screens/metering/bloc_metering_test.dart
@@ -7,9 +7,9 @@ import 'package:lightmeter/screens/metering/communication/event_communication_me
as communication_events;
import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart'
as communication_states;
-import 'package:lightmeter/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart';
import 'package:lightmeter/screens/metering/event_metering.dart';
import 'package:lightmeter/screens/metering/state_metering.dart';
+import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart';
import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
diff --git a/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart b/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart
index 39c38ba..bd037af 100644
--- a/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart
+++ b/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart
@@ -7,16 +7,11 @@ import 'package:lightmeter/screens/metering/components/shared/readings_container
import 'package:mocktail/mocktail.dart';
import '../../../../../../application_mock.dart';
+import '../../../../../../function_mock.dart';
import '../utils.dart';
-class _ValueChanged {
- void onChanged(T value) {}
-}
-
-class _MockValueChanged extends Mock implements _ValueChanged {}
-
void main() {
- final functions = _MockValueChanged();
+ final functions = MockValueChanged();
group(
'onChanged',
diff --git a/test/screens/metering/utils/listener_equipment_profiles_test.dart b/test/screens/metering/utils/listener_equipment_profiles_test.dart
new file mode 100644
index 0000000..ae6cb87
--- /dev/null
+++ b/test/screens/metering/utils/listener_equipment_profiles_test.dart
@@ -0,0 +1,138 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/providers/equipment_profile_provider.dart';
+import 'package:lightmeter/screens/metering/utils/listener_equipment_profiles.dart';
+import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
+import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart';
+import 'package:mocktail/mocktail.dart';
+
+import '../../../function_mock.dart';
+
+class _MockIAPStorageService extends Mock implements IAPStorageService {}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ final storageService = _MockIAPStorageService();
+ final equipmentProfileProviderKey = GlobalKey();
+ final onDidChangeDependencies = MockValueChanged();
+
+ tearDown(() {
+ reset(onDidChangeDependencies);
+ reset(storageService);
+ });
+
+ Future pumpTestWidget(WidgetTester tester) async {
+ await tester.pumpWidget(
+ IAPProducts(
+ products: [
+ IAPProduct(
+ storeId: IAPProductType.paidFeatures.storeId,
+ status: IAPProductStatus.purchased,
+ ),
+ ],
+ child: EquipmentProfileProvider(
+ key: equipmentProfileProviderKey,
+ storageService: storageService,
+ child: MaterialApp(
+ home: EquipmentProfileListener(
+ onDidChangeDependencies: onDidChangeDependencies.onChanged,
+ child: Builder(builder: (context) => Text(EquipmentProfiles.selectedOf(context).name)),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ testWidgets(
+ 'Trigger `onDidChangeDependencies` by selecting a new profile',
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
+ when(() => storageService.selectedEquipmentProfileId).thenReturn('');
+ await pumpTestWidget(tester);
+
+ equipmentProfileProviderKey.currentState!.setProfile(_customProfiles[0]);
+ await tester.pump();
+ verify(() => onDidChangeDependencies.onChanged(_customProfiles[0])).called(1);
+ },
+ );
+
+ testWidgets(
+ 'Trigger `onDidChangeDependencies` by updating the selected profile',
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
+ when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id);
+ await pumpTestWidget(tester);
+
+ final updatedProfile1 = _customProfiles[0].copyWith(name: 'Test 1 updated');
+ equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile1);
+ await tester.pump();
+ verify(() => onDidChangeDependencies.onChanged(updatedProfile1)).called(1);
+
+ /// Verify that updating the not selected profile doesn't trigger the callback
+ final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated');
+ equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile2);
+ await tester.pump();
+ verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2));
+ },
+ );
+
+ testWidgets(
+ "Don't trigger `onDidChangeDependencies` by updating the unselected profile",
+ (tester) async {
+ when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles));
+ when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id);
+ await pumpTestWidget(tester);
+
+ final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated');
+ equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile2);
+ await tester.pump();
+ verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2));
+ },
+ );
+}
+
+final List _customProfiles = [
+ const EquipmentProfile(
+ id: '1',
+ name: 'Test 1',
+ apertureValues: [
+ ApertureValue(4.0, StopType.full),
+ ApertureValue(4.5, StopType.third),
+ ApertureValue(4.8, StopType.half),
+ ApertureValue(5.0, StopType.third),
+ ApertureValue(5.6, StopType.full),
+ ApertureValue(6.3, StopType.third),
+ ApertureValue(6.7, StopType.half),
+ ApertureValue(7.1, StopType.third),
+ ApertureValue(8, StopType.full),
+ ],
+ ndValues: [
+ NdValue(0),
+ NdValue(2),
+ NdValue(4),
+ NdValue(8),
+ NdValue(16),
+ NdValue(32),
+ NdValue(64),
+ ],
+ shutterSpeedValues: ShutterSpeedValue.values,
+ isoValues: [
+ IsoValue(100, StopType.full),
+ IsoValue(125, StopType.third),
+ IsoValue(160, StopType.third),
+ IsoValue(200, StopType.full),
+ IsoValue(250, StopType.third),
+ IsoValue(320, StopType.third),
+ IsoValue(400, StopType.full),
+ ],
+ ),
+ const EquipmentProfile(
+ id: '2',
+ name: 'Test 2',
+ apertureValues: ApertureValue.values,
+ ndValues: NdValue.values,
+ shutterSpeedValues: ShutterSpeedValue.values,
+ isoValues: IsoValue.values,
+ ),
+];
diff --git a/test/screens/metering/utils/notifier_volume_keys_test.dart b/test/screens/metering/utils/notifier_volume_keys_test.dart
new file mode 100644
index 0000000..7bb6458
--- /dev/null
+++ b/test/screens/metering/utils/notifier_volume_keys_test.dart
@@ -0,0 +1,46 @@
+import 'dart:async';
+
+import 'package:lightmeter/data/models/volume_action.dart';
+import 'package:lightmeter/data/volume_events_service.dart';
+import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:test/test.dart';
+
+import '../../../function_mock.dart';
+
+class _MockVolumeEventsService extends Mock implements VolumeEventsService {}
+
+void main() {
+ late _MockVolumeEventsService mockVolumeEventsService;
+
+ setUp(() {
+ mockVolumeEventsService = _MockVolumeEventsService();
+ });
+
+ test(
+ 'Listen to `volumeButtonsEventStream()`',
+ () async {
+ final StreamController volumeButtonsEvents = StreamController();
+ when(() => mockVolumeEventsService.volumeButtonsEventStream()).thenAnswer((_) => volumeButtonsEvents.stream);
+
+ final volumeKeysNotifier = VolumeKeysNotifier(mockVolumeEventsService);
+ final functions = MockValueChanged();
+ volumeKeysNotifier.addListener(() => functions.onChanged(volumeKeysNotifier.value));
+ expect(volumeKeysNotifier.value, VolumeKey.up);
+
+ volumeButtonsEvents.add(25);
+ volumeButtonsEvents.add(25);
+ volumeButtonsEvents.add(25);
+ volumeButtonsEvents.add(24);
+ volumeButtonsEvents.add(24);
+ volumeButtonsEvents.add(25);
+ await Future.delayed(Duration.zero);
+ verify(() => functions.onChanged(VolumeKey.up)).called(2);
+ verify(() => functions.onChanged(VolumeKey.down)).called(4);
+
+ volumeKeysNotifier.removeListener(() => functions.onChanged(volumeKeysNotifier.value));
+ await volumeKeysNotifier.dispose();
+ await volumeButtonsEvents.close();
+ },
+ );
+}
diff --git a/test/screens/settings/utils/show_buy_pro_dialog_test.dart b/test/screens/settings/utils/show_buy_pro_dialog_test.dart
new file mode 100644
index 0000000..16a8e19
--- /dev/null
+++ b/test/screens/settings/utils/show_buy_pro_dialog_test.dart
@@ -0,0 +1,61 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/data/models/feature.dart';
+import 'package:lightmeter/generated/l10n.dart';
+import 'package:lightmeter/providers/remote_config_provider.dart';
+import 'package:lightmeter/screens/settings/utils/show_buy_pro_dialog.dart';
+
+import '../../../application_mock.dart';
+
+void main() {
+ Future pumpApplication(WidgetTester tester) async {
+ await tester.pumpWidget(
+ RemoteConfig(
+ config: const {Feature.unlockProFeaturesText: false},
+ child: WidgetTestApplicationMock(
+ child: Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => showBuyProDialog(context),
+ child: const SizedBox(),
+ ),
+ ),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+ }
+
+ testWidgets(
+ '`showBuyProDialog` and buy',
+ (tester) async {
+ await pumpApplication(tester);
+ await tester.tap(find.byType(ElevatedButton));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsOneWidget);
+ expect(find.text(S.current.lightmeterPro), findsOneWidget);
+ expect(find.text(S.current.cancel), findsOneWidget);
+ expect(find.text(S.current.buy), findsOneWidget);
+
+ await tester.tap(find.text(S.current.buy));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsNothing);
+ },
+ );
+
+ testWidgets(
+ '`showBuyProDialog` and cancel',
+ (tester) async {
+ await pumpApplication(tester);
+ await tester.tap(find.byType(ElevatedButton));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsOneWidget);
+ expect(find.text(S.current.lightmeterPro), findsOneWidget);
+ expect(find.text(S.current.cancel), findsOneWidget);
+ expect(find.text(S.current.buy), findsOneWidget);
+
+ await tester.tap(find.text(S.current.cancel));
+ await tester.pumpAndSettle();
+ expect(find.byType(AlertDialog), findsNothing);
+ },
+ );
+}
diff --git a/test/utils/selectable_provider_test.dart b/test/utils/selectable_provider_test.dart
new file mode 100644
index 0000000..4ef2b96
--- /dev/null
+++ b/test/utils/selectable_provider_test.dart
@@ -0,0 +1,76 @@
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/utils/selectable_provider.dart';
+
+void main() {
+ group('SelectableInheritedModel.updateShouldNotifyDependent', () {
+ final model = SelectableInheritedModel(
+ values: List.generate(25, (index) => index),
+ selected: 1,
+ child: const SizedBox(),
+ );
+
+ test(
+ '`{}`',
+ () {
+ expect(
+ model.updateShouldNotifyDependent(
+ SelectableInheritedModel(
+ values: List.generate(25, (index) => index),
+ selected: 1,
+ child: const SizedBox(),
+ ),
+ {},
+ ),
+ false,
+ );
+ },
+ );
+
+ test(
+ '`{SelectableAspect.list}`',
+ () {
+ expect(
+ model.updateShouldNotifyDependent(
+ SelectableInheritedModel(
+ values: List.generate(25, (index) => index),
+ selected: 1,
+ child: const SizedBox(),
+ ),
+ {SelectableAspect.list},
+ ),
+ true,
+ );
+ },
+ );
+
+ test(
+ '`{SelectableAspect.selected}`',
+ () {
+ expect(
+ model.updateShouldNotifyDependent(
+ SelectableInheritedModel(
+ values: List.generate(25, (index) => index),
+ selected: 1,
+ child: const SizedBox(),
+ ),
+ {SelectableAspect.selected},
+ ),
+ false,
+ );
+ expect(
+ model.updateShouldNotifyDependent(
+ SelectableInheritedModel(
+ values: List.generate(25, (index) => index),
+ selected: 2,
+ child: const SizedBox(),
+ ),
+ {SelectableAspect.selected},
+ ),
+ true,
+ );
+ },
+ );
+ });
+}
diff --git a/test/utils/to_string_signed_test.dart b/test/utils/to_string_signed_test.dart
new file mode 100644
index 0000000..8611204
--- /dev/null
+++ b/test/utils/to_string_signed_test.dart
@@ -0,0 +1,16 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:lightmeter/utils/to_string_signed.dart';
+
+void main() {
+ test('toStringSignedAsFixed(0)', () {
+ expect(1.5.toStringSignedAsFixed(0), '+2');
+ expect((-1.5).toStringSignedAsFixed(0), '-2');
+ expect(0.0.toStringSignedAsFixed(0), '0');
+ });
+
+ test('toStringSignedAsFixed(1)', () {
+ expect(1.5.toStringSignedAsFixed(1), '+1.5');
+ expect((-1.5).toStringSignedAsFixed(1), '-1.5');
+ expect(0.0.toStringSignedAsFixed(1), '0.0');
+ });
+}
From 068834bfe540040bd05a51582617246284ab885c Mon Sep 17 00:00:00 2001
From: Vadim <44135514+vodemn@users.noreply.github.com>
Date: Tue, 7 Nov 2023 11:57:36 +0100
Subject: [PATCH 06/13] ML-134 Firebase Remote Config issues (#135)
* Unable to connect to the server
* internal remote config fetch error
* fixed tests
---
lib/data/remote_config_service.dart | 7 ++++++-
lib/providers/remote_config_provider.dart | 7 ++++++-
test/providers/remote_config_provider_test.dart | 1 +
3 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/lib/data/remote_config_service.dart b/lib/data/remote_config_service.dart
index 9fc83fc..8243dd2 100644
--- a/lib/data/remote_config_service.dart
+++ b/lib/data/remote_config_service.dart
@@ -24,7 +24,6 @@ class RemoteConfigService {
await remoteConfig.setDefaults(featuresDefaultValues.map((key, value) => MapEntry(key.name, value)));
await remoteConfig.activate();
await remoteConfig.ensureInitialized();
- unawaited(remoteConfig.fetch());
log('Firebase remote config initialized successfully');
} on FirebaseException catch (e) {
@@ -34,6 +33,12 @@ class RemoteConfigService {
}
}
+ Future fetchConfig() async {
+ // https://github.com/firebase/flutterfire/issues/6196#issuecomment-927751667
+ await Future.delayed(const Duration(seconds: 1));
+ await FirebaseRemoteConfig.instance.fetch();
+ }
+
dynamic getValue(Feature feature) => FirebaseRemoteConfig.instance.getValue(feature.name).toValue(feature);
Map getAll() {
diff --git a/lib/providers/remote_config_provider.dart b/lib/providers/remote_config_provider.dart
index 9736e1d..a00b385 100644
--- a/lib/providers/remote_config_provider.dart
+++ b/lib/providers/remote_config_provider.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:lightmeter/data/models/feature.dart';
@@ -25,7 +26,11 @@ class RemoteConfigProviderState extends State {
@override
void initState() {
super.initState();
- _updatesSubscription = widget.remoteConfigService.onConfigUpdated().listen(_updateFeatures);
+ widget.remoteConfigService.fetchConfig();
+ _updatesSubscription = widget.remoteConfigService.onConfigUpdated().listen(
+ _updateFeatures,
+ onError: (e) => log(e.toString()),
+ );
}
@override
diff --git a/test/providers/remote_config_provider_test.dart b/test/providers/remote_config_provider_test.dart
index aa6815d..a215cbe 100644
--- a/test/providers/remote_config_provider_test.dart
+++ b/test/providers/remote_config_provider_test.dart
@@ -18,6 +18,7 @@ void main() {
});
setUp(() {
+ when(() => mockRemoteConfigService.fetchConfig()).thenAnswer((_) async {});
when(() => mockRemoteConfigService.getValue(Feature.unlockProFeaturesText)).thenReturn(false);
when(() => mockRemoteConfigService.getAll()).thenReturn({Feature.unlockProFeaturesText: false});
});
From 434327a7d0fbcc238580eeaf90f04ce01d8aaf0b Mon Sep 17 00:00:00 2001
From: Vadim <44135514+vodemn@users.noreply.github.com>
Date: Tue, 7 Nov 2023 12:03:38 +0100
Subject: [PATCH 07/13] Disable list tile `onTap` if IAP is pending
---
.../buy_pro/widget_list_tile_buy_pro.dart | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
index 854a914..9854a14 100644
--- a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
+++ b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart
@@ -18,12 +18,14 @@ class BuyProListTile extends StatelessWidget {
return ListTile(
leading: const Icon(Icons.star),
title: Text(unlockFeaturesEnabled ? S.of(context).unlockProFeatures : S.of(context).buyLightmeterPro),
- onTap: () {
- showBuyProDialog(context);
- ServicesProvider.of(context)
- .analytics
- .logUnlockProFeatures(unlockFeaturesEnabled ? 'Unlock Pro features' : 'Buy Lightmeter Pro');
- },
+ onTap: !isPending
+ ? () {
+ showBuyProDialog(context);
+ ServicesProvider.of(context)
+ .analytics
+ .logUnlockProFeatures(unlockFeaturesEnabled ? 'Unlock Pro features' : 'Buy Lightmeter Pro');
+ }
+ : null,
trailing: isPending
? const SizedBox(
height: Dimens.grid24,
From ddc7ec8c8b2be7f0fb38f7ea5833599da1d26985 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 7 Nov 2023 11:15:17 +0000
Subject: [PATCH 08/13] Version bump
---
pubspec.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pubspec.yaml b/pubspec.yaml
index ed5fc90..eb53e59 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: lightmeter
description: Lightmeter app inspired by Material 3 design system.
publish_to: "none"
-version: 0.15.2+43
+version: 0.15.3+44
environment:
sdk: ">=3.0.0 <4.0.0"
From 6566108994371f149720b330447422605227356b Mon Sep 17 00:00:00 2001
From: Vadim <44135514+vodemn@users.noreply.github.com>
Date: Sat, 11 Nov 2023 21:05:11 +0100
Subject: [PATCH 09/13] ML-129 Spot metering (#136)
* imlemented `CameraSpotDetector`
* separated generic `DialogSwitch`
* added `CameraFeature` model
* added `CameraFeaturesListTile` to metering section
* added features dialog subtitles
* added long press to remove metering spot
* translations
* hide camera features for purchasable status
* hide `CameraHistogram` & `CameraSpotDetector` if purchasable
* bumped iap version
* fixed tests
* removed redundant camera state emission
* tests
* Fixed remote config initalization
* updated pro features description
---
lib/application_wrapper.dart | 5 +-
lib/data/models/camera_feature.dart | 13 +++
lib/data/models/feature.dart | 2 +-
.../models/metering_screen_layout_config.dart | 33 +++++--
lib/data/remote_config_service.dart | 49 +++++++++-
lib/data/shared_prefs_service.dart | 39 +++++---
lib/l10n/intl_en.arb | 10 +-
lib/l10n/intl_fr.arb | 10 +-
lib/l10n/intl_ru.arb | 10 +-
lib/l10n/intl_zh.arb | 10 +-
lib/providers/remote_config_provider.dart | 2 +-
lib/providers/user_preferences_provider.dart | 55 ++++++-----
lib/res/theme.dart | 14 ++-
.../bloc_container_camera.dart | 18 ++--
.../widget_camera_spot_detector.dart | 71 ++++++++++++++
.../camera_view/widget_camera_view.dart | 21 ++--
.../camera_preview/widget_camera_preview.dart | 53 +++++++---
.../event_container_camera.dart | 18 ++++
.../widget_container_camera.dart | 3 +
.../widget_list_tile_camera_features.dart | 45 +++++++++
...ialog_metering_screen_layout_features.dart | 97 -------------------
...dget_list_tile_metering_screen_layout.dart | 36 ++++++-
.../widget_settings_section_metering.dart | 2 +
.../dialog_switch/widget_dialog_switch.dart | 95 ++++++++++++++++++
.../settings/utils/show_buy_pro_dialog.dart | 31 +++++-
lib/utils/map_model.dart | 26 +++++
pubspec.yaml | 2 +-
screenshots/generate_screenshots.dart | 1 -
.../models/camera_features_config_test.dart | 47 +++++++++
.../metering_screen_layout_config_test.dart | 11 +--
test/data/shared_prefs_service_test.dart | 68 +++++++++++--
.../user_preferences_provider_test.dart | 57 ++++++++++-
.../camera/bloc_container_camera_test.dart | 59 ++++++-----
.../camera/event_container_camera_test.dart | 21 ++++
34 files changed, 783 insertions(+), 251 deletions(-)
create mode 100644 lib/data/models/camera_feature.dart
create mode 100644 lib/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart
create mode 100644 lib/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart
delete mode 100644 lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart
create mode 100644 lib/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart
create mode 100644 lib/utils/map_model.dart
create mode 100644 test/data/models/camera_features_config_test.dart
diff --git a/lib/application_wrapper.dart b/lib/application_wrapper.dart
index d28fbcf..ae7d1ae 100644
--- a/lib/application_wrapper.dart
+++ b/lib/application_wrapper.dart
@@ -30,7 +30,7 @@ class ApplicationWrapper extends StatelessWidget {
future: Future.wait([
SharedPreferences.getInstance(),
const LightSensorService(LocalPlatform()).hasSensor(),
- const RemoteConfigService().activeAndFetchFeatures(),
+ if (env.buildType != BuildType.dev) const RemoteConfigService().activeAndFetchFeatures(),
]),
builder: (_, snapshot) {
if (snapshot.data != null) {
@@ -47,7 +47,8 @@ class ApplicationWrapper extends StatelessWidget {
userPreferencesService: userPreferencesService,
volumeEventsService: const VolumeEventsService(LocalPlatform()),
child: RemoteConfigProvider(
- remoteConfigService: const RemoteConfigService(),
+ remoteConfigService:
+ env.buildType != BuildType.dev ? const RemoteConfigService() : const MockRemoteConfigService(),
child: EquipmentProfileProvider(
storageService: iapService,
child: FilmsProvider(
diff --git a/lib/data/models/camera_feature.dart b/lib/data/models/camera_feature.dart
new file mode 100644
index 0000000..20193bf
--- /dev/null
+++ b/lib/data/models/camera_feature.dart
@@ -0,0 +1,13 @@
+enum CameraFeature {
+ spotMetering,
+ histogram,
+}
+
+typedef CameraFeaturesConfig = Map;
+
+extension CameraFeaturesConfigJson on CameraFeaturesConfig {
+ static CameraFeaturesConfig fromJson(Map