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] 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 data) => + {for (final f in CameraFeature.values) f: data[f.name] as bool? ?? false}; + + Map toJson() => map((key, value) => MapEntry(key.name, value)); +} diff --git a/lib/data/models/feature.dart b/lib/data/models/feature.dart index e4dc30e..b022db7 100644 --- a/lib/data/models/feature.dart +++ b/lib/data/models/feature.dart @@ -1,5 +1,5 @@ enum Feature { unlockProFeaturesText } const featuresDefaultValues = { - Feature.unlockProFeaturesText: false, + Feature.unlockProFeaturesText: true, }; diff --git a/lib/data/models/metering_screen_layout_config.dart b/lib/data/models/metering_screen_layout_config.dart index 7802195..7d550e4 100644 --- a/lib/data/models/metering_screen_layout_config.dart +++ b/lib/data/models/metering_screen_layout_config.dart @@ -1,18 +1,31 @@ enum MeteringScreenLayoutFeature { - extremeExposurePairs, - filmPicker, - histogram, - equipmentProfiles, + extremeExposurePairs, // 0 + filmPicker, // 1 + equipmentProfiles, // 3 } typedef MeteringScreenLayoutConfig = Map; extension MeteringScreenLayoutConfigJson on MeteringScreenLayoutConfig { - static MeteringScreenLayoutConfig fromJson(Map data) => - { - for (final f in MeteringScreenLayoutFeature.values) - f: data[f.index.toString()] as bool? ?? true - }; + static MeteringScreenLayoutConfig fromJson(Map data) { + int? migratedIndex(MeteringScreenLayoutFeature feature) { + switch (feature) { + case MeteringScreenLayoutFeature.extremeExposurePairs: + return 0; + case MeteringScreenLayoutFeature.filmPicker: + return 1; + case MeteringScreenLayoutFeature.equipmentProfiles: + return 3; + default: + return null; + } + } - Map toJson() => map((key, value) => MapEntry(key.index.toString(), value)); + return { + for (final f in MeteringScreenLayoutFeature.values) + f: (data[migratedIndex(f).toString()] ?? data[f.name]) as bool? ?? true + }; + } + + Map toJson() => map((key, value) => MapEntry(key.name, value)); } diff --git a/lib/data/remote_config_service.dart b/lib/data/remote_config_service.dart index 8243dd2..f8c123b 100644 --- a/lib/data/remote_config_service.dart +++ b/lib/data/remote_config_service.dart @@ -7,9 +7,26 @@ import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/foundation.dart'; import 'package:lightmeter/data/models/feature.dart'; -class RemoteConfigService { +abstract class IRemoteConfigService { + const IRemoteConfigService(); + + Future activeAndFetchFeatures(); + + Future fetchConfig(); + + dynamic getValue(Feature feature); + + Map getAll(); + + Stream> onConfigUpdated(); + + bool isEnabled(Feature feature); +} + +class RemoteConfigService implements IRemoteConfigService { const RemoteConfigService(); + @override Future activeAndFetchFeatures() async { final FirebaseRemoteConfig remoteConfig = FirebaseRemoteConfig.instance; const cacheStaleDuration = kDebugMode ? Duration(minutes: 1) : Duration(hours: 12); @@ -28,19 +45,22 @@ class RemoteConfigService { log('Firebase remote config initialized successfully'); } on FirebaseException catch (e) { _logError('Firebase exception during Firebase Remote Config initialization: $e'); - } on Exception catch (e) { + } catch (e) { _logError('Error during Firebase Remote Config initialization: $e'); } } + @override Future fetchConfig() async { // https://github.com/firebase/flutterfire/issues/6196#issuecomment-927751667 await Future.delayed(const Duration(seconds: 1)); await FirebaseRemoteConfig.instance.fetch(); } + @override dynamic getValue(Feature feature) => FirebaseRemoteConfig.instance.getValue(feature.name).toValue(feature); + @override Map getAll() { final Map result = {}; for (final value in FirebaseRemoteConfig.instance.getAll().entries) { @@ -54,6 +74,7 @@ class RemoteConfigService { return result; } + @override Stream> onConfigUpdated() => FirebaseRemoteConfig.instance.onConfigUpdated.asyncMap( (event) async { await FirebaseRemoteConfig.instance.activate(); @@ -69,6 +90,7 @@ class RemoteConfigService { }, ); + @override bool isEnabled(Feature feature) => FirebaseRemoteConfig.instance.getBool(feature.name); void _logError(dynamic throwable, {StackTrace? stackTrace}) { @@ -76,6 +98,29 @@ class RemoteConfigService { } } +class MockRemoteConfigService implements IRemoteConfigService { + const MockRemoteConfigService(); + + @override + Future activeAndFetchFeatures() async {} + + @override + Future fetchConfig() async {} + + @override + Map getAll() => featuresDefaultValues; + + @override + dynamic getValue(Feature feature) => featuresDefaultValues[feature]; + + @override + // ignore: cast_nullable_to_non_nullable + bool isEnabled(Feature feature) => featuresDefaultValues[feature] as bool; + + @override + Stream> onConfigUpdated() => const Stream.empty(); +} + extension on RemoteConfigValue { dynamic toValue(Feature feature) { switch (feature) { diff --git a/lib/data/shared_prefs_service.dart b/lib/data/shared_prefs_service.dart index 96d7d9d..443f2e5 100644 --- a/lib/data/shared_prefs_service.dart +++ b/lib/data/shared_prefs_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/camera_feature.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'; @@ -18,6 +19,7 @@ class UserPreferencesService { static const cameraEvCalibrationKey = "cameraEvCalibration"; static const lightSensorEvCalibrationKey = "lightSensorEvCalibration"; static const meteringScreenLayoutKey = "meteringScreenLayout"; + static const cameraFeaturesKey = "cameraFeatures"; static const caffeineKey = "caffeine"; static const hapticsKey = "haptics"; @@ -70,16 +72,13 @@ class UserPreferencesService { } } - IsoValue get iso => - IsoValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(isoKey) ?? 100)); + IsoValue get iso => IsoValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(isoKey) ?? 100)); set iso(IsoValue value) => _sharedPreferences.setInt(isoKey, value.value); - NdValue get ndFilter => - NdValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(ndFilterKey) ?? 0)); + NdValue get ndFilter => NdValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(ndFilterKey) ?? 0)); set ndFilter(NdValue value) => _sharedPreferences.setInt(ndFilterKey, value.value); - EvSourceType get evSourceType => - EvSourceType.values[_sharedPreferences.getInt(evSourceTypeKey) ?? 0]; + EvSourceType get evSourceType => EvSourceType.values[_sharedPreferences.getInt(evSourceTypeKey) ?? 0]; set evSourceType(EvSourceType value) => _sharedPreferences.setInt(evSourceTypeKey, value.index); StopType get stopType => StopType.values[_sharedPreferences.getInt(stopTypeKey) ?? 2]; @@ -96,7 +95,6 @@ class UserPreferencesService { MeteringScreenLayoutFeature.equipmentProfiles: true, MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: true, }; } } @@ -104,6 +102,21 @@ class UserPreferencesService { set meteringScreenLayout(MeteringScreenLayoutConfig value) => _sharedPreferences.setString(meteringScreenLayoutKey, json.encode(value.toJson())); + CameraFeaturesConfig get cameraFeatures { + final configJson = _sharedPreferences.getString(cameraFeaturesKey); + if (configJson != null) { + return CameraFeaturesConfigJson.fromJson(json.decode(configJson) as Map); + } else { + return { + CameraFeature.spotMetering: false, + CameraFeature.histogram: false, + }; + } + } + + set cameraFeatures(CameraFeaturesConfig value) => + _sharedPreferences.setString(cameraFeaturesKey, json.encode(value.toJson())); + bool get caffeine => _sharedPreferences.getBool(caffeineKey) ?? false; set caffeine(bool value) => _sharedPreferences.setBool(caffeineKey, value); @@ -114,8 +127,7 @@ class UserPreferencesService { (e) => e.toString() == _sharedPreferences.getString(volumeActionKey), orElse: () => VolumeAction.shutter, ); - set volumeAction(VolumeAction value) => - _sharedPreferences.setString(volumeActionKey, value.toString()); + set volumeAction(VolumeAction value) => _sharedPreferences.setString(volumeActionKey, value.toString()); SupportedLocale get locale => SupportedLocale.values.firstWhere( (e) => e.toString() == _sharedPreferences.getString(localeKey), @@ -124,13 +136,10 @@ class UserPreferencesService { set locale(SupportedLocale value) => _sharedPreferences.setString(localeKey, value.toString()); double get cameraEvCalibration => _sharedPreferences.getDouble(cameraEvCalibrationKey) ?? 0.0; - set cameraEvCalibration(double value) => - _sharedPreferences.setDouble(cameraEvCalibrationKey, value); + set cameraEvCalibration(double value) => _sharedPreferences.setDouble(cameraEvCalibrationKey, value); - double get lightSensorEvCalibration => - _sharedPreferences.getDouble(lightSensorEvCalibrationKey) ?? 0.0; - set lightSensorEvCalibration(double value) => - _sharedPreferences.setDouble(lightSensorEvCalibrationKey, value); + double get lightSensorEvCalibration => _sharedPreferences.getDouble(lightSensorEvCalibrationKey) ?? 0.0; + set lightSensorEvCalibration(double value) => _sharedPreferences.setDouble(lightSensorEvCalibrationKey, value); ThemeType get themeType => ThemeType.values[_sharedPreferences.getInt(themeTypeKey) ?? 0]; set themeType(ThemeType value) => _sharedPreferences.setInt(themeTypeKey, value.index); diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 57e91d2..02bb95a 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -39,7 +39,11 @@ "meteringScreenLayoutHintEquipmentProfiles": "Equipment profile picker", "meteringScreenFeatureExtremeExposurePairs": "Fastest & shortest exposure pairs", "meteringScreenFeatureFilmPicker": "Film picker", - "meteringScreenFeatureHistogram": "Histogram", + "cameraFeatures": "Camera features", + "cameraFeatureSpotMetering": "Spot metering", + "cameraFeatureSpotMeteringHint": "Long press the camera view to remove metering spot", + "cameraFeatureHistogram": "Histogram", + "cameraFeatureHistogramHint": "Enabling histogram can encrease battery drain", "film": "Film", "filmPush": "Film (push)", "filmPull": "Film (pull)", @@ -94,11 +98,11 @@ }, "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.", + "lightmeterProDescription": "Unlocks extra features:\n \u2022 Equipment profiles containing filters for aperture, shutter speed, and more\n \u2022 List of films with compensation for what's known as reciprocity failure\n \u2022 Spot metering\n \u2022 Histogram\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.", + "unlockProFeaturesDescription": "Unlock professional features:\n \u2022 Equipment profiles containing filters for aperture, shutter speed, and more\n \u2022 List of films with compensation for what's known as reciprocity failure\n \u2022 Spot metering\n \u2022 Histogram\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", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index b1cd7fb..f8b52c3 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -39,7 +39,11 @@ "meteringScreenLayoutHintEquipmentProfiles": "Sélecteur de profil de l'équipement", "meteringScreenFeatureExtremeExposurePairs": "Paires d'exposition les plus rapides et les plus courtes", "meteringScreenFeatureFilmPicker": "Sélecteur de film", - "meteringScreenFeatureHistogram": "Histogramme", + "cameraFeatures": "Fonctionnalités de la caméra", + "cameraFeatureSpotMetering": "Mesure spot", + "cameraFeatureSpotMeteringHint": "Appuyez longuement sur la vue de l'appareil photo pour supprimer le spot de mesure", + "cameraFeatureHistogram": "Histogramme", + "cameraFeatureHistogramHint": "L'activation de l'histogramme peut augmenter la consommation de la batterie", "film": "Pellicule", "filmPush": "Pellicule (push)", "filmPull": "Pellicule (pull)", @@ -94,11 +98,11 @@ }, "buyLightmeterPro": "Acheter Lightmeter Pro", "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.", + "lightmeterProDescription": "Débloque des fonctionnalités supplémentaires:\n \u2022 Profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore\n \u2022 Liste de films avec une compensation pour ce que l'on appelle l'échec de réciprocité\n \u2022 Mesure spot\n \u2022 Histogramme\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.", + "unlockProFeaturesDescription": "Déverrouillez des fonctions professionnelles:\n \u2022 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 \u2022 Mesure spot\n \u2022 Histogramme\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", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index a18cc9c..f25dc8e 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -39,7 +39,11 @@ "meteringScreenLayoutHintEquipmentProfiles": "Выбор профиля оборудования", "meteringScreenFeatureExtremeExposurePairs": "Длинная и короткая выдержки", "meteringScreenFeatureFilmPicker": "Выбор пленки", - "meteringScreenFeatureHistogram": "Гистограмма", + "cameraFeatures": "Возможности камеры", + "cameraFeatureSpotMetering": "Точечный замер", + "cameraFeatureSpotMeteringHint": "Используйте долгое нажатие, чтобы удалить точку замера", + "cameraFeatureHistogram": "Гистограмма", + "cameraFeatureHistogramHint": "Использование гистограммы может увеличить расход аккумулятора", "film": "Пленка", "filmPush": "Пленка (push)", "filmPull": "Пленка (pull)", @@ -94,11 +98,11 @@ }, "buyLightmeterPro": "Купить Lightmeter Pro", "lightmeterPro": "Lightmeter Pro", - "lightmeterProDescription": "Даёт доступ к таким функциям как профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений, а также набору пленок с компенсацией эффекта Шварцшильда.\n\nИсходный код Lightmeter доступен на GitHub. Вы можете собрать его самостоятельно. Однако если вы хотите поддержать разработку и получать новые функции и обновления, то приобретите Lightmeter Pro.", + "lightmeterProDescription": "Даёт доступ к различным функциям:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nИсходный код Lightmeter доступен на GitHub. Вы можете собрать его самостоятельно. Однако если вы хотите поддержать разработку и получать новые функции и обновления, то приобретите Lightmeter Pro.", "buy": "Купить", "proFeatures": "Профессиональные настройки", "unlockProFeatures": "Разблокировать профессиональные настройки", - "unlockProFeaturesDescription": "Вы можете разблокировать профессиональные настройки, такие как профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений, а также набору пленок с компенсацией эффекта Шварцшильда.\n\nПолучая доступ к профессиональным настройкам, вы поддерживаете разработку и делаете возможным появление новых функций в приложении.", + "unlockProFeaturesDescription": "Вы можете разблокировать профессиональные настройки:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nПолучая доступ к профессиональным настройкам, вы поддерживаете разработку и делаете возможным появление новых функций в приложении.", "unlock": "Разблокировать", "tooltipAdd": "Добавить", "tooltipClose": "Закрыть", diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 5788ea9..714c4b3 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -39,7 +39,11 @@ "meteringScreenLayoutHintEquipmentProfiles": "设备配置选择", "meteringScreenFeatureExtremeExposurePairs": "最快 & 最慢曝光组合", "meteringScreenFeatureFilmPicker": "胶片选择", - "meteringScreenFeatureHistogram": "直方图", + "cameraFeatures": "相机功能", + "cameraFeatureSpotMetering": "点测光", + "cameraFeatureSpotMeteringHint": "长按相机视图可移除测光点", + "cameraFeatureHistogram": "直方图", + "cameraFeatureHistogramHint": "启用直方图会增加电池消耗", "film": "胶片", "filmPush": "胶片 (push)", "filmPull": "胶片 (pull)", @@ -94,11 +98,11 @@ }, "buyLightmeterPro": "购买 Lightmeter Pro", "lightmeterPro": "Lightmeter Pro", - "lightmeterProDescription": "购买以解锁额外功能。例如包含光圈、快门速度等参数的配置文件;以及一个胶卷预设列表来提供倒易率失效时的曝光补偿。\n\n您可以在 GitHub 上获取 Lightmeter 的源代码,欢迎自行编译。不过,如果您想支持开发并获得新功能和更新,请考虑购买 Lightmeter Pro。", + "lightmeterProDescription": "解锁额外功能:\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶片预设列表,用于在反转率发生故障时提供曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n您可以在 GitHub 上获取 Lightmeter 的源代码,欢迎自行编译。不过,如果您想支持开发并获得新功能和更新,请考虑购买 Lightmeter Pro。", "buy": "购买", "proFeatures": "专业功能", "unlockProFeatures": "解锁专业功能", - "unlockProFeaturesDescription": "解锁专业功能。例如包含光圈、快门速度等参数的配置文件;以及一个胶卷预设列表来提供倒易率失效时的曝光补偿。\n\n通过解锁专业版功能,您可以支持开发工作,帮助为应用程序添加新功能。", + "unlockProFeaturesDescription": "\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶片预设列表,用于在反转率发生故障时提供曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n通过解锁专业版功能,您可以支持开发工作,帮助为应用程序添加新功能。", "unlock": "解锁", "tooltipAdd": "添加", "tooltipClose": "关闭", diff --git a/lib/providers/remote_config_provider.dart b/lib/providers/remote_config_provider.dart index a00b385..4557ed6 100644 --- a/lib/providers/remote_config_provider.dart +++ b/lib/providers/remote_config_provider.dart @@ -6,7 +6,7 @@ import 'package:lightmeter/data/models/feature.dart'; import 'package:lightmeter/data/remote_config_service.dart'; class RemoteConfigProvider extends StatefulWidget { - final RemoteConfigService remoteConfigService; + final IRemoteConfigService remoteConfigService; final Widget child; const RemoteConfigProvider({ diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index 3a4111c..764f282 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -1,6 +1,7 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:lightmeter/data/models/camera_feature.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'; @@ -9,6 +10,7 @@ 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/res/theme.dart'; +import 'package:lightmeter/utils/map_model.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class UserPreferencesProvider extends StatefulWidget { @@ -51,6 +53,14 @@ class UserPreferencesProvider extends StatefulWidget { return _inheritFromEnumsModel(context, _Aspect.stopType).stopType; } + static CameraFeaturesConfig cameraConfigOf(BuildContext context) { + return context.findAncestorWidgetOfExactType<_CameraFeaturesModel>()!.data; + } + + static bool cameraFeatureOf(BuildContext context, CameraFeature feature) { + return InheritedModel.inheritFrom<_CameraFeaturesModel>(context, aspect: feature)!.data[feature]!; + } + static ThemeData themeOf(BuildContext context) { return _inheritFromEnumsModel(context, _Aspect.theme).theme; } @@ -74,6 +84,7 @@ class _UserPreferencesProviderState extends State with late EvSourceType _evSourceType; late StopType _stopType = widget.userPreferencesService.stopType; late MeteringScreenLayoutConfig _meteringScreenLayout = widget.userPreferencesService.meteringScreenLayout; + late CameraFeaturesConfig _cameraFeatures = widget.userPreferencesService.cameraFeatures; late SupportedLocale _locale = widget.userPreferencesService.locale; late ThemeType _themeType = widget.userPreferencesService.themeType; late Color _primaryColor = widget.userPreferencesService.primaryColor; @@ -83,7 +94,8 @@ class _UserPreferencesProviderState extends State with void initState() { super.initState(); _evSourceType = widget.userPreferencesService.evSourceType; - _evSourceType = _evSourceType == EvSourceType.sensor && !widget.hasLightSensor ? EvSourceType.camera : _evSourceType; + _evSourceType = + _evSourceType == EvSourceType.sensor && !widget.hasLightSensor ? EvSourceType.camera : _evSourceType; WidgetsBinding.instance.addObserver(this); } @@ -127,7 +139,10 @@ class _UserPreferencesProviderState extends State with themeType: _themeType, child: _MeteringScreenLayoutModel( data: _meteringScreenLayout, - child: widget.child, + child: _CameraFeaturesModel( + data: _cameraFeatures, + child: widget.child, + ), ), ); }, @@ -172,6 +187,13 @@ class _UserPreferencesProviderState extends State with widget.userPreferencesService.meteringScreenLayout = _meteringScreenLayout; } + void setCameraFeature(CameraFeaturesConfig config) { + setState(() { + _cameraFeatures = config; + }); + widget.userPreferencesService.cameraFeatures = _cameraFeatures; + } + void setPrimaryColor(Color primaryColor) { setState(() { _primaryColor = primaryColor; @@ -264,27 +286,16 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> { } } -class _MeteringScreenLayoutModel extends InheritedModel { - final Map data; - +class _MeteringScreenLayoutModel extends MapModel { const _MeteringScreenLayoutModel({ - required this.data, + required super.data, + required super.child, + }); +} + +class _CameraFeaturesModel extends MapModel { + const _CameraFeaturesModel({ + required super.data, required super.child, }); - - @override - bool updateShouldNotify(_MeteringScreenLayoutModel oldWidget) => oldWidget.data != data; - - @override - bool updateShouldNotifyDependent( - _MeteringScreenLayoutModel oldWidget, - Set dependencies, - ) { - for (final dependecy in dependencies) { - if (oldWidget.data[dependecy] != data[dependecy]) { - return true; - } - } - return false; - } } diff --git a/lib/res/theme.dart b/lib/res/theme.dart index a6320c1..52e0e92 100644 --- a/lib/res/theme.dart +++ b/lib/res/theme.dart @@ -23,7 +23,7 @@ const primaryColorsList = [ ThemeData themeFrom(Color primaryColor, Brightness brightness) { final scheme = _colorSchemeFromColor(primaryColor, brightness); - return ThemeData( + final theme = ThemeData( useMaterial3: true, brightness: scheme.brightness, primaryColor: primaryColor, @@ -60,12 +60,18 @@ ThemeData themeFrom(Color primaryColor, Brightness brightness) { ), scaffoldBackgroundColor: scheme.surface, ); + return theme.copyWith( + listTileTheme: ListTileThemeData( + style: ListTileStyle.list, + iconColor: scheme.onSurface, + textColor: scheme.onSurface, + subtitleTextStyle: theme.textTheme.bodyMedium!.copyWith(color: scheme.onSurfaceVariant), + ), + ); } ColorScheme _colorSchemeFromColor(Color primaryColor, Brightness brightness) { - final scheme = brightness == Brightness.light - ? Scheme.light(primaryColor.value) - : Scheme.dark(primaryColor.value); + final scheme = brightness == Brightness.light ? Scheme.light(primaryColor.value) : Scheme.dark(primaryColor.value); return ColorScheme( brightness: brightness, diff --git a/lib/screens/metering/components/camera_container/bloc_container_camera.dart b/lib/screens/metering/components/camera_container/bloc_container_camera.dart index 1d7d4b5..2e6b051 100644 --- a/lib/screens/metering/components/camera_container/bloc_container_camera.dart +++ b/lib/screens/metering/components/camera_container/bloc_container_camera.dart @@ -11,10 +11,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; -import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' - as communication_event; -import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' - as communication_states; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_event; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart'; import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart'; @@ -57,6 +55,7 @@ class CameraContainerBloc extends EvSourceBlocBase(_onZoomChanged); on(_onExposureOffsetChanged); on(_onExposureOffsetResetEvent); + on(_onExposureSpotChangedEvent); } @override @@ -166,9 +165,7 @@ class CameraContainerBloc extends EvSourceBlocBase _onZoomChanged(ZoomChangedEvent event, Emitter emit) async { - if (_cameraController != null && - event.value >= _zoomRange!.start && - event.value <= _zoomRange!.end) { + if (_cameraController != null && event.value >= _zoomRange!.start && event.value <= _zoomRange!.end) { _cameraController!.setZoomLevel(event.value); _currentZoom = event.value; _emitActiveState(emit); @@ -188,6 +185,13 @@ class CameraContainerBloc extends EvSourceBlocBase _onExposureSpotChangedEvent(ExposureSpotChangedEvent event, Emitter emit) async { + if (_cameraController != null) { + _cameraController!.setExposurePoint(event.offset); + _cameraController!.setFocusPoint(event.offset); + } + } + void _emitActiveState(Emitter emit) { emit( CameraActiveState( diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart b/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart new file mode 100644 index 0000000..0ec7f17 --- /dev/null +++ b/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class CameraSpotDetector extends StatefulWidget { + final ValueChanged onSpotTap; + + const CameraSpotDetector({ + required this.onSpotTap, + super.key, + }); + + @override + State createState() => _CameraSpotDetectorState(); +} + +class _CameraSpotDetectorState extends State { + Offset? spot; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (_, constraints) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (TapDownDetails details) => onViewFinderTap(details, constraints), + onLongPress: () => onViewFinderTap(null, constraints), + child: Stack( + children: [ + if (spot != null) + AnimatedPositioned( + duration: Dimens.durationS, + left: spot!.dx - Dimens.grid16 / 2, + top: spot!.dy - Dimens.grid16 / 2, + height: Dimens.grid16, + width: Dimens.grid16, + child: const _Spot(), + ), + ], + ), + ), + ); + } + + void onViewFinderTap(TapDownDetails? details, BoxConstraints constraints) { + setState(() { + spot = details?.localPosition; + }); + + widget.onSpotTap( + details != null + ? Offset( + details.localPosition.dx / constraints.maxWidth, + details.localPosition.dy / constraints.maxHeight, + ) + : null, + ); + } +} + +class _Spot extends StatelessWidget { + const _Spot(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + color: Colors.white70, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart b/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart index 7c06062..c054f3d 100644 --- a/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart +++ b/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart @@ -12,14 +12,17 @@ class CameraView extends StatelessWidget { final value = controller.value; return ValueListenableBuilder( valueListenable: controller, - builder: (_, __, ___) => AspectRatio( + builder: (_, __, Widget? child) => AspectRatio( aspectRatio: _isLandscape(value) ? value.aspectRatio : (1 / value.aspectRatio), - child: value.isInitialized - ? RotatedBox( - quarterTurns: _getQuarterTurns(value), - child: controller.buildPreview(), - ) - : const SizedBox.shrink(), + child: Stack( + children: [ + RotatedBox( + quarterTurns: _getQuarterTurns(value), + child: controller.buildPreview(), + ), + child ?? const SizedBox(), + ], + ), ), ); } @@ -42,8 +45,6 @@ class CameraView extends StatelessWidget { DeviceOrientation _getApplicableOrientation(CameraValue value) { return value.isRecordingVideo ? value.recordingOrientation! - : (value.previewPauseOrientation ?? - value.lockedCaptureOrientation ?? - value.deviceOrientation); + : (value.previewPauseOrientation ?? value.lockedCaptureOrientation ?? value.deviceOrientation); } } diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart b/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart index 3e9538f..e6f1699 100644 --- a/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart +++ b/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart @@ -1,19 +1,27 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; -import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/data/models/camera_feature.dart'; import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart'; import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart'; import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/camera_view_placeholder/widget_placeholder_camera_view.dart'; import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/histogram/widget_histogram.dart'; import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; class CameraPreview extends StatefulWidget { final CameraController? controller; final CameraErrorType? error; + final ValueChanged onSpotTap; - const CameraPreview({this.controller, this.error, super.key}); + const CameraPreview({ + this.controller, + this.error, + required this.onSpotTap, + super.key, + }); @override State createState() => _CameraPreviewState(); @@ -31,7 +39,10 @@ class _CameraPreviewState extends State { AnimatedSwitcher( duration: Dimens.switchDuration, child: widget.controller != null - ? _CameraPreviewBuilder(controller: widget.controller!) + ? _CameraPreviewBuilder( + controller: widget.controller!, + onSpotTap: widget.onSpotTap, + ) : CameraViewPlaceholder(error: widget.error), ), ], @@ -43,16 +54,19 @@ class _CameraPreviewState extends State { class _CameraPreviewBuilder extends StatefulWidget { final CameraController controller; + final ValueChanged onSpotTap; - const _CameraPreviewBuilder({required this.controller}); + const _CameraPreviewBuilder({ + required this.controller, + required this.onSpotTap, + }); @override State<_CameraPreviewBuilder> createState() => _CameraPreviewBuilderState(); } class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> { - late final ValueNotifier _initializedNotifier = - ValueNotifier(widget.controller.value.isInitialized); + late final ValueNotifier _initializedNotifier = ValueNotifier(widget.controller.value.isInitialized); @override void initState() { @@ -79,16 +93,23 @@ class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> { alignment: Alignment.bottomCenter, children: [ CameraView(controller: widget.controller), - if (UserPreferencesProvider.meteringScreenFeatureOf( - context, - MeteringScreenLayoutFeature.histogram, - )) - Positioned( - left: Dimens.grid8, - right: Dimens.grid8, - bottom: Dimens.grid16, - child: CameraHistogram(controller: widget.controller), - ), + if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ...[ + if (UserPreferencesProvider.cameraFeatureOf( + context, + CameraFeature.histogram, + )) + Positioned( + left: Dimens.grid8, + right: Dimens.grid8, + bottom: Dimens.grid16, + child: CameraHistogram(controller: widget.controller), + ), + if (UserPreferencesProvider.cameraFeatureOf( + context, + CameraFeature.spotMetering, + )) + CameraSpotDetector(onSpotTap: widget.onSpotTap) + ], ], ) : const SizedBox.shrink(), diff --git a/lib/screens/metering/components/camera_container/event_container_camera.dart b/lib/screens/metering/components/camera_container/event_container_camera.dart index d3e5995..fe0713d 100644 --- a/lib/screens/metering/components/camera_container/event_container_camera.dart +++ b/lib/screens/metering/components/camera_container/event_container_camera.dart @@ -1,3 +1,5 @@ +import 'package:flutter/gestures.dart'; + abstract class CameraContainerEvent { const CameraContainerEvent(); } @@ -53,3 +55,19 @@ class ExposureOffsetChangedEvent extends CameraContainerEvent { class ExposureOffsetResetEvent extends CameraContainerEvent { const ExposureOffsetResetEvent(); } + +class ExposureSpotChangedEvent extends CameraContainerEvent { + final Offset? offset; + + const ExposureSpotChangedEvent(this.offset); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is ExposureSpotChangedEvent && other.offset == offset; + } + + @override + int get hashCode => Object.hash(offset, runtimeType); +} diff --git a/lib/screens/metering/components/camera_container/widget_container_camera.dart b/lib/screens/metering/components/camera_container/widget_container_camera.dart index 944aa34..a23545e 100644 --- a/lib/screens/metering/components/camera_container/widget_container_camera.dart +++ b/lib/screens/metering/components/camera_container/widget_container_camera.dart @@ -143,6 +143,9 @@ class _CameraViewBuilder extends StatelessWidget { builder: (context, state) => CameraPreview( controller: state is CameraInitializedState ? state.controller : null, error: state is CameraErrorState ? state.error : null, + onSpotTap: (value) { + context.read().add(ExposureSpotChangedEvent(value)); + }, ), ); } diff --git a/lib/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart b/lib/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart new file mode 100644 index 0000000..1446be3 --- /dev/null +++ b/lib/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/camera_feature.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart'; +import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart'; + +class CameraFeaturesListTile extends StatelessWidget { + const CameraFeaturesListTile({super.key}); + + @override + Widget build(BuildContext context) { + return IAPListTile( + leading: const Icon(Icons.camera_alt), + title: Text(S.of(context).cameraFeatures), + onTap: () { + showDialog( + context: context, + builder: (_) => DialogSwitch( + icon: Icons.layers_outlined, + title: S.of(context).cameraFeatures, + values: UserPreferencesProvider.cameraConfigOf(context), + titleAdapter: (context, feature) { + switch (feature) { + case CameraFeature.spotMetering: + return S.of(context).cameraFeatureSpotMetering; + case CameraFeature.histogram: + return S.of(context).cameraFeatureHistogram; + } + }, + subtitleAdapter: (context, feature) { + switch (feature) { + case CameraFeature.spotMetering: + return S.of(context).cameraFeatureSpotMeteringHint; + case CameraFeature.histogram: + return S.of(context).cameraFeatureHistogramHint; + } + }, + onSave: UserPreferencesProvider.of(context).setCameraFeature, + ), + ); + }, + ); + } +} 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 deleted file mode 100644 index 2529e2e..0000000 --- a/lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart +++ /dev/null @@ -1,97 +0,0 @@ -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_resources/m3_lightmeter_resources.dart'; - -class MeteringScreenLayoutFeaturesDialog extends StatefulWidget { - const MeteringScreenLayoutFeaturesDialog({super.key}); - - @override - State createState() => _MeteringScreenLayoutFeaturesDialogState(); -} - -class _MeteringScreenLayoutFeaturesDialogState extends State { - late final _features = MeteringScreenLayoutConfig.from(UserPreferencesProvider.meteringScreenConfigOf(context)); - - @override - Widget build(BuildContext context) { - return AlertDialog( - icon: const Icon(Icons.layers_outlined), - titlePadding: Dimens.dialogIconTitlePadding, - title: Text(S.of(context).meteringScreenLayout), - contentPadding: EdgeInsets.zero, - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), - child: Text(S.of(context).meteringScreenLayoutHint), - ), - const SizedBox(height: Dimens.grid16), - ListView( - shrinkWrap: true, - children: [ - _featureListTile(MeteringScreenLayoutFeature.equipmentProfiles), - _featureListTile(MeteringScreenLayoutFeature.extremeExposurePairs), - _featureListTile(MeteringScreenLayoutFeature.filmPicker), - _featureListTile(MeteringScreenLayoutFeature.histogram), - ], - ), - ], - ), - ), - actionsPadding: Dimens.dialogActionsPadding, - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text(S.of(context).cancel), - ), - TextButton( - onPressed: () { - if (!_features[MeteringScreenLayoutFeature.equipmentProfiles]!) { - EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first); - } - if (!_features[MeteringScreenLayoutFeature.filmPicker]!) { - FilmsProvider.of(context).setFilm(const Film.other()); - } - UserPreferencesProvider.of(context).setMeteringScreenLayout(_features); - Navigator.of(context).pop(); - }, - child: Text(S.of(context).save), - ), - ], - ); - } - - Widget _featureListTile(MeteringScreenLayoutFeature f) { - return SwitchListTile( - contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left), - title: Text(_toStringLocalized(context, f)), - value: _features[f]!, - onChanged: (value) { - setState(() { - _features.update(f, (_) => value); - }); - }, - ); - } - - String _toStringLocalized(BuildContext context, MeteringScreenLayoutFeature feature) { - switch (feature) { - case MeteringScreenLayoutFeature.equipmentProfiles: - return S.of(context).meteringScreenLayoutHintEquipmentProfiles; - case MeteringScreenLayoutFeature.extremeExposurePairs: - return S.of(context).meteringScreenFeatureExtremeExposurePairs; - case MeteringScreenLayoutFeature.filmPicker: - return S.of(context).meteringScreenFeatureFilmPicker; - case MeteringScreenLayoutFeature.histogram: - return S.of(context).meteringScreenFeatureHistogram; - } - } -} diff --git a/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart b/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart index a540926..1f89b4b 100644 --- a/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart +++ b/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/generated/l10n.dart'; - -import 'package:lightmeter/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.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/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringScreenLayoutListTile extends StatelessWidget { const MeteringScreenLayoutListTile({super.key}); @@ -14,9 +18,35 @@ class MeteringScreenLayoutListTile extends StatelessWidget { onTap: () { showDialog( context: context, - builder: (_) => const MeteringScreenLayoutFeaturesDialog(), + builder: (_) => DialogSwitch( + icon: Icons.layers_outlined, + title: S.of(context).meteringScreenLayout, + description: S.of(context).meteringScreenLayoutHint, + values: UserPreferencesProvider.meteringScreenConfigOf(context), + titleAdapter: _toStringLocalized, + onSave: (value) { + if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) { + EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first); + } + if (!value[MeteringScreenLayoutFeature.filmPicker]!) { + FilmsProvider.of(context).setFilm(const Film.other()); + } + UserPreferencesProvider.of(context).setMeteringScreenLayout(value); + }, + ), ); }, ); } + + String _toStringLocalized(BuildContext context, MeteringScreenLayoutFeature feature) { + switch (feature) { + case MeteringScreenLayoutFeature.equipmentProfiles: + return S.of(context).meteringScreenLayoutHintEquipmentProfiles; + case MeteringScreenLayoutFeature.extremeExposurePairs: + return S.of(context).meteringScreenFeatureExtremeExposurePairs; + case MeteringScreenLayoutFeature.filmPicker: + return S.of(context).meteringScreenFeatureFilmPicker; + } + } } diff --git a/lib/screens/settings/components/metering/widget_settings_section_metering.dart b/lib/screens/settings/components/metering/widget_settings_section_metering.dart index 90de68d..0c6c07d 100644 --- a/lib/screens/settings/components/metering/widget_settings_section_metering.dart +++ b/lib/screens/settings/components/metering/widget_settings_section_metering.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/screens/settings/components/metering/components/calibration/widget_list_tile_calibration.dart'; +import 'package:lightmeter/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart'; import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/widget_list_tile_equipment_profiles.dart'; import 'package:lightmeter/screens/settings/components/metering/components/films/widget_list_tile_films.dart'; import 'package:lightmeter/screens/settings/components/metering/components/fractional_stops/widget_list_tile_fractional_stops.dart'; @@ -20,6 +21,7 @@ class MeteringSettingsSection extends StatelessWidget { MeteringScreenLayoutListTile(), EquipmentProfilesListTile(), FilmsListTile(), + CameraFeaturesListTile(), ], ); } diff --git a/lib/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart b/lib/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart new file mode 100644 index 0000000..aa3a370 --- /dev/null +++ b/lib/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; + +typedef StringAdapter = String Function(BuildContext context, T value); + +class DialogSwitch extends StatefulWidget { + final IconData icon; + final String title; + final String? description; + final Map values; + final StringAdapter titleAdapter; + final StringAdapter? subtitleAdapter; + final ValueChanged> onSave; + + const DialogSwitch({ + required this.icon, + required this.title, + this.description, + required this.values, + required this.titleAdapter, + this.subtitleAdapter, + required this.onSave, + super.key, + }); + + @override + State> createState() => _DialogSwitchState(); +} + +class _DialogSwitchState extends State> { + late final Map _features = Map.from(widget.values); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: Icon(widget.icon), + titlePadding: Dimens.dialogIconTitlePadding, + title: Text(widget.title), + contentPadding: EdgeInsets.zero, + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.description != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), + child: Text(widget.description!), + ), + const SizedBox(height: Dimens.grid16) + ], + ListView( + shrinkWrap: true, + children: _features.entries + .map( + (entry) => SwitchListTile( + contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left), + title: Text(widget.titleAdapter(context, entry.key)), + subtitle: widget.subtitleAdapter != null + ? Text( + widget.subtitleAdapter!.call(context, entry.key), + style: Theme.of(context).listTileTheme.subtitleTextStyle, + ) + : null, + value: _features[entry.key]!, + onChanged: (value) { + setState(() { + _features.update(entry.key, (_) => value); + }); + }, + ), + ) + .toList(), + ), + ], + ), + ), + actionsPadding: Dimens.dialogActionsPadding, + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(S.of(context).cancel), + ), + TextButton( + onPressed: () { + widget.onSave(_features); + Navigator.of(context).pop(); + }, + child: Text(S.of(context).save), + ), + ], + ); + } +} diff --git a/lib/screens/settings/utils/show_buy_pro_dialog.dart b/lib/screens/settings/utils/show_buy_pro_dialog.dart index 7ce3ff6..181259f 100644 --- a/lib/screens/settings/utils/show_buy_pro_dialog.dart +++ b/lib/screens/settings/utils/show_buy_pro_dialog.dart @@ -7,6 +7,31 @@ import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; Future showBuyProDialog(BuildContext context) { final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText); + + Widget splitDescription() { + final description = + unlockFeaturesEnabled ? S.of(context).unlockProFeaturesDescription : S.of(context).lightmeterProDescription; + final paragraphs = description.split('\n\n'); + final features = paragraphs.first.split('\n \u2022 ').sublist(1); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(paragraphs.first.split('\n \u2022 ').first), + ...features.map( + (f) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('\u2022 '), + Flexible(child: Text(f)), + ], + ), + ), + Text('\n${paragraphs.last}'), + ], + ); + } + return showDialog( context: context, builder: (_) => AlertDialog( @@ -14,11 +39,7 @@ Future showBuyProDialog(BuildContext context) { titlePadding: Dimens.dialogIconTitlePadding, title: Text(unlockFeaturesEnabled ? S.of(context).proFeatures : S.of(context).lightmeterPro), contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), - content: SingleChildScrollView( - child: Text( - unlockFeaturesEnabled ? S.of(context).unlockProFeaturesDescription : S.of(context).lightmeterProDescription, - ), - ), + content: SingleChildScrollView(child: splitDescription()), actionsPadding: Dimens.dialogActionsPadding, actions: [ TextButton( diff --git a/lib/utils/map_model.dart b/lib/utils/map_model.dart new file mode 100644 index 0000000..1f5ea09 --- /dev/null +++ b/lib/utils/map_model.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class MapModel extends InheritedModel { + final Map data; + + const MapModel({ + required this.data, + required super.child, + }); + + @override + bool updateShouldNotify(MapModel oldWidget) => oldWidget.data != data; + + @override + bool updateShouldNotifyDependent( + MapModel oldWidget, + Set dependencies, + ) { + for (final dependecy in dependencies) { + if (oldWidget.data[dependecy] != data[dependecy]) { + return true; + } + } + return false; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index eb53e59..d88de9f 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.7.0 + ref: v0.7.1 m3_lightmeter_resources: git: url: "https://github.com/vodemn/m3_lightmeter_resources" diff --git a/screenshots/generate_screenshots.dart b/screenshots/generate_screenshots.dart index 8c8e7e9..814cfd1 100644 --- a/screenshots/generate_screenshots.dart +++ b/screenshots/generate_screenshots.dart @@ -46,7 +46,6 @@ void main() { MeteringScreenLayoutFeature.equipmentProfiles: true, MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: false, }.toJson(), ), diff --git a/test/data/models/camera_features_config_test.dart b/test/data/models/camera_features_config_test.dart new file mode 100644 index 0000000..d7261a8 --- /dev/null +++ b/test/data/models/camera_features_config_test.dart @@ -0,0 +1,47 @@ +import 'package:lightmeter/data/models/camera_feature.dart'; +import 'package:test/test.dart'; + +void main() { + group( + 'fromJson()', + () { + test('All keys', () { + expect( + CameraFeaturesConfigJson.fromJson( + { + 'spotMetering': true, + 'histogram': true, + }, + ), + { + CameraFeature.spotMetering: true, + CameraFeature.histogram: true, + }, + ); + }); + + test('Legacy (no spotMetering & histogram)', () { + expect( + CameraFeaturesConfigJson.fromJson({}), + { + CameraFeature.spotMetering: false, + CameraFeature.histogram: false, + }, + ); + }); + }, + ); + + test('toJson()', () { + expect( + { + CameraFeature.spotMetering: true, + CameraFeature.histogram: true, + }.toJson(), + { + 'spotMetering': true, + 'histogram': true, + }, + ); + }); +} diff --git a/test/data/models/metering_screen_layout_config_test.dart b/test/data/models/metering_screen_layout_config_test.dart index 9e9393e..f3216ba 100644 --- a/test/data/models/metering_screen_layout_config_test.dart +++ b/test/data/models/metering_screen_layout_config_test.dart @@ -18,7 +18,6 @@ void main() { { MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: true, MeteringScreenLayoutFeature.equipmentProfiles: true, }, ); @@ -35,7 +34,6 @@ void main() { { MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: false, - MeteringScreenLayoutFeature.histogram: true, MeteringScreenLayoutFeature.equipmentProfiles: true, }, ); @@ -53,7 +51,6 @@ void main() { { MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: false, - MeteringScreenLayoutFeature.histogram: false, MeteringScreenLayoutFeature.equipmentProfiles: true, }, ); @@ -67,13 +64,11 @@ void main() { MeteringScreenLayoutFeature.equipmentProfiles: true, MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: true, }.toJson(), { - '3': true, - '0': true, - '1': true, - '2': true, + 'equipmentProfiles': true, + 'extremeExposurePairs': true, + 'filmPicker': true, }, ); }); diff --git a/test/data/shared_prefs_service_test.dart b/test/data/shared_prefs_service_test.dart index 8ec42e1..96f4e1e 100644 --- a/test/data/shared_prefs_service_test.dart +++ b/test/data/shared_prefs_service_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/camera_feature.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'; @@ -191,12 +192,11 @@ void main() { MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, MeteringScreenLayoutFeature.equipmentProfiles: true, - MeteringScreenLayoutFeature.histogram: true, }, ); }); - test('get', () { + test('get (legacy)', () { when( () => sharedPreferences.getString(UserPreferencesService.meteringScreenLayoutKey), ).thenReturn("""{"0":false,"1":true}"""); @@ -206,7 +206,20 @@ void main() { MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: true, MeteringScreenLayoutFeature.equipmentProfiles: true, - MeteringScreenLayoutFeature.histogram: true, + }, + ); + }); + + test('get', () { + when( + () => sharedPreferences.getString(UserPreferencesService.meteringScreenLayoutKey), + ).thenReturn("""{"extremeExposurePairs":false,"filmPicker":true}"""); + expect( + service.meteringScreenLayout, + { + MeteringScreenLayoutFeature.extremeExposurePairs: false, + MeteringScreenLayoutFeature.filmPicker: true, + MeteringScreenLayoutFeature.equipmentProfiles: true, }, ); }); @@ -215,19 +228,62 @@ void main() { when( () => sharedPreferences.setString( UserPreferencesService.meteringScreenLayoutKey, - """{"0":false,"1":true,"2":true,"3":true}""", + """{"extremeExposurePairs":false,"filmPicker":true,"equipmentProfiles":true}""", ), ).thenAnswer((_) => Future.value(true)); service.meteringScreenLayout = { MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: true, MeteringScreenLayoutFeature.equipmentProfiles: true, }; verify( () => sharedPreferences.setString( UserPreferencesService.meteringScreenLayoutKey, - """{"0":false,"1":true,"2":true,"3":true}""", + """{"extremeExposurePairs":false,"filmPicker":true,"equipmentProfiles":true}""", + ), + ).called(1); + }); + }); + + group('cameraFeatures', () { + test('get default', () { + when(() => sharedPreferences.getString(UserPreferencesService.cameraFeaturesKey)).thenReturn(null); + expect( + service.cameraFeatures, + { + CameraFeature.spotMetering: false, + CameraFeature.histogram: false, + }, + ); + }); + + test('get', () { + when(() => sharedPreferences.getString(UserPreferencesService.cameraFeaturesKey)) + .thenReturn("""{"spotMetering":false,"histogram":true}"""); + expect( + service.cameraFeatures, + { + CameraFeature.spotMetering: false, + CameraFeature.histogram: true, + }, + ); + }); + + test('set', () { + when( + () => sharedPreferences.setString( + UserPreferencesService.cameraFeaturesKey, + """{"spotMetering":false,"histogram":true}""", + ), + ).thenAnswer((_) => Future.value(true)); + service.cameraFeatures = { + CameraFeature.spotMetering: false, + CameraFeature.histogram: true, + }; + verify( + () => sharedPreferences.setString( + UserPreferencesService.cameraFeaturesKey, + """{"spotMetering":false,"histogram":true}""", ), ).called(1); }); diff --git a/test/providers/user_preferences_provider_test.dart b/test/providers/user_preferences_provider_test.dart index ffa7993..b6dc2ae 100644 --- a/test/providers/user_preferences_provider_test.dart +++ b/test/providers/user_preferences_provider_test.dart @@ -1,6 +1,7 @@ import 'package:dynamic_color/test_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/camera_feature.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'; @@ -31,7 +32,10 @@ void main() { MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, MeteringScreenLayoutFeature.equipmentProfiles: true, - MeteringScreenLayoutFeature.histogram: true, + }); + when(() => mockUserPreferencesService.cameraFeatures).thenReturn({ + CameraFeature.spotMetering: true, + CameraFeature.histogram: true, }); when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en); when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); @@ -184,7 +188,6 @@ void main() { MeteringScreenLayoutFeature.equipmentProfiles: true, MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: false, - MeteringScreenLayoutFeature.histogram: true, }), child: const Text(''), ), @@ -196,20 +199,64 @@ void main() { 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 camera features config', + (tester) async { + await pumpTestWidget( + tester, + builder: (context) { + final config = UserPreferencesProvider.cameraConfigOf(context); + return Column( + children: [ + ...List.generate( + config.length, + (index) => Text('${config.keys.toList()[index]}: ${config.values.toList()[index]}'), + ), + ...List.generate( + CameraFeature.values.length, + (index) => Text( + '${CameraFeature.values[index]}: ${UserPreferencesProvider.cameraFeatureOf(context, CameraFeature.values[index])}', + ), + ), + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setCameraFeature({ + CameraFeature.spotMetering: true, + CameraFeature.histogram: false, + }), + child: const Text(''), + ), + ], + ); + }, + ); + // Match `findsNWidgets(2)` to verify that `cameraFeatureOf` specific results are the same as the whole config + expect(find.text("${CameraFeature.spotMetering}: true"), findsNWidgets(2)); + expect(find.text("${CameraFeature.histogram}: true"), findsNWidgets(2)); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text("${CameraFeature.spotMetering}: true"), findsNWidgets(2)); + expect(find.text("${CameraFeature.histogram}: false"), findsNWidgets(2)); + verify( + () => mockUserPreferencesService.cameraFeatures = { + CameraFeature.spotMetering: true, + CameraFeature.histogram: false, }, ).called(1); }, diff --git a/test/screens/metering/components/camera/bloc_container_camera_test.dart b/test/screens/metering/components/camera/bloc_container_camera_test.dart index d54c0a5..52c57c8 100644 --- a/test/screens/metering/components/camera/bloc_container_camera_test.dart +++ b/test/screens/metering/components/camera/bloc_container_camera_test.dart @@ -4,10 +4,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; -import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' - as communication_events; -import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' - as communication_states; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_events; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart'; @@ -16,9 +14,9 @@ import 'package:mocktail/mocktail.dart'; class _MockMeteringInteractor extends Mock implements MeteringInteractor {} -class _MockMeteringCommunicationBloc extends MockBloc< - communication_events.MeteringCommunicationEvent, - communication_states.MeteringCommunicationState> implements MeteringCommunicationBloc {} +class _MockMeteringCommunicationBloc + extends MockBloc + implements MeteringCommunicationBloc {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -147,8 +145,7 @@ void main() { verify(() => meteringInteractor.requestCameraPermission()).called(1); }, expect: () => [ - isA() - .having((state) => state.error, "error", CameraErrorType.permissionNotGranted), + isA().having((state) => state.error, "error", CameraErrorType.permissionNotGranted), ], ); @@ -166,8 +163,7 @@ void main() { }, expect: () => [ isA(), - isA() - .having((state) => state.error, "error", CameraErrorType.permissionNotGranted), + isA().having((state) => state.error, "error", CameraErrorType.permissionNotGranted), ], ); @@ -215,8 +211,7 @@ void main() { 'No cameras detected error', setUp: () { when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( cameraMethodChannel, (methodCall) async => cameraMethodCallSuccessHandler(methodCall, cameras: const []), ); @@ -232,8 +227,7 @@ void main() { }, expect: () => [ isA(), - isA() - .having((state) => state.error, "error", CameraErrorType.noCamerasDetected), + isA().having((state) => state.error, "error", CameraErrorType.noCamerasDetected), ], ); @@ -241,8 +235,7 @@ void main() { 'No back facing cameras available', setUp: () { when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( cameraMethodChannel, (methodCall) async => cameraMethodCallSuccessHandler(methodCall, cameras: frontCameras), ); @@ -263,8 +256,7 @@ void main() { 'Catch other initialization errors', setUp: () { when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( cameraMethodChannel, (methodCall) async { switch (methodCall.method) { @@ -300,10 +292,8 @@ void main() { act: (bloc) async { bloc.add(const InitializeEvent()); await Future.delayed(Duration.zero); - TestWidgetsFlutterBinding.instance - .handleAppLifecycleStateChanged(AppLifecycleState.detached); - TestWidgetsFlutterBinding.instance - .handleAppLifecycleStateChanged(AppLifecycleState.resumed); + TestWidgetsFlutterBinding.instance.handleAppLifecycleStateChanged(AppLifecycleState.detached); + TestWidgetsFlutterBinding.instance.handleAppLifecycleStateChanged(AppLifecycleState.resumed); }, verify: (_) { verify(() => meteringInteractor.checkCameraPermission()).called(2); @@ -500,6 +490,29 @@ void main() { ); }, ); + + group( + '`ExposureSpotChangedEvent`', + () { + blocTest( + 'Set exposure spot multiple times', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + }, + build: () => bloc, + act: (bloc) async { + bloc.add(const InitializeEvent()); + await Future.delayed(Duration.zero); + bloc.add(const ExposureSpotChangedEvent(Offset(0.1, 0.1))); + bloc.add(const ExposureSpotChangedEvent(Offset(1.0, 0.5))); + }, + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => [...initializedStateSequence], + ); + }, + ); } extension _MethodChannelMock on MethodChannel { diff --git a/test/screens/metering/components/camera/event_container_camera_test.dart b/test/screens/metering/components/camera/event_container_camera_test.dart index f136b5f..3751f10 100644 --- a/test/screens/metering/components/camera/event_container_camera_test.dart +++ b/test/screens/metering/components/camera/event_container_camera_test.dart @@ -1,5 +1,7 @@ // ignore_for_file: prefer_const_constructors +import 'dart:ui'; + import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; import 'package:test/test.dart'; @@ -41,4 +43,23 @@ void main() { }); }, ); + + group( + '`ExposureSpotChangedEvent`', + () { + final a = ExposureSpotChangedEvent(Offset(0.0, 0.0)); + final b = ExposureSpotChangedEvent(Offset(0.0, 0.0)); + final c = ExposureSpotChangedEvent(Offset(2.0, 2.0)); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); }