mirror of
https://github.com/vodemn/m3_lightmeter.git
synced 2024-11-21 23:10:40 +00:00
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:
parent
ddc7ec8c8b
commit
6566108994
34 changed files with 783 additions and 251 deletions
|
@ -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(
|
||||
|
|
13
lib/data/models/camera_feature.dart
Normal file
13
lib/data/models/camera_feature.dart
Normal 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));
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
enum Feature { unlockProFeaturesText }
|
||||
|
||||
const featuresDefaultValues = {
|
||||
Feature.unlockProFeaturesText: false,
|
||||
Feature.unlockProFeaturesText: true,
|
||||
};
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
||||
Map<String, dynamic> toJson() => map((key, value) => MapEntry(key.index.toString(), value));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Закрыть",
|
||||
|
|
|
@ -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": "关闭",
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,8 +139,11 @@ class _UserPreferencesProviderState extends State<UserPreferencesProvider> with
|
|||
themeType: _themeType,
|
||||
child: _MeteringScreenLayoutModel(
|
||||
data: _meteringScreenLayout,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
class _CameraFeaturesModel extends MapModel<CameraFeature> {
|
||||
const _CameraFeaturesModel({
|
||||
required super.data,
|
||||
required super.child,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
child: Stack(
|
||||
children: [
|
||||
RotatedBox(
|
||||
quarterTurns: _getQuarterTurns(value),
|
||||
child: controller.buildPreview(),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,9 +93,10 @@ class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> {
|
|||
alignment: Alignment.bottomCenter,
|
||||
children: [
|
||||
CameraView(controller: widget.controller),
|
||||
if (UserPreferencesProvider.meteringScreenFeatureOf(
|
||||
if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ...[
|
||||
if (UserPreferencesProvider.cameraFeatureOf(
|
||||
context,
|
||||
MeteringScreenLayoutFeature.histogram,
|
||||
CameraFeature.histogram,
|
||||
))
|
||||
Positioned(
|
||||
left: Dimens.grid8,
|
||||
|
@ -89,6 +104,12 @@ class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> {
|
|||
bottom: Dimens.grid16,
|
||||
child: CameraHistogram(controller: widget.controller),
|
||||
),
|
||||
if (UserPreferencesProvider.cameraFeatureOf(
|
||||
context,
|
||||
CameraFeature.spotMetering,
|
||||
))
|
||||
CameraSpotDetector(onSpotTap: widget.onSpotTap)
|
||||
],
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
26
lib/utils/map_model.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -46,7 +46,6 @@ void main() {
|
|||
MeteringScreenLayoutFeature.equipmentProfiles: true,
|
||||
MeteringScreenLayoutFeature.extremeExposurePairs: true,
|
||||
MeteringScreenLayoutFeature.filmPicker: true,
|
||||
MeteringScreenLayoutFeature.histogram: false,
|
||||
}.toJson(),
|
||||
),
|
||||
|
||||
|
|
47
test/data/models/camera_features_config_test.dart
Normal file
47
test/data/models/camera_features_config_test.dart
Normal 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,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue