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
This commit is contained in:
Vadim 2023-11-11 21:05:11 +01:00 committed by GitHub
parent ddc7ec8c8b
commit 6566108994
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 783 additions and 251 deletions

View file

@ -30,7 +30,7 @@ class ApplicationWrapper extends StatelessWidget {
future: Future.wait<dynamic>([
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(

View file

@ -0,0 +1,13 @@
enum CameraFeature {
spotMetering,
histogram,
}
typedef CameraFeaturesConfig = Map<CameraFeature, bool>;
extension CameraFeaturesConfigJson on CameraFeaturesConfig {
static CameraFeaturesConfig fromJson(Map<String, dynamic> data) =>
<CameraFeature, bool>{for (final f in CameraFeature.values) f: data[f.name] as bool? ?? false};
Map<String, dynamic> toJson() => map((key, value) => MapEntry(key.name, value));
}

View file

@ -1,5 +1,5 @@
enum Feature { unlockProFeaturesText }
const featuresDefaultValues = {
Feature.unlockProFeaturesText: false,
Feature.unlockProFeaturesText: true,
};

View file

@ -1,18 +1,31 @@
enum MeteringScreenLayoutFeature {
extremeExposurePairs,
filmPicker,
histogram,
equipmentProfiles,
extremeExposurePairs, // 0
filmPicker, // 1
equipmentProfiles, // 3
}
typedef MeteringScreenLayoutConfig = Map<MeteringScreenLayoutFeature, bool>;
extension MeteringScreenLayoutConfigJson on MeteringScreenLayoutConfig {
static MeteringScreenLayoutConfig fromJson(Map<String, dynamic> data) =>
<MeteringScreenLayoutFeature, bool>{
for (final f in MeteringScreenLayoutFeature.values)
f: data[f.index.toString()] as bool? ?? true
};
static MeteringScreenLayoutConfig fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => map((key, value) => MapEntry(key.index.toString(), value));
return <MeteringScreenLayoutFeature, bool>{
for (final f in MeteringScreenLayoutFeature.values)
f: (data[migratedIndex(f).toString()] ?? data[f.name]) as bool? ?? true
};
}
Map<String, dynamic> toJson() => map((key, value) => MapEntry(key.name, value));
}

View file

@ -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<void> activeAndFetchFeatures();
Future<void> fetchConfig();
dynamic getValue(Feature feature);
Map<Feature, dynamic> getAll();
Stream<Set<Feature>> onConfigUpdated();
bool isEnabled(Feature feature);
}
class RemoteConfigService implements IRemoteConfigService {
const RemoteConfigService();
@override
Future<void> 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<void> 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<Feature, dynamic> getAll() {
final Map<Feature, dynamic> result = {};
for (final value in FirebaseRemoteConfig.instance.getAll().entries) {
@ -54,6 +74,7 @@ class RemoteConfigService {
return result;
}
@override
Stream<Set<Feature>> 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<void> activeAndFetchFeatures() async {}
@override
Future<void> fetchConfig() async {}
@override
Map<Feature, dynamic> 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<Set<Feature>> onConfigUpdated() => const Stream.empty();
}
extension on RemoteConfigValue {
dynamic toValue(Feature feature) {
switch (feature) {

View file

@ -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<String, dynamic>);
} 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);

View file

@ -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",

View file

@ -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",

View file

@ -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": "Закрыть",

View file

@ -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": "关闭",

View file

@ -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({

View file

@ -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<UserPreferencesProvider> 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<UserPreferencesProvider> 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<UserPreferencesProvider> 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<UserPreferencesProvider> 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<MeteringScreenLayoutFeature> {
final Map<MeteringScreenLayoutFeature, bool> data;
class _MeteringScreenLayoutModel extends MapModel<MeteringScreenLayoutFeature> {
const _MeteringScreenLayoutModel({
required this.data,
required super.data,
required super.child,
});
}
class _CameraFeaturesModel extends MapModel<CameraFeature> {
const _CameraFeaturesModel({
required super.data,
required super.child,
});
@override
bool updateShouldNotify(_MeteringScreenLayoutModel oldWidget) => oldWidget.data != data;
@override
bool updateShouldNotifyDependent(
_MeteringScreenLayoutModel oldWidget,
Set<MeteringScreenLayoutFeature> dependencies,
) {
for (final dependecy in dependencies) {
if (oldWidget.data[dependecy] != data[dependecy]) {
return true;
}
}
return false;
}
}

View file

@ -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,

View file

@ -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<CameraContainerEvent, CameraC
on<ZoomChangedEvent>(_onZoomChanged);
on<ExposureOffsetChangedEvent>(_onExposureOffsetChanged);
on<ExposureOffsetResetEvent>(_onExposureOffsetResetEvent);
on<ExposureSpotChangedEvent>(_onExposureSpotChangedEvent);
}
@override
@ -166,9 +165,7 @@ class CameraContainerBloc extends EvSourceBlocBase<CameraContainerEvent, CameraC
}
Future<void> _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<CameraContainerEvent, CameraC
add(const ExposureOffsetChangedEvent(0));
}
Future<void> _onExposureSpotChangedEvent(ExposureSpotChangedEvent event, Emitter emit) async {
if (_cameraController != null) {
_cameraController!.setExposurePoint(event.offset);
_cameraController!.setFocusPoint(event.offset);
}
}
void _emitActiveState(Emitter emit) {
emit(
CameraActiveState(

View file

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/res/dimens.dart';
class CameraSpotDetector extends StatefulWidget {
final ValueChanged<Offset?> onSpotTap;
const CameraSpotDetector({
required this.onSpotTap,
super.key,
});
@override
State<CameraSpotDetector> createState() => _CameraSpotDetectorState();
}
class _CameraSpotDetectorState extends State<CameraSpotDetector> {
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,
),
);
}
}

View file

@ -12,14 +12,17 @@ class CameraView extends StatelessWidget {
final value = controller.value;
return ValueListenableBuilder<CameraValue>(
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);
}
}

View file

@ -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<Offset?> onSpotTap;
const CameraPreview({this.controller, this.error, super.key});
const CameraPreview({
this.controller,
this.error,
required this.onSpotTap,
super.key,
});
@override
State<CameraPreview> createState() => _CameraPreviewState();
@ -31,7 +39,10 @@ class _CameraPreviewState extends State<CameraPreview> {
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<CameraPreview> {
class _CameraPreviewBuilder extends StatefulWidget {
final CameraController controller;
final ValueChanged<Offset?> 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<bool> _initializedNotifier =
ValueNotifier<bool>(widget.controller.value.isInitialized);
late final ValueNotifier<bool> _initializedNotifier = ValueNotifier<bool>(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(),

View file

@ -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);
}

View file

@ -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<CameraContainerBloc>().add(ExposureSpotChangedEvent(value));
},
),
);
}

View file

@ -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<CameraFeature>(
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,
),
);
},
);
}
}

View file

@ -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<MeteringScreenLayoutFeaturesDialog> createState() => _MeteringScreenLayoutFeaturesDialogState();
}
class _MeteringScreenLayoutFeaturesDialogState extends State<MeteringScreenLayoutFeaturesDialog> {
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;
}
}
}

View file

@ -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<MeteringScreenLayoutFeature>(
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;
}
}
}

View file

@ -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(),
],
);
}

View file

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:lightmeter/generated/l10n.dart';
import 'package:lightmeter/res/dimens.dart';
typedef StringAdapter<T> = String Function(BuildContext context, T value);
class DialogSwitch<T> extends StatefulWidget {
final IconData icon;
final String title;
final String? description;
final Map<T, bool> values;
final StringAdapter<T> titleAdapter;
final StringAdapter<T>? subtitleAdapter;
final ValueChanged<Map<T, bool>> 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<DialogSwitch<T>> createState() => _DialogSwitchState<T>();
}
class _DialogSwitchState<T> extends State<DialogSwitch<T>> {
late final Map<T, bool> _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),
),
],
);
}
}

View file

@ -7,6 +7,31 @@ import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart';
Future<void> 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<void> 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(

26
lib/utils/map_model.dart Normal file
View file

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class MapModel<T> extends InheritedModel<T> {
final Map<T, bool> data;
const MapModel({
required this.data,
required super.child,
});
@override
bool updateShouldNotify(MapModel oldWidget) => oldWidget.data != data;
@override
bool updateShouldNotifyDependent(
MapModel<T> oldWidget,
Set<T> dependencies,
) {
for (final dependecy in dependencies) {
if (oldWidget.data[dependecy] != data[dependecy]) {
return true;
}
}
return false;
}
}

View file

@ -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"

View file

@ -46,7 +46,6 @@ void main() {
MeteringScreenLayoutFeature.equipmentProfiles: true,
MeteringScreenLayoutFeature.extremeExposurePairs: true,
MeteringScreenLayoutFeature.filmPicker: true,
MeteringScreenLayoutFeature.histogram: false,
}.toJson(),
),

View file

@ -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,
},
);
});
}

View file

@ -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,
},
);
});

View file

@ -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);
});

View file

@ -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);
},

View file

@ -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<communication_events.MeteringCommunicationEvent, communication_states.MeteringCommunicationState>
implements MeteringCommunicationBloc {}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@ -147,8 +145,7 @@ void main() {
verify(() => meteringInteractor.requestCameraPermission()).called(1);
},
expect: () => [
isA<CameraErrorState>()
.having((state) => state.error, "error", CameraErrorType.permissionNotGranted),
isA<CameraErrorState>().having((state) => state.error, "error", CameraErrorType.permissionNotGranted),
],
);
@ -166,8 +163,7 @@ void main() {
},
expect: () => [
isA<CameraLoadingState>(),
isA<CameraErrorState>()
.having((state) => state.error, "error", CameraErrorType.permissionNotGranted),
isA<CameraErrorState>().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<CameraLoadingState>(),
isA<CameraErrorState>()
.having((state) => state.error, "error", CameraErrorType.noCamerasDetected),
isA<CameraErrorState>().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<CameraContainerBloc, CameraContainerState>(
'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 {

View file

@ -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);
});
},
);
}