From 1b4be83dda0f08e1373b356cd378583674a0d6fb Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:52:56 +0100 Subject: [PATCH 1/8] Update PRIVACY_POLICY.md --- PRIVACY_POLICY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index 05710bd..d9b9cad 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,6 +1,6 @@ **Privacy Policy** -I, Vodemn, built the Material Lightmeter app as a Free app. This app is provided at no cost and is intended for use as is. +I, Vadim Turko, built the Material Lightmeter app as a Free app. This app is provided at no cost and is intended for use as is. **Information Collection and Use** @@ -20,7 +20,7 @@ This app contains links to other sites. If you click on a third-party link, you I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on this page. -This policy is effective as of 2023-02-24 +This policy is effective as of 2024-01-04 **Contact Us** From c80bac23b21569a8932d670b05072671baff4b72 Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Thu, 4 Jan 2024 18:39:44 +0100 Subject: [PATCH 2/8] Added Support section to README.md - Fixed Development section numeration - Added contact email link --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ef38b93..b5ef345 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - [Backstory](#backstory) - [Screenshots](#screenshots) - [Development](#development) -- [Contribution](#contribution) +- [Support](#support) - [iOS Limitations](#ios-limitations) # Backstory @@ -36,7 +36,7 @@ Without further delay behold my new Lightmeter app inspired by Material You (a.k To build this app you need to install Flutter 3.10.0 stable. [How to install](https://docs.flutter.dev/get-started/install). -### 3. Project setup +### 2. Project setup As part of the app's functionallity is in the private repo, you have to replace these lines in _pubspec.yaml_: @@ -69,11 +69,11 @@ flutter pub get flutter pub run intl_utils:generate ``` -### 4. (Optional) Install Firebase +### 3. (Optional) Install Firebase Out of the box Firebase Crashlytics won't work. If you want to add Crashlytics to your local build please follow [this guide](https://firebase.google.com/docs/flutter/setup). -### 5. Build +### 4. Build #### Android @@ -87,11 +87,11 @@ flutter build apk --release --flavor dev --dart-define cameraPreviewAspectRatio= TBD -# Contribution +# Support -To report a bug or suggest a new feature open a new [issue](https://github.com/vodemn/m3_lightmeter/issues). +To report a bug or suggest a new feature open a new [issue](https://github.com/vodemn/m3_lightmeter/issues). To contribute to the project feel free to open a Pull Request, but you need to follow this [style guide](doc/style_guide.md). -In case you want to help develop this project feel free to open a Pull Request, but you need to follow this [style guide](doc/style_guide.md). +In case you have any other questions please contact me via [email](mailto:contact.vodemn@gmail.com?subject="Lightmeter"). # iOS Limitations From a2b4c88256552311917bd7dded1c66db58e7123e Mon Sep 17 00:00:00 2001 From: nathan musoke Date: Sat, 13 Jan 2024 10:42:23 -0600 Subject: [PATCH 3/8] Fixed typo in reciprocity description (#142) grater -> greater Co-authored-by: Vadim <44135514+vodemn@users.noreply.github.com> --- lib/l10n/intl_en.arb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 02bb95a..8c5bf93 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -47,7 +47,7 @@ "film": "Film", "filmPush": "Film (push)", "filmPull": "Film (pull)", - "filmReciprocityHint": "Applies correction for shutter speeds grater than 1 second", + "filmReciprocityHint": "Applies correction for shutter speeds greater than 1 second", "equipmentProfileName": "Equipment profile name", "equipmentProfileNameHint": "Praktica MTL5B", "equipmentProfileAllValues": "All", @@ -116,4 +116,4 @@ "tooltipUseLightSensor": "Use lightsensor", "tooltipUseCamera": "Use camera", "tooltipOpenSettings": "Open settings" -} \ No newline at end of file +} From 73d0c323234a4c8268177655a80621cd7c9f4e65 Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Sat, 13 Jan 2024 18:20:58 +0100 Subject: [PATCH 4/8] Hide Pro features from the metering screen (#147) * implemented `MockCameraContainerBloc` to stub camera on simulator * hide pro features from metering screen * disable pro features in settings * use closed child background color in `AnimatedDialog` * adjust `AnimatedDialogPicker` to items count * close `AnimatedDialog` through context * cleanup * fixed `ReadingValueContainer` text color * removed legacy translations * fixed tests * fixed `AnimatedDialog` scaling * added `evFromImage` test * added no EXIF test to `evFromImage` --- .vscode/launch.json | 15 +++ lib/application.dart | 5 +- lib/l10n/intl_en.arb | 4 - lib/l10n/intl_fr.arb | 4 - lib/l10n/intl_ru.arb | 4 - lib/l10n/intl_zh.arb | 4 - lib/platform_config.dart | 2 + .../bloc_container_camera.dart | 39 ++---- .../mock_bloc_container_camera.dart | 80 ++++++++++++ .../provider_container_camera.dart | 17 ++- .../widget_container_camera.dart | 30 +++-- .../lightmeter_pro/widget_lightmeter_pro.dart | 27 ++++ .../widget_dialog_animated.dart | 43 ++++--- .../dialog_picker/widget_picker_dialog.dart | 117 +++++++----------- .../widget_picker_dialog_animated.dart | 36 +++--- .../widget_container_reading_value.dart | 27 ++-- .../widget_container_readings.dart | 24 ++-- .../buy_pro/widget_list_tile_buy_pro.dart | 16 +-- ...idget_settings_section_lightmeter_pro.dart | 6 +- ...dget_list_tile_metering_screen_layout.dart | 10 ++ .../dialog_switch/widget_dialog_switch.dart | 19 ++- .../shared/disable/widget_disable.dart | 24 ++++ .../iap_list_tile/widget_list_tile_iap.dart | 8 +- .../widget_list_tile_primary_color.dart | 13 +- .../settings/utils/show_buy_pro_dialog.dart | 59 --------- .../widget_dialog_pro_features.dart | 56 +++++++++ .../widget_dialog_transparent.dart | 104 ++++++++++++++++ lib/utils/context_utils.dart | 12 ++ lib/utils/ev_from_bytes.dart | 27 ++++ lib/utils/text_height.dart | 29 +++++ test/application_mock.dart | 8 +- ..._settings_section_lightmeter_pro_test.dart | 60 +++++++++ .../utils/show_buy_pro_dialog_test.dart | 61 --------- test/utils/ev_from_bytes_test.dart | 24 ++++ 34 files changed, 665 insertions(+), 349 deletions(-) create mode 100644 lib/screens/metering/components/camera_container/mock_bloc_container_camera.dart create mode 100644 lib/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart create mode 100644 lib/screens/settings/components/shared/disable/widget_disable.dart delete mode 100644 lib/screens/settings/utils/show_buy_pro_dialog.dart create mode 100644 lib/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart create mode 100644 lib/screens/shared/transparent_dialog/widget_dialog_transparent.dart create mode 100644 lib/utils/context_utils.dart create mode 100644 lib/utils/ev_from_bytes.dart create mode 100644 lib/utils/text_height.dart create mode 100644 test/screens/settings/components/widget_settings_section_lightmeter_pro_test.dart delete mode 100644 test/screens/settings/utils/show_buy_pro_dialog_test.dart create mode 100644 test/utils/ev_from_bytes_test.dart diff --git a/.vscode/launch.json b/.vscode/launch.json index 40dbfac..a222516 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -82,5 +82,20 @@ ], "program": "${workspaceFolder}/lib/main_dev.dart", }, + { + "name": "dev-simulator", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "args": [ + "--flavor", + "dev", + "--dart-define", + "cameraPreviewAspectRatio=240/320", + "--dart-define", + "cameraStubImage=assets/camera_stub_image.jpg" + ], + "program": "${workspaceFolder}/lib/main_dev.dart", + }, ], } \ No newline at end of file diff --git a/lib/application.dart b/lib/application.dart index 68dd44a..15eba3a 100644 --- a/lib/application.dart +++ b/lib/application.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:lightmeter/data/models/supported_locale.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/screens/metering/flow_metering.dart'; import 'package:lightmeter/screens/settings/flow_settings.dart'; @@ -17,13 +18,13 @@ class Application extends StatelessWidget { return AnnotatedRegion( value: SystemUiOverlayStyle( statusBarColor: Colors.transparent, - statusBarBrightness: - systemIconsBrightness == Brightness.light ? Brightness.dark : Brightness.light, + statusBarBrightness: systemIconsBrightness == Brightness.light ? Brightness.dark : Brightness.light, statusBarIconBrightness: systemIconsBrightness, systemNavigationBarColor: Colors.transparent, systemNavigationBarIconBrightness: systemIconsBrightness, ), child: MaterialApp( + debugShowCheckedModeBanner: !PlatformConfig.isTest, theme: theme, locale: Locale(UserPreferencesProvider.localeOf(context).intlName), localizationsDelegates: const [ diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 8c5bf93..13d5cbd 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -96,10 +96,6 @@ } } }, - "lightmeterPro": "Lightmeter Pro", - "buyLightmeterPro": "Buy 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:\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.", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index f8b52c3..66dc9fb 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -96,10 +96,6 @@ } } }, - "buyLightmeterPro": "Acheter Lightmeter Pro", - "lightmeterPro": "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:\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.", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index f25dc8e..367b707 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -96,10 +96,6 @@ } } }, - "buyLightmeterPro": "Купить Lightmeter Pro", - "lightmeterPro": "Lightmeter Pro", - "lightmeterProDescription": "Даёт доступ к различным функциям:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nИсходный код Lightmeter доступен на GitHub. Вы можете собрать его самостоятельно. Однако если вы хотите поддержать разработку и получать новые функции и обновления, то приобретите Lightmeter Pro.", - "buy": "Купить", "proFeatures": "Профессиональные настройки", "unlockProFeatures": "Разблокировать профессиональные настройки", "unlockProFeaturesDescription": "Вы можете разблокировать профессиональные настройки:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nПолучая доступ к профессиональным настройкам, вы поддерживаете разработку и делаете возможным появление новых функций в приложении.", diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 3844a76..0055685 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -96,10 +96,6 @@ } } }, - "buyLightmeterPro": "购买 Lightmeter Pro", - "lightmeterPro": "Lightmeter Pro", - "lightmeterProDescription": "解锁额外功能:\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶卷列表,对胶片倒易率失效进行曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n您可以在 GitHub 上获取 Lightmeter 的源代码,欢迎自行编译。不过,如果您想支持开发并获得新功能和更新,请考虑购买 Lightmeter Pro。", - "buy": "购买", "proFeatures": "专业功能", "unlockProFeatures": "解锁专业功能", "unlockProFeaturesDescription": "\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶卷列表,对胶片倒易率失效进行曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n通过解锁专业版功能,您可以支持开发工作,帮助为应用程序添加新功能。", diff --git a/lib/platform_config.dart b/lib/platform_config.dart index def5a80..d06223e 100644 --- a/lib/platform_config.dart +++ b/lib/platform_config.dart @@ -7,4 +7,6 @@ class PlatformConfig { } static String get cameraStubImage => const String.fromEnvironment('cameraStubImage'); + + static bool get isTest => cameraStubImage.isNotEmpty; } diff --git a/lib/screens/metering/components/camera_container/bloc_container_camera.dart b/lib/screens/metering/components/camera_container/bloc_container_camera.dart index 2e6b051..3b5596b 100644 --- a/lib/screens/metering/components/camera_container/bloc_container_camera.dart +++ b/lib/screens/metering/components/camera_container/bloc_container_camera.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'dart:math' as math; import 'package:camera/camera.dart'; -import 'package:exif/exif.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -17,7 +16,9 @@ import 'package:lightmeter/screens/metering/components/camera_container/event_co 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'; import 'package:lightmeter/screens/metering/components/shared/ev_source_base/bloc_base_ev_source.dart'; -import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:lightmeter/utils/ev_from_bytes.dart'; + +part 'mock_bloc_container_camera.dart'; class CameraContainerBloc extends EvSourceBlocBase { final MeteringInteractor _meteringInteractor; @@ -213,33 +214,15 @@ class CameraContainerBloc extends EvSourceBlocBase _takePhoto() async { try { // https://github.com/flutter/flutter/issues/84957#issuecomment-1661155095 + await _cameraController!.setFocusMode(FocusMode.locked); + await _cameraController!.setExposureMode(ExposureMode.locked); + final file = await _cameraController!.takePicture(); + await _cameraController!.setFocusMode(FocusMode.auto); + await _cameraController!.setExposureMode(ExposureMode.auto); + final bytes = await file.readAsBytes(); + Directory(file.path).deleteSync(recursive: true); - late final Uint8List bytes; - if (PlatformConfig.cameraStubImage.isNotEmpty) { - bytes = (await rootBundle.load(PlatformConfig.cameraStubImage)).buffer.asUint8List(); - } else { - await _cameraController!.setFocusMode(FocusMode.locked); - await _cameraController!.setExposureMode(ExposureMode.locked); - final file = await _cameraController!.takePicture(); - await _cameraController!.setFocusMode(FocusMode.auto); - await _cameraController!.setExposureMode(ExposureMode.auto); - bytes = await file.readAsBytes(); - Directory(file.path).deleteSync(recursive: true); - } - - final tags = await readExifFromBytes(bytes); - final iso = double.tryParse("${tags["EXIF ISOSpeedRatings"]}"); - final apertureValueRatio = (tags["EXIF FNumber"]?.values as IfdRatios?)?.ratios.first; - final speedValueRatio = (tags["EXIF ExposureTime"]?.values as IfdRatios?)?.ratios.first; - if (iso == null || apertureValueRatio == null || speedValueRatio == null) { - log('Error parsing EXIF: ${tags.keys}'); - return null; - } - - final aperture = apertureValueRatio.numerator / apertureValueRatio.denominator; - final speed = speedValueRatio.numerator / speedValueRatio.denominator; - - return log2(math.pow(aperture, 2)) - log2(speed) - log2(iso / 100); + return await evFromImage(bytes); } catch (e) { log(e.toString()); return null; diff --git a/lib/screens/metering/components/camera_container/mock_bloc_container_camera.dart b/lib/screens/metering/components/camera_container/mock_bloc_container_camera.dart new file mode 100644 index 0000000..f915a52 --- /dev/null +++ b/lib/screens/metering/components/camera_container/mock_bloc_container_camera.dart @@ -0,0 +1,80 @@ +part of 'bloc_container_camera.dart'; + +class MockCameraContainerBloc extends CameraContainerBloc { + MockCameraContainerBloc( + super._meteringInteractor, + super.communicationBloc, + ); + + @override + Future _onRequestPermission(_, Emitter emit) async { + add(const InitializeEvent()); + } + + @override + Future _onOpenAppSettings(_, Emitter emit) async { + _meteringInteractor.openAppSettings(); + } + + @override + Future _onInitialize(_, Emitter emit) async { + emit(const CameraLoadingState()); + try { + _cameraController = CameraController( + const CameraDescription(name: '0', lensDirection: CameraLensDirection.back, sensorOrientation: 0), + ResolutionPreset.low, + enableAudio: false, + ); + + _zoomRange = const RangeValues(1, 6); + _currentZoom = _zoomRange!.start; + + _exposureOffsetRange = const RangeValues(-4, 4); + _exposureStep = 0.1; + _currentExposureOffset = 0.0; + + emit(CameraInitializedState(_cameraController!)); + + _emitActiveState(emit); + } catch (e) { + emit(const CameraErrorState(CameraErrorType.other)); + } + } + + @override + Future _onZoomChanged(ZoomChangedEvent event, Emitter emit) async { + if (event.value >= _zoomRange!.start && event.value <= _zoomRange!.end) { + _currentZoom = event.value; + _emitActiveState(emit); + } + } + + @override + Future _onExposureOffsetChanged(ExposureOffsetChangedEvent event, Emitter emit) async { + _currentExposureOffset = event.value; + _emitActiveState(emit); + } + + @override + Future _onExposureOffsetResetEvent(ExposureOffsetResetEvent event, Emitter emit) async { + _meteringInteractor.quickVibration(); + add(const ExposureOffsetChangedEvent(0)); + } + + @override + Future _onExposureSpotChangedEvent(ExposureSpotChangedEvent event, Emitter emit) async {} + + @override + bool get _canTakePhoto => PlatformConfig.cameraStubImage.isNotEmpty; + + @override + Future _takePhoto() async { + try { + final bytes = (await rootBundle.load(PlatformConfig.cameraStubImage)).buffer.asUint8List(); + return await evFromImage(bytes); + } catch (e) { + log(e.toString()); + return null; + } + } +} diff --git a/lib/screens/metering/components/camera_container/provider_container_camera.dart b/lib/screens/metering/components/camera_container/provider_container_camera.dart index 1d6d8c0..80be814 100644 --- a/lib/screens/metering/components/camera_container/provider_container_camera.dart +++ b/lib/screens/metering/components/camera_container/provider_container_camera.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; +import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; @@ -30,12 +31,18 @@ class CameraContainerProvider extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( + return BlocProvider( lazy: false, - create: (context) => CameraContainerBloc( - MeteringInteractorProvider.of(context), - context.read(), - )..add(const RequestPermissionEvent()), + create: (context) => (PlatformConfig.cameraStubImage.isNotEmpty + ? MockCameraContainerBloc( + MeteringInteractorProvider.of(context), + context.read(), + ) + : CameraContainerBloc( + MeteringInteractorProvider.of(context), + context.read(), + )) + ..add(const RequestPermissionEvent()), child: CameraContainer( fastest: fastest, slowest: slowest, diff --git a/lib/screens/metering/components/camera_container/widget_container_camera.dart b/lib/screens/metering/components/camera_container/widget_container_camera.dart index a23545e..a4cb3e8 100644 --- a/lib/screens/metering/components/camera_container/widget_container_camera.dart +++ b/lib/screens/metering/components/camera_container/widget_container_camera.dart @@ -5,7 +5,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.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/bloc_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/components/camera_controls/widget_camera_controls.dart'; @@ -17,6 +16,8 @@ import 'package:lightmeter/screens/metering/components/camera_container/state_co import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart'; import 'package:lightmeter/screens/metering/components/shared/metering_top_bar/widget_top_bar_metering.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/widget_container_readings.dart'; +import 'package:lightmeter/utils/context_utils.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class CameraContainer extends StatelessWidget { @@ -101,28 +102,25 @@ class CameraContainer extends StatelessWidget { } double _meteringContainerHeight(BuildContext context) { + final isPro = IAPProducts.isPurchased(context, IAPProductType.paidFeatures); double enabledFeaturesHeight = 0; - if (UserPreferencesProvider.meteringScreenFeatureOf( - context, - MeteringScreenLayoutFeature.equipmentProfiles, - )) { + if (!isPro) { enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight; enabledFeaturesHeight += Dimens.paddingS; + } else { + if (context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) { + enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight; + enabledFeaturesHeight += Dimens.paddingS; + } + if (context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) { + enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight; + enabledFeaturesHeight += Dimens.paddingS; + } } - if (UserPreferencesProvider.meteringScreenFeatureOf( - context, - MeteringScreenLayoutFeature.extremeExposurePairs, - )) { + if (context.meteringFeature(MeteringScreenLayoutFeature.extremeExposurePairs)) { enabledFeaturesHeight += Dimens.readingContainerDoubleValueHeight; enabledFeaturesHeight += Dimens.paddingS; } - if (UserPreferencesProvider.meteringScreenFeatureOf( - context, - MeteringScreenLayoutFeature.filmPicker, - )) { - enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight; - enabledFeaturesHeight += Dimens.paddingS; - } return enabledFeaturesHeight + Dimens.readingContainerSingleValueHeight; // ISO & ND } diff --git a/lib/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart b/lib/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart new file mode 100644 index 0000000..9d5218a --- /dev/null +++ b/lib/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; +import 'package:lightmeter/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart'; + +class LightmeterProAnimatedDialog extends StatelessWidget { + const LightmeterProAnimatedDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AnimatedDialog( + closedChild: ReadingValueContainer( + color: Theme.of(context).colorScheme.errorContainer, + textColor: Theme.of(context).colorScheme.onErrorContainer, + values: [ + ReadingValue( + label: S.of(context).proFeatures, + value: S.of(context).unlock, + ), + ], + ), + openedChild: const ProFeaturesDialog(), + openedSize: Size.fromHeight(const ProFeaturesDialog().height(context)), + ); + } +} diff --git a/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart b/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart index 72e770b..63bd1e6 100644 --- a/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart +++ b/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart @@ -1,9 +1,15 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:lightmeter/res/dimens.dart'; +mixin AnimatedDialogClosedChild on Widget { + Color backgroundColor(BuildContext context); +} + class AnimatedDialog extends StatefulWidget { final Size? openedSize; - final Widget? closedChild; + final AnimatedDialogClosedChild? closedChild; final Widget? openedChild; final Widget? child; @@ -15,6 +21,9 @@ class AnimatedDialog extends StatefulWidget { super.key, }); + static Future? maybeClose(BuildContext context) => + context.findAncestorWidgetOfExactType<_AnimatedOverlay>()?.onDismiss(); + @override State createState() => AnimatedDialogState(); } @@ -95,7 +104,7 @@ class AnimatedDialogState extends State with SingleTickerProvide void didChangeDependencies() { super.didChangeDependencies(); _foregroundColorAnimation = ColorTween( - begin: Theme.of(context).colorScheme.primaryContainer, + begin: widget.closedChild?.backgroundColor(context) ?? Theme.of(context).colorScheme.primaryContainer, end: Theme.of(context).colorScheme.surface, ).animate(_defaultCurvedAnimation); @@ -135,14 +144,15 @@ class AnimatedDialogState extends State with SingleTickerProvide if (renderBox != null) { final size = MediaQuery.sizeOf(context); final padding = MediaQuery.paddingOf(context); + final maxWidth = size.width - padding.horizontal - Dimens.dialogMargin.horizontal; + final maxHeight = size.height - padding.vertical - Dimens.dialogMargin.vertical; _closedSize = _key.currentContext!.size!; _sizeTween = SizeTween( begin: _closedSize, - end: widget.openedSize ?? - Size( - size.width - padding.horizontal - Dimens.dialogMargin.horizontal, - size.height - padding.vertical - Dimens.dialogMargin.vertical, - ), + end: Size( + min(widget.openedSize?.width ?? double.maxFinite, maxWidth), + min(widget.openedSize?.height ?? double.maxFinite, maxHeight), + ), ); _sizeAnimation = _sizeTween.animate(_defaultCurvedAnimation); @@ -181,7 +191,6 @@ class AnimatedDialogState extends State with SingleTickerProvide onDismiss: close, builder: widget.closedChild != null && widget.openedChild != null ? (_) => _AnimatedSwitcher( - sizeAnimation: _sizeAnimation, closedOpacityAnimation: _closedOpacityAnimation, openedOpacityAnimation: _openedOpacityAnimation, closedSize: _sizeTween.begin!, @@ -223,7 +232,7 @@ class _AnimatedOverlay extends StatelessWidget { final Animation borderRadiusAnimation; final Animation foregroundColorAnimation; final Animation elevationAnimation; - final VoidCallback onDismiss; + final Future Function() onDismiss; final Widget? child; final Widget Function(BuildContext context)? builder; @@ -281,7 +290,6 @@ class _AnimatedOverlay extends StatelessWidget { } class _AnimatedSwitcher extends StatelessWidget { - final Animation sizeAnimation; final Animation closedOpacityAnimation; final Animation openedOpacityAnimation; final Size closedSize; @@ -290,7 +298,6 @@ class _AnimatedSwitcher extends StatelessWidget { final Widget openedChild; const _AnimatedSwitcher({ - required this.sizeAnimation, required this.closedOpacityAnimation, required this.openedOpacityAnimation, required this.closedSize, @@ -306,17 +313,21 @@ class _AnimatedSwitcher extends StatelessWidget { children: [ Opacity( opacity: closedOpacityAnimation.value, - child: Transform.scale( - scale: sizeAnimation.value!.width / closedSize.width, - child: SizedBox( - width: closedSize.width, + child: FittedBox( + child: SizedBox.fromSize( + size: closedSize, child: closedChild, ), ), ), Opacity( opacity: openedOpacityAnimation.value, - child: openedChild, + child: FittedBox( + child: SizedBox.fromSize( + size: openedSize, + child: openedChild, + ), + ), ), ], ); diff --git a/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart b/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart index 3c253b1..dfbf1ce 100644 --- a/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart +++ b/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart'; typedef DialogPickerItemTitleBuilder = Widget Function(BuildContext context, T value); typedef DialogPickerItemTrailingBuilder = Widget? Function(T selected, T value); @@ -29,6 +30,14 @@ class DialogPicker extends StatefulWidget { super.key, }); + double height(BuildContext context) => TransparentDialog.height( + context, + title: title, + subtitle: subtitle, + scrollableContent: true, + contextHeight: Dimens.grid56 * values.length, + ); + @override State> createState() => _DialogPickerState(); } @@ -46,83 +55,43 @@ class _DialogPickerState extends State> { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: Dimens.dialogTitlePadding, - child: Icon(widget.icon), - ), - Padding( - padding: Dimens.dialogIconTitlePadding, - child: Text( - widget.title, - style: Theme.of(context).textTheme.headlineSmall, - textAlign: TextAlign.center, - ), - ), - if (widget.subtitle != null) - Padding( - padding: const EdgeInsets.fromLTRB( - Dimens.paddingL, - 0, - Dimens.paddingL, - Dimens.paddingM, - ), - child: Text( - widget.subtitle!, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ), - ], - ), - const Divider(), - Expanded( - child: ListView.builder( - controller: _scrollController, - padding: EdgeInsets.zero, - itemCount: widget.values.length, - itemExtent: Dimens.grid56, - itemBuilder: (context, index) => RadioListTile( - value: widget.values[index], - groupValue: _selectedValue, - title: DefaultTextStyle( - style: Theme.of(context).textTheme.bodyLarge!, - child: widget.itemTitleBuilder(context, widget.values[index]), - ), - secondary: widget.itemTrailingBuilder?.call(_selectedValue, widget.values[index]), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedValue = value; - }); - } - }, + return TransparentDialog( + icon: widget.icon, + title: widget.title, + subtitle: widget.subtitle, + content: Expanded( + child: ListView.builder( + controller: _scrollController, + padding: EdgeInsets.zero, + itemCount: widget.values.length, + itemExtent: Dimens.grid56, + itemBuilder: (context, index) => RadioListTile( + value: widget.values[index], + groupValue: _selectedValue, + title: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyLarge!, + child: widget.itemTitleBuilder(context, widget.values[index]), ), + secondary: widget.itemTrailingBuilder?.call(_selectedValue, widget.values[index]), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedValue = value; + }); + } + }, ), ), - const Divider(), - Padding( - padding: Dimens.dialogActionsPadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const Spacer(), - TextButton( - onPressed: widget.onCancel, - child: Text(S.of(context).cancel), - ), - const SizedBox(width: Dimens.grid16), - TextButton( - onPressed: () => widget.onSelect(_selectedValue), - child: Text(S.of(context).select), - ), - ], - ), + ), + scrollableContent: true, + actions: [ + TextButton( + onPressed: widget.onCancel, + child: Text(S.of(context).cancel), + ), + TextButton( + onPressed: () => widget.onSelect(_selectedValue), + child: Text(S.of(context).select), ), ], ); diff --git a/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart b/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart index eeeec52..efa07c7 100644 --- a/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart +++ b/lib/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart'; -// Has to be stateful, so that [GlobalKey] is not recreated. +// Has to be stateful, so that [GlobalKey] is not recreated. // Otherwise use will no be able to close the dialog after EV value has changed. class AnimatedDialogPicker extends StatefulWidget { final IconData icon; @@ -13,7 +13,7 @@ class AnimatedDialogPicker extends StatefulWidget { final DialogPickerItemTitleBuilder itemTitleBuilder; final DialogPickerItemTrailingBuilder? itemTrailingBuilder; final ValueChanged onChanged; - final Widget closedChild; + final AnimatedDialogClosedChild closedChild; const AnimatedDialogPicker({ required this.icon, @@ -37,24 +37,26 @@ class _AnimatedDialogPickerState extends State> { @override Widget build(BuildContext context) { + final dialogPicker = DialogPicker( + icon: widget.icon, + title: widget.title, + subtitle: widget.subtitle, + initialValue: widget.selectedValue, + values: widget.values, + itemTitleBuilder: widget.itemTitleBuilder, + itemTrailingBuilder: widget.itemTrailingBuilder, + onCancel: () { + _key.currentState?.close(); + }, + onSelect: (value) { + _key.currentState?.close().then((_) => widget.onChanged(value)); + }, + ); return AnimatedDialog( key: _key, closedChild: widget.closedChild, - openedChild: DialogPicker( - icon: widget.icon, - title: widget.title, - subtitle: widget.subtitle, - initialValue: widget.selectedValue, - values: widget.values, - itemTitleBuilder: widget.itemTitleBuilder, - itemTrailingBuilder: widget.itemTrailingBuilder, - onCancel: () { - _key.currentState?.close(); - }, - onSelect: (value) { - _key.currentState?.close().then((_) => widget.onChanged(value)); - }, - ), + openedChild: dialogPicker, + openedSize: Size.fromHeight(dialogPicker.height(context)), ); } } diff --git a/lib/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart b/lib/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart index 3254456..6968db6 100644 --- a/lib/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart +++ b/lib/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart'; class ReadingValue { final String label; @@ -11,11 +12,15 @@ class ReadingValue { }); } -class ReadingValueContainer extends StatelessWidget { +class ReadingValueContainer extends StatelessWidget implements AnimatedDialogClosedChild { late final List _items; + final Color? color; + final Color? textColor; ReadingValueContainer({ required List values, + this.color, + this.textColor, super.key, }) { _items = []; @@ -23,21 +28,26 @@ class ReadingValueContainer extends StatelessWidget { if (i > 0) { _items.add(const SizedBox(height: Dimens.grid8)); } - _items.add(_ReadingValueBuilder(values[i])); + _items.add(_ReadingValueBuilder(values[i], textColor: textColor)); } } ReadingValueContainer.singleValue({ required ReadingValue value, + this.color, + this.textColor, super.key, - }) : _items = [_ReadingValueBuilder(value)]; + }) : _items = [_ReadingValueBuilder(value, textColor: textColor)]; + + @override + Color backgroundColor(BuildContext context) => color ?? Theme.of(context).colorScheme.primaryContainer; @override Widget build(BuildContext context) { return ClipRRect( borderRadius: BorderRadius.circular(Dimens.borderRadiusM), child: ColoredBox( - color: Theme.of(context).colorScheme.primaryContainer, + color: backgroundColor(context), child: Padding( padding: const EdgeInsets.all(Dimens.paddingM), child: Column( @@ -53,20 +63,21 @@ class ReadingValueContainer extends StatelessWidget { class _ReadingValueBuilder extends StatelessWidget { final ReadingValue reading; + final Color? textColor; - const _ReadingValueBuilder(this.reading); + const _ReadingValueBuilder(this.reading, {this.textColor}); @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final textColor = Theme.of(context).colorScheme.onPrimaryContainer; + final color = textColor ?? Theme.of(context).colorScheme.onPrimaryContainer; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( reading.label, - style: textTheme.labelMedium?.copyWith(color: textColor), + style: textTheme.labelMedium?.copyWith(color: color), maxLines: 1, overflow: TextOverflow.visible, softWrap: false, @@ -76,7 +87,7 @@ class _ReadingValueBuilder extends StatelessWidget { duration: Dimens.switchDuration, child: Text( reading.value, - style: textTheme.titleMedium?.copyWith(color: textColor), + style: textTheme.titleMedium?.copyWith(color: color), maxLines: 1, overflow: TextOverflow.ellipsis, softWrap: false, diff --git a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart index cb8af05..30d1392 100644 --- a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart +++ b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart @@ -2,13 +2,15 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/providers/equipment_profile_provider.dart'; -import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart'; +import 'package:lightmeter/utils/context_utils.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class ReadingsContainer extends StatelessWidget { @@ -31,30 +33,26 @@ class ReadingsContainer extends StatelessWidget { @override Widget build(BuildContext context) { + final isPro = IAPProducts.isPurchased(context, IAPProductType.paidFeatures); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (UserPreferencesProvider.meteringScreenFeatureOf( - context, - MeteringScreenLayoutFeature.equipmentProfiles, - )) ...[ + if (!isPro) ...[ + const LightmeterProAnimatedDialog(), + const _InnerPadding(), + ], + if (isPro && context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) ...[ const EquipmentProfilePicker(), const _InnerPadding(), ], - if (UserPreferencesProvider.meteringScreenFeatureOf( - context, - MeteringScreenLayoutFeature.extremeExposurePairs, - )) ...[ + if (context.meteringFeature(MeteringScreenLayoutFeature.extremeExposurePairs)) ...[ ExtremeExposurePairsContainer( fastest: fastest, slowest: slowest, ), const _InnerPadding(), ], - if (UserPreferencesProvider.meteringScreenFeatureOf( - context, - MeteringScreenLayoutFeature.filmPicker, - )) ...[ + if (isPro && context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) ...[ FilmPicker(selectedIso: iso), const _InnerPadding(), ], diff --git a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart index 9854a14..4bb401d 100644 --- a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart +++ b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:lightmeter/data/models/feature.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/remote_config_provider.dart'; -import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/res/dimens.dart'; -import 'package:lightmeter/screens/settings/utils/show_buy_pro_dialog.dart'; +import 'package:lightmeter/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; class BuyProListTile extends StatelessWidget { @@ -12,18 +9,17 @@ class BuyProListTile extends StatelessWidget { @override Widget build(BuildContext context) { - final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText); final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status; final isPending = status == IAPProductStatus.purchased || status == null; return ListTile( leading: const Icon(Icons.star), - title: Text(unlockFeaturesEnabled ? S.of(context).unlockProFeatures : S.of(context).buyLightmeterPro), + title: Text(S.of(context).unlockProFeatures), onTap: !isPending ? () { - showBuyProDialog(context); - ServicesProvider.of(context) - .analytics - .logUnlockProFeatures(unlockFeaturesEnabled ? 'Unlock Pro features' : 'Buy Lightmeter Pro'); + showDialog( + context: context, + builder: (_) => const Dialog(child: ProFeaturesDialog()), + ); } : null, trailing: isPending diff --git a/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart b/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart index 7050ae2..57d5d14 100644 --- a/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart +++ b/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:lightmeter/data/models/feature.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/remote_config_provider.dart'; import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart'; import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart'; @@ -11,9 +9,7 @@ class LightmeterProSettingsSection extends StatelessWidget { @override Widget build(BuildContext context) { return SettingsSection( - title: RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText) - ? S.of(context).proFeatures - : S.of(context).lightmeterPro, + title: S.of(context).proFeatures, children: const [BuyProListTile()], ); } diff --git a/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart b/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart index 1f89b4b..9dfb026 100644 --- a/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart +++ b/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart @@ -5,6 +5,7 @@ import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/providers/films_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart'; +import 'package:lightmeter/utils/context_utils.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringScreenLayoutListTile extends StatelessWidget { @@ -24,6 +25,15 @@ class MeteringScreenLayoutListTile extends StatelessWidget { description: S.of(context).meteringScreenLayoutHint, values: UserPreferencesProvider.meteringScreenConfigOf(context), titleAdapter: _toStringLocalized, + enabledAdapter: (value) { + switch (value) { + case MeteringScreenLayoutFeature.equipmentProfiles: + case MeteringScreenLayoutFeature.filmPicker: + return context.isPro; + default: + return true; + } + }, onSave: (value) { if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) { EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first); diff --git a/lib/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart b/lib/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart index aa3a370..fdf7cc5 100644 --- a/lib/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart +++ b/lib/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart'; typedef StringAdapter = String Function(BuildContext context, T value); @@ -11,6 +12,7 @@ class DialogSwitch extends StatefulWidget { final Map values; final StringAdapter titleAdapter; final StringAdapter? subtitleAdapter; + final bool Function(T value)? enabledAdapter; final ValueChanged> onSave; const DialogSwitch({ @@ -20,6 +22,7 @@ class DialogSwitch extends StatefulWidget { required this.values, required this.titleAdapter, this.subtitleAdapter, + this.enabledAdapter, required this.onSave, super.key, }); @@ -52,9 +55,12 @@ class _DialogSwitchState extends State> { ], ListView( shrinkWrap: true, - children: _features.entries - .map( - (entry) => SwitchListTile( + children: _features.entries.map( + (entry) { + final isEnabled = widget.enabledAdapter?.call(entry.key) ?? true; + return Disable( + disable: !isEnabled, + child: SwitchListTile( contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left), title: Text(widget.titleAdapter(context, entry.key)), subtitle: widget.subtitleAdapter != null @@ -63,15 +69,16 @@ class _DialogSwitchState extends State> { style: Theme.of(context).listTileTheme.subtitleTextStyle, ) : null, - value: _features[entry.key]!, + value: isEnabled && _features[entry.key]!, onChanged: (value) { setState(() { _features.update(entry.key, (_) => value); }); }, ), - ) - .toList(), + ); + }, + ).toList(), ), ], ), diff --git a/lib/screens/settings/components/shared/disable/widget_disable.dart b/lib/screens/settings/components/shared/disable/widget_disable.dart new file mode 100644 index 0000000..3b1d276 --- /dev/null +++ b/lib/screens/settings/components/shared/disable/widget_disable.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class Disable extends StatelessWidget { + final bool disable; + final Widget? child; + + const Disable({ + this.disable = true, + this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: disable ? Dimens.disabledOpacity : Dimens.enabledOpacity, + child: IgnorePointer( + ignoring: disable, + child: child, + ), + ); + } +} diff --git a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart index a2b980f..5fd1d3d 100644 --- a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart +++ b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; /// Depends on the product status and replaces [onTap] with purchase callback @@ -23,12 +23,12 @@ class IAPListTile extends StatelessWidget { @override Widget build(BuildContext context) { final isPurchased = IAPProducts.isPurchased(context, IAPProductType.paidFeatures); - return Opacity( - opacity: isPurchased ? Dimens.enabledOpacity : Dimens.disabledOpacity, + return Disable( + disable: !isPurchased, child: ListTile( leading: leading, title: title, - onTap: isPurchased ? onTap : null, + onTap: onTap, ), ); } diff --git a/lib/screens/settings/components/theme/components/primary_color/widget_list_tile_primary_color.dart b/lib/screens/settings/components/theme/components/primary_color/widget_list_tile_primary_color.dart index 44de22e..1cf8762 100644 --- a/lib/screens/settings/components/theme/components/primary_color/widget_list_tile_primary_color.dart +++ b/lib/screens/settings/components/theme/components/primary_color/widget_list_tile_primary_color.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/data/models/dynamic_colors_state.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; -import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart'; import 'package:lightmeter/screens/settings/components/theme/components/primary_color/components/primary_color_picker_dialog/widget_dialog_picker_primary_color.dart'; class PrimaryColorListTile extends StatelessWidget { @@ -11,13 +11,10 @@ class PrimaryColorListTile extends StatelessWidget { @override Widget build(BuildContext context) { if (UserPreferencesProvider.dynamicColorStateOf(context) == DynamicColorState.enabled) { - return Opacity( - opacity: Dimens.disabledOpacity, - child: IgnorePointer( - child: ListTile( - leading: const Icon(Icons.palette), - title: Text(S.of(context).primaryColor), - ), + return Disable( + child: ListTile( + leading: const Icon(Icons.palette), + title: Text(S.of(context).primaryColor), ), ); } diff --git a/lib/screens/settings/utils/show_buy_pro_dialog.dart b/lib/screens/settings/utils/show_buy_pro_dialog.dart deleted file mode 100644 index 181259f..0000000 --- a/lib/screens/settings/utils/show_buy_pro_dialog.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/data/models/feature.dart'; -import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/remote_config_provider.dart'; -import 'package:lightmeter/res/dimens.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; - -Future showBuyProDialog(BuildContext context) { - final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText); - - 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( - icon: const Icon(Icons.star), - titlePadding: Dimens.dialogIconTitlePadding, - title: Text(unlockFeaturesEnabled ? S.of(context).proFeatures : S.of(context).lightmeterPro), - contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), - content: SingleChildScrollView(child: splitDescription()), - actionsPadding: Dimens.dialogActionsPadding, - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text(S.of(context).cancel), - ), - FilledButton( - onPressed: () { - Navigator.of(context).pop(); - IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures); - }, - child: Text(unlockFeaturesEnabled ? S.of(context).unlock : S.of(context).buy), - ), - ], - ), - ); -} diff --git a/lib/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart b/lib/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart new file mode 100644 index 0000000..c58e94d --- /dev/null +++ b/lib/screens/shared/pro_features_dialog/widget_dialog_pro_features.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/animated_dialog/widget_dialog_animated.dart'; +import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart'; +import 'package:lightmeter/utils/text_height.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; + +class ProFeaturesDialog extends StatelessWidget { + const ProFeaturesDialog({super.key}); + + double height(BuildContext context) => TransparentDialog.height( + context, + title: S.of(context).proFeatures, + contextHeight: dialogTextHeight( + context, + S.of(context).unlockProFeaturesDescription, + Theme.of(context).textTheme.bodyMedium, + Dimens.paddingL * 2, + ), + ); + + @override + Widget build(BuildContext context) { + return TransparentDialog( + icon: Icons.star, + title: S.of(context).proFeatures, + scrollableContent: false, + content: Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), + child: Text( + S.of(context).unlockProFeaturesDescription, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => _close(context), + child: Text(S.of(context).cancel), + ), + FilledButton( + onPressed: () { + _close(context).then((_) => IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures)); + }, + child: Text(S.of(context).unlock), + ), + ], + ); + } + + Future _close(BuildContext context) async => AnimatedDialog.maybeClose(context) ?? Navigator.of(context).pop(); +} diff --git a/lib/screens/shared/transparent_dialog/widget_dialog_transparent.dart b/lib/screens/shared/transparent_dialog/widget_dialog_transparent.dart new file mode 100644 index 0000000..ee3870e --- /dev/null +++ b/lib/screens/shared/transparent_dialog/widget_dialog_transparent.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/utils/text_height.dart'; + +class TransparentDialog extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final Widget content; + final bool scrollableContent; + final List actions; + + const TransparentDialog({ + required this.icon, + required this.title, + this.subtitle, + required this.content, + required this.scrollableContent, + this.actions = const [], + super.key, + }); + + static double height( + BuildContext context, { + required String title, + String? subtitle, + required double contextHeight, + bool scrollableContent = false, + }) { + double height = IconTheme.of(context).size! + Dimens.dialogTitlePadding.vertical; + height += dialogTextHeight( + context, + title, + Theme.of(context).textTheme.headlineSmall, + Dimens.dialogIconTitlePadding.horizontal, + ) + + Dimens.dialogIconTitlePadding.vertical; + if (subtitle != null) { + height += dialogTextHeight( + context, + subtitle, + Theme.of(context).textTheme.bodyMedium, + Dimens.dialogIconTitlePadding.horizontal, + ) + + Dimens.dialogIconTitlePadding.vertical; + } + height += contextHeight; + if (scrollableContent) height += 1; + return height += 48 + Dimens.dialogActionsPadding.vertical; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: Dimens.dialogTitlePadding, + child: Icon(icon), + ), + Padding( + padding: Dimens.dialogIconTitlePadding, + child: Text( + title, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ), + if (subtitle != null) + Padding( + padding: Dimens.dialogIconTitlePadding, + child: Text( + subtitle!, + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ), + ], + ), + if (scrollableContent) const Divider(), + content, + if (scrollableContent) const Divider(), + Padding( + padding: Dimens.dialogActionsPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: _actions().toList(), + ), + ), + ], + ); + } + + Iterable _actions() sync* { + for (int i = 0; i < actions.length; i++) { + yield i == 0 ? const Spacer() : const SizedBox(width: Dimens.grid16); + yield actions[i]; + } + } +} diff --git a/lib/utils/context_utils.dart b/lib/utils/context_utils.dart new file mode 100644 index 0000000..f64ea76 --- /dev/null +++ b/lib/utils/context_utils.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; + +extension BuildContextUtils on BuildContext { + bool meteringFeature(MeteringScreenLayoutFeature feature) { + return UserPreferencesProvider.meteringScreenFeatureOf(this, feature); + } + + bool get isPro => IAPProducts.isPurchased(this, IAPProductType.paidFeatures); +} diff --git a/lib/utils/ev_from_bytes.dart b/lib/utils/ev_from_bytes.dart new file mode 100644 index 0000000..c7d12ea --- /dev/null +++ b/lib/utils/ev_from_bytes.dart @@ -0,0 +1,27 @@ +import 'dart:developer'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:exif/exif.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +Future evFromImage(Uint8List bytes) async { + try { + final tags = await readExifFromBytes(bytes); + final iso = double.tryParse("${tags["EXIF ISOSpeedRatings"]}"); + final apertureValueRatio = (tags["EXIF FNumber"]?.values as IfdRatios?)?.ratios.first; + final speedValueRatio = (tags["EXIF ExposureTime"]?.values as IfdRatios?)?.ratios.first; + if (iso == null || apertureValueRatio == null || speedValueRatio == null) { + log('Error parsing EXIF: ${tags.keys}'); + return null; + } + + final aperture = apertureValueRatio.numerator / apertureValueRatio.denominator; + final speed = speedValueRatio.numerator / speedValueRatio.denominator; + + return log2(math.pow(aperture, 2)) - log2(speed) - log2(iso / 100); + } catch (e) { + log(e.toString()); + return null; + } +} diff --git a/lib/utils/text_height.dart b/lib/utils/text_height.dart new file mode 100644 index 0000000..f5a0fe1 --- /dev/null +++ b/lib/utils/text_height.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; + +double dialogTextHeight( + BuildContext context, + String text, + TextStyle? style, + double textPadding, +) => + textHeight( + text, + style, + MediaQuery.sizeOf(context).width - Dimens.dialogMargin.horizontal - textPadding, + ); + +double textHeight( + String text, + TextStyle? style, + double maxWidth, +) { + final TextPainter titlePainter = TextPainter( + text: TextSpan( + text: text, + style: style, + ), + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxWidth); + return titlePainter.height; +} diff --git a/test/application_mock.dart b/test/application_mock.dart index dbcf260..a5aecb3 100644 --- a/test/application_mock.dart +++ b/test/application_mock.dart @@ -2,12 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/theme.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; /// Provides [MaterialApp] with default theme and "en" localization class WidgetTestApplicationMock extends StatelessWidget { + final IAPProductStatus productStatus; final Widget child; - const WidgetTestApplicationMock({required this.child, super.key}); + const WidgetTestApplicationMock({ + this.productStatus = IAPProductStatus.purchased, + required this.child, + super.key, + }); @override Widget build(BuildContext context) { diff --git a/test/screens/settings/components/widget_settings_section_lightmeter_pro_test.dart b/test/screens/settings/components/widget_settings_section_lightmeter_pro_test.dart new file mode 100644 index 0000000..1ae99f4 --- /dev/null +++ b/test/screens/settings/components/widget_settings_section_lightmeter_pro_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart'; +import 'package:lightmeter/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart'; +import 'package:lightmeter/screens/shared/transparent_dialog/widget_dialog_transparent.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; + +import '../../../application_mock.dart'; + +void main() { + Future pumpApplication(WidgetTester tester) async { + await tester.pumpWidget( + IAPProducts( + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + ), + ], + child: const WidgetTestApplicationMock( + child: LightmeterProSettingsSection(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets( + '`showBuyProDialog` and buy', + (tester) async { + await pumpApplication(tester); + await tester.tap(find.byType(BuyProListTile)); + await tester.pumpAndSettle(); + expect(find.byType(TransparentDialog), findsOneWidget); + expect(find.text(S.current.proFeatures), findsNWidgets(2)); + expect(find.text(S.current.cancel), findsOneWidget); + expect(find.text(S.current.unlock), findsOneWidget); + + await tester.tap(find.text(S.current.unlock)); + await tester.pumpAndSettle(); + expect(find.byType(TransparentDialog), findsNothing); + }, + ); + + testWidgets( + '`showBuyProDialog` and cancel', + (tester) async { + await pumpApplication(tester); + await tester.tap(find.byType(BuyProListTile)); + await tester.pumpAndSettle(); + expect(find.byType(TransparentDialog), findsOneWidget); + expect(find.text(S.current.proFeatures), findsNWidgets(2)); + expect(find.text(S.current.cancel), findsOneWidget); + expect(find.text(S.current.unlock), findsOneWidget); + + await tester.tap(find.text(S.current.cancel)); + await tester.pumpAndSettle(); + expect(find.byType(TransparentDialog), findsNothing); + }, + ); +} diff --git a/test/screens/settings/utils/show_buy_pro_dialog_test.dart b/test/screens/settings/utils/show_buy_pro_dialog_test.dart deleted file mode 100644 index 16a8e19..0000000 --- a/test/screens/settings/utils/show_buy_pro_dialog_test.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:lightmeter/data/models/feature.dart'; -import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/remote_config_provider.dart'; -import 'package:lightmeter/screens/settings/utils/show_buy_pro_dialog.dart'; - -import '../../../application_mock.dart'; - -void main() { - Future pumpApplication(WidgetTester tester) async { - await tester.pumpWidget( - RemoteConfig( - config: const {Feature.unlockProFeaturesText: false}, - child: WidgetTestApplicationMock( - child: Builder( - builder: (context) => ElevatedButton( - onPressed: () => showBuyProDialog(context), - child: const SizedBox(), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - } - - testWidgets( - '`showBuyProDialog` and buy', - (tester) async { - await pumpApplication(tester); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text(S.current.lightmeterPro), findsOneWidget); - expect(find.text(S.current.cancel), findsOneWidget); - expect(find.text(S.current.buy), findsOneWidget); - - await tester.tap(find.text(S.current.buy)); - await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsNothing); - }, - ); - - testWidgets( - '`showBuyProDialog` and cancel', - (tester) async { - await pumpApplication(tester); - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text(S.current.lightmeterPro), findsOneWidget); - expect(find.text(S.current.cancel), findsOneWidget); - expect(find.text(S.current.buy), findsOneWidget); - - await tester.tap(find.text(S.current.cancel)); - await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsNothing); - }, - ); -} diff --git a/test/utils/ev_from_bytes_test.dart b/test/utils/ev_from_bytes_test.dart new file mode 100644 index 0000000..f73a257 --- /dev/null +++ b/test/utils/ev_from_bytes_test.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/utils/ev_from_bytes.dart'; + +void main() { + group('evFromImage', () { + test( + 'camera_stub_image.jpg', + () { + final bytes = File('assets/camera_stub_image.jpg').readAsBytesSync(); + expectLater(evFromImage(bytes), completion(8.25230310752341)); + }, + ); + + test( + 'no EXIF', + () { + final bytes = File('assets/launcher_icon_dev_512.png').readAsBytesSync(); + expectLater(evFromImage(bytes), completion(null)); + }, + ); + }); +} From 8f5893c7d23beb1a487ff1c5600f94becf469a4c Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Mon, 15 Jan 2024 20:47:10 +0100 Subject: [PATCH 5/8] ML-143 EV100 indication (#148) * added `showEV100` to user preferences * integrated EV100 setting to UI * available for pro * replaced `IAPProducts.isPurchased` with context extension * fixed `UserPreferencesProvider` tests * EV100 -> Ev100 --- lib/data/shared_prefs_service.dart | 4 +++ lib/l10n/intl_en.arb | 1 + lib/l10n/intl_fr.arb | 1 + lib/l10n/intl_ru.arb | 1 + lib/l10n/intl_zh.arb | 1 + lib/providers/equipment_profile_provider.dart | 5 ++-- lib/providers/films_provider.dart | 7 ++--- lib/providers/user_preferences_provider.dart | 18 ++++++++++++ .../measure_button/widget_button_measure.dart | 28 +++++++++++++++++-- .../provider_bottom_controls.dart | 3 ++ .../widget_bottom_controls.dart | 3 ++ .../camera_preview/widget_camera_preview.dart | 4 +-- .../widget_container_camera.dart | 4 +-- .../widget_container_readings.dart | 8 ++---- lib/screens/metering/screen_metering.dart | 1 + .../widget_list_tile_show_ev_100.dart | 24 ++++++++++++++++ .../widget_settings_section_metering.dart | 2 ++ .../iap_list_tile/widget_list_tile_iap.dart | 4 +-- lib/screens/settings/screen_settings.dart | 5 ++-- test/data/shared_prefs_service_test.dart | 19 +++++++++++++ .../user_preferences_provider_test.dart | 22 +++++++++++++++ 21 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 lib/screens/settings/components/metering/components/show_ev_100/widget_list_tile_show_ev_100.dart diff --git a/lib/data/shared_prefs_service.dart b/lib/data/shared_prefs_service.dart index 443f2e5..3bb6dfb 100644 --- a/lib/data/shared_prefs_service.dart +++ b/lib/data/shared_prefs_service.dart @@ -16,6 +16,7 @@ class UserPreferencesService { static const evSourceTypeKey = "evSourceType"; static const stopTypeKey = "stopType"; + static const showEv100Key = "showEv100"; static const cameraEvCalibrationKey = "cameraEvCalibration"; static const lightSensorEvCalibrationKey = "lightSensorEvCalibration"; static const meteringScreenLayoutKey = "meteringScreenLayout"; @@ -84,6 +85,9 @@ class UserPreferencesService { StopType get stopType => StopType.values[_sharedPreferences.getInt(stopTypeKey) ?? 2]; set stopType(StopType value) => _sharedPreferences.setInt(stopTypeKey, value.index); + bool get showEv100 => _sharedPreferences.getBool(showEv100Key) ?? false; + set showEv100(bool value) => _sharedPreferences.setBool(showEv100Key, value); + MeteringScreenLayoutConfig get meteringScreenLayout { final configJson = _sharedPreferences.getString(meteringScreenLayoutKey); if (configJson != null) { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 13d5cbd..02dbb3d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -34,6 +34,7 @@ "calibrationMessageCameraOnly": "The accuracy of the readings measured by this application depends entirely on the rear camera of the device. Therefore, consider testing this application and setting up an EV calibration value that will give you the desired measurement results.", "camera": "Camera", "lightSensor": "Light sensor", + "showEv100": "Show EV\u2081\u2080\u2080", "meteringScreenLayout": "Metering screen layout", "meteringScreenLayoutHint": "Hide elements on the metering screen that you don't need so that they don't waste exposure pairs list space.", "meteringScreenLayoutHintEquipmentProfiles": "Equipment profile picker", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 66dc9fb..590976d 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -34,6 +34,7 @@ "calibrationMessageCameraOnly": "La précision des lectures mesurées par cette application dépend entièrement de la caméra arrière de l'appareil. Par conséquent, envisagez de tester cette application et de configurer une valeur d'étalonnage EV qui vous donnera les résultats de mesure souhaités.", "camera": "Caméra", "lightSensor": "Capteur de lumière", + "showEv100": "Montrer EV\u2081\u2080\u2080", "meteringScreenLayout": "Disposition de l'écran de mesure", "meteringScreenLayoutHint": "Masquer les éléments sur l'écran de mesure dont vous n'avez pas besoin pour qu'ils ne gaspillent pas de l'espace dans les paires d'exposition.", "meteringScreenLayoutHintEquipmentProfiles": "Sélecteur de profil de l'équipement", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index 367b707..e7f0743 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -34,6 +34,7 @@ "calibrationMessageCameraOnly": "Точность измерений данного приложения полностью зависит от точности камеры вашего устройства. Поэтому рекомендуется самостоятельно подобрать калибровочное значение, которое даст желаемый результат измерений.", "camera": "Камера", "lightSensor": "Датчик освещённости", + "showEv100": "Показывать EV\u2081\u2080\u2080", "meteringScreenLayout": "Элементы главного экрана", "meteringScreenLayoutHint": "Здесь вы можете скрыть некоторые ненужные или неиспользуемые элементы с главного экрана.", "meteringScreenLayoutHintEquipmentProfiles": "Выбор профиля оборудования", diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 0055685..71fe757 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -34,6 +34,7 @@ "calibrationMessageCameraOnly": "此应用程序测量读数的准确s性完全取决于设备的后置摄像头。因此,请考虑测试此应用并手动设置 EV 校准,以获得准确的测量结果。", "camera": "摄像头", "lightSensor": "光传感器", + "showEv100": "显示 EV\u2081\u2080\u2080", "meteringScreenLayout": "布局", "meteringScreenLayoutHint": "隐藏不需要的元素,以免浪费曝光列表空间", "meteringScreenLayoutHintEquipmentProfiles": "设备配置选择", diff --git a/lib/providers/equipment_profile_provider.dart b/lib/providers/equipment_profile_provider.dart index 564c5ef..74397a5 100644 --- a/lib/providers/equipment_profile_provider.dart +++ b/lib/providers/equipment_profile_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/utils/context_utils.dart'; import 'package:lightmeter/utils/selectable_provider.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; @@ -52,9 +53,9 @@ class EquipmentProfileProviderState extends State { return EquipmentProfiles( values: [ _defaultProfile, - if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ..._customProfiles, + if (context.isPro) ..._customProfiles, ], - selected: IAPProducts.isPurchased(context, IAPProductType.paidFeatures) ? _selectedProfile : _defaultProfile, + selected: context.isPro ? _selectedProfile : _defaultProfile, child: widget.child, ); } diff --git a/lib/providers/films_provider.dart b/lib/providers/films_provider.dart index aff6d01..3e9d02d 100644 --- a/lib/providers/films_provider.dart +++ b/lib/providers/films_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/utils/context_utils.dart'; import 'package:lightmeter/utils/selectable_provider.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; @@ -44,11 +45,9 @@ class FilmsProviderState extends State { ], filmsInUse: [ const Film.other(), - if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ..._filmsInUse, + if (context.isPro) ..._filmsInUse, ], - selected: IAPProducts.isPurchased(context, IAPProductType.paidFeatures) - ? _selected - : const Film.other(), + selected: context.isPro ? _selected : const Film.other(), child: widget.child, ); } diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index 764f282..e2eecd7 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -53,6 +53,10 @@ class UserPreferencesProvider extends StatefulWidget { return _inheritFromEnumsModel(context, _Aspect.stopType).stopType; } + static bool showEv100Of(BuildContext context) { + return _inheritFromEnumsModel(context, _Aspect.showEv100).showEv100; + } + static CameraFeaturesConfig cameraConfigOf(BuildContext context) { return context.findAncestorWidgetOfExactType<_CameraFeaturesModel>()!.data; } @@ -83,6 +87,7 @@ class UserPreferencesProvider extends StatefulWidget { class _UserPreferencesProviderState extends State with WidgetsBindingObserver { late EvSourceType _evSourceType; late StopType _stopType = widget.userPreferencesService.stopType; + late bool _showEv100 = widget.userPreferencesService.showEv100; late MeteringScreenLayoutConfig _meteringScreenLayout = widget.userPreferencesService.meteringScreenLayout; late CameraFeaturesConfig _cameraFeatures = widget.userPreferencesService.cameraFeatures; late SupportedLocale _locale = widget.userPreferencesService.locale; @@ -135,6 +140,7 @@ class _UserPreferencesProviderState extends State with evSourceType: _evSourceType, locale: _locale, primaryColor: dynamicPrimaryColor ?? _primaryColor, + showEv100: _showEv100, stopType: _stopType, themeType: _themeType, child: _MeteringScreenLayoutModel( @@ -201,6 +207,13 @@ class _UserPreferencesProviderState extends State with widget.userPreferencesService.primaryColor = primaryColor; } + void toggleShowEv100() { + setState(() { + _showEv100 = !_showEv100; + }); + widget.userPreferencesService.showEv100 = _showEv100; + } + void setStopType(StopType stopType) { setState(() { _stopType = stopType; @@ -231,6 +244,7 @@ enum _Aspect { dynamicColorState, evSourceType, locale, + showEv100, stopType, theme, themeType, @@ -240,6 +254,7 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> { final DynamicColorState dynamicColorState; final EvSourceType evSourceType; final SupportedLocale locale; + final bool showEv100; final StopType stopType; final ThemeType themeType; @@ -252,6 +267,7 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> { required this.evSourceType, required this.locale, required Color primaryColor, + required this.showEv100, required this.stopType, required this.themeType, required super.child, @@ -267,6 +283,7 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> { evSourceType != oldWidget.evSourceType || locale != oldWidget.locale || _primaryColor != oldWidget._primaryColor || + showEv100 != oldWidget.showEv100 || stopType != oldWidget.stopType || themeType != oldWidget.themeType; } @@ -279,6 +296,7 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> { return (dependencies.contains(_Aspect.dynamicColorState) && dynamicColorState != oldWidget.dynamicColorState) || (dependencies.contains(_Aspect.evSourceType) && evSourceType != oldWidget.evSourceType) || (dependencies.contains(_Aspect.locale) && locale != oldWidget.locale) || + (dependencies.contains(_Aspect.showEv100) && showEv100 != oldWidget.showEv100) || (dependencies.contains(_Aspect.stopType) && stopType != oldWidget.stopType) || (dependencies.contains(_Aspect.theme) && (_brightness != oldWidget._brightness || _primaryColor != oldWidget._primaryColor)) || diff --git a/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart b/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart index 99bd2ae..e918557 100644 --- a/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart +++ b/lib/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart @@ -1,15 +1,21 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/shared/filled_circle/widget_circle_filled.dart'; +import 'package:lightmeter/utils/context_utils.dart'; + +const String _subscript100 = '\u2081\u2080\u2080'; class MeteringMeasureButton extends StatefulWidget { final double? ev; + final double? ev100; final bool isMetering; final VoidCallback onTap; const MeteringMeasureButton({ required this.ev, + required this.ev100, required this.isMetering, required this.onTap, super.key, @@ -61,7 +67,7 @@ class _MeteringMeasureButtonState extends State { color: Theme.of(context).colorScheme.onSurface, size: Dimens.grid72 - Dimens.grid8, child: Center( - child: widget.ev != null ? _EvValueText(ev: widget.ev!) : null, + child: widget.ev != null ? _EvValueText(ev: widget.ev!, ev100: widget.ev100!) : null, ), ), ), @@ -83,16 +89,32 @@ class _MeteringMeasureButtonState extends State { class _EvValueText extends StatelessWidget { final double ev; + final double ev100; - const _EvValueText({required this.ev}); + const _EvValueText({ + required this.ev, + required this.ev100, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Text( - '${ev.toStringAsFixed(1)}\n${S.of(context).ev}', + _text(context), style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.surface), textAlign: TextAlign.center, ); } + + String _text(BuildContext context) { + final bool showEv100 = context.isPro && UserPreferencesProvider.showEv100Of(context); + final StringBuffer buffer = StringBuffer() + ..writeAll([ + (showEv100 ? ev100 : ev).toStringAsFixed(1), + '\n', + S.of(context).ev, + if (showEv100) _subscript100, + ]); + return buffer.toString(); + } } diff --git a/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart b/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart index dd4a9be..f9d6d47 100644 --- a/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart +++ b/lib/screens/metering/components/bottom_controls/provider_bottom_controls.dart @@ -5,6 +5,7 @@ import 'package:lightmeter/screens/metering/components/bottom_controls/widget_bo class MeteringBottomControlsProvider extends StatelessWidget { final double? ev; + final double? ev100; final bool isMetering; final VoidCallback? onSwitchEvSourceType; final VoidCallback onMeasure; @@ -12,6 +13,7 @@ class MeteringBottomControlsProvider extends StatelessWidget { const MeteringBottomControlsProvider({ required this.ev, + required this.ev100, required this.isMetering, required this.onSwitchEvSourceType, required this.onMeasure, @@ -35,6 +37,7 @@ class MeteringBottomControlsProvider extends StatelessWidget { ), child: MeteringBottomControls( ev: ev, + ev100: ev100, isMetering: isMetering, onSwitchEvSourceType: onSwitchEvSourceType, onMeasure: onMeasure, diff --git a/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart b/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart index 31ecac4..0eef568 100644 --- a/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart +++ b/lib/screens/metering/components/bottom_controls/widget_bottom_controls.dart @@ -7,6 +7,7 @@ import 'package:lightmeter/screens/metering/components/bottom_controls/component class MeteringBottomControls extends StatelessWidget { final double? ev; + final double? ev100; final bool isMetering; final VoidCallback? onSwitchEvSourceType; final VoidCallback onMeasure; @@ -14,6 +15,7 @@ class MeteringBottomControls extends StatelessWidget { const MeteringBottomControls({ required this.ev, + required this.ev100, required this.isMetering, required this.onSwitchEvSourceType, required this.onMeasure, @@ -58,6 +60,7 @@ class MeteringBottomControls extends StatelessWidget { const Spacer(), MeteringMeasureButton( ev: ev, + ev100: ev100, isMetering: isMetering, onTap: onMeasure, ), diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart b/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart index e6f1699..464228f 100644 --- a/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart +++ b/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart @@ -9,7 +9,7 @@ import 'package:lightmeter/screens/metering/components/camera_container/componen 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'; +import 'package:lightmeter/utils/context_utils.dart'; class CameraPreview extends StatefulWidget { final CameraController? controller; @@ -93,7 +93,7 @@ class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> { alignment: Alignment.bottomCenter, children: [ CameraView(controller: widget.controller), - if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ...[ + if (context.isPro) ...[ if (UserPreferencesProvider.cameraFeatureOf( context, CameraFeature.histogram, diff --git a/lib/screens/metering/components/camera_container/widget_container_camera.dart b/lib/screens/metering/components/camera_container/widget_container_camera.dart index a4cb3e8..5ed2b43 100644 --- a/lib/screens/metering/components/camera_container/widget_container_camera.dart +++ b/lib/screens/metering/components/camera_container/widget_container_camera.dart @@ -17,7 +17,6 @@ import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_lis import 'package:lightmeter/screens/metering/components/shared/metering_top_bar/widget_top_bar_metering.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/widget_container_readings.dart'; import 'package:lightmeter/utils/context_utils.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class CameraContainer extends StatelessWidget { @@ -102,9 +101,8 @@ class CameraContainer extends StatelessWidget { } double _meteringContainerHeight(BuildContext context) { - final isPro = IAPProducts.isPurchased(context, IAPProductType.paidFeatures); double enabledFeaturesHeight = 0; - if (!isPro) { + if (!context.isPro) { enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight; enabledFeaturesHeight += Dimens.paddingS; } else { diff --git a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart index 30d1392..ce373a8 100644 --- a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart +++ b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart @@ -10,7 +10,6 @@ import 'package:lightmeter/screens/metering/components/shared/readings_container import 'package:lightmeter/screens/metering/components/shared/readings_container/components/lightmeter_pro/widget_lightmeter_pro.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart'; import 'package:lightmeter/utils/context_utils.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class ReadingsContainer extends StatelessWidget { @@ -33,15 +32,14 @@ class ReadingsContainer extends StatelessWidget { @override Widget build(BuildContext context) { - final isPro = IAPProducts.isPurchased(context, IAPProductType.paidFeatures); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!isPro) ...[ + if (!context.isPro) ...[ const LightmeterProAnimatedDialog(), const _InnerPadding(), ], - if (isPro && context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) ...[ + if (context.isPro && context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) ...[ const EquipmentProfilePicker(), const _InnerPadding(), ], @@ -52,7 +50,7 @@ class ReadingsContainer extends StatelessWidget { ), const _InnerPadding(), ], - if (isPro && context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) ...[ + if (context.isPro && context.meteringFeature(MeteringScreenLayoutFeature.filmPicker)) ...[ FilmPicker(selectedIso: iso), const _InnerPadding(), ], diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart index 380411f..836e6d0 100644 --- a/lib/screens/metering/screen_metering.dart +++ b/lib/screens/metering/screen_metering.dart @@ -40,6 +40,7 @@ class MeteringScreen extends StatelessWidget { BlocBuilder( builder: (context, state) => MeteringBottomControlsProvider( ev: state is MeteringDataState ? state.ev : null, + ev100: state is MeteringDataState ? state.ev100 : null, isMetering: state.isMetering, onSwitchEvSourceType: ServicesProvider.of(context).environment.hasLightSensor ? UserPreferencesProvider.of(context).toggleEvSourceType diff --git a/lib/screens/settings/components/metering/components/show_ev_100/widget_list_tile_show_ev_100.dart b/lib/screens/settings/components/metering/components/show_ev_100/widget_list_tile_show_ev_100.dart new file mode 100644 index 0000000..a92623e --- /dev/null +++ b/lib/screens/settings/components/metering/components/show_ev_100/widget_list_tile_show_ev_100.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart'; +import 'package:lightmeter/utils/context_utils.dart'; + +class ShowEv100ListTile extends StatelessWidget { + const ShowEv100ListTile({super.key}); + + @override + Widget build(BuildContext context) { + return Disable( + disable: !context.isPro, + child: SwitchListTile( + secondary: const Icon(Icons.adjust), + title: Text(S.of(context).showEv100), + value: context.isPro && UserPreferencesProvider.showEv100Of(context), + onChanged: (_) => UserPreferencesProvider.of(context).toggleShowEv100(), + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), + ), + ); + } +} diff --git a/lib/screens/settings/components/metering/widget_settings_section_metering.dart b/lib/screens/settings/components/metering/widget_settings_section_metering.dart index 0c6c07d..becf531 100644 --- a/lib/screens/settings/components/metering/widget_settings_section_metering.dart +++ b/lib/screens/settings/components/metering/widget_settings_section_metering.dart @@ -6,6 +6,7 @@ import 'package:lightmeter/screens/settings/components/metering/components/equip 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'; import 'package:lightmeter/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart'; +import 'package:lightmeter/screens/settings/components/metering/components/show_ev_100/widget_list_tile_show_ev_100.dart'; import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart'; class MeteringSettingsSection extends StatelessWidget { @@ -18,6 +19,7 @@ class MeteringSettingsSection extends StatelessWidget { children: const [ StopTypeListTile(), CalibrationListTile(), + ShowEv100ListTile(), MeteringScreenLayoutListTile(), EquipmentProfilesListTile(), FilmsListTile(), diff --git a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart index 5fd1d3d..8cc522a 100644 --- a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart +++ b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/screens/settings/components/shared/disable/widget_disable.dart'; +import 'package:lightmeter/utils/context_utils.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; /// Depends on the product status and replaces [onTap] with purchase callback @@ -22,9 +23,8 @@ class IAPListTile extends StatelessWidget { @override Widget build(BuildContext context) { - final isPurchased = IAPProducts.isPurchased(context, IAPProductType.paidFeatures); return Disable( - disable: !isPurchased, + disable: !context.isPro, child: ListTile( leading: leading, title: title, diff --git a/lib/screens/settings/screen_settings.dart b/lib/screens/settings/screen_settings.dart index ea8e1c5..295e01c 100644 --- a/lib/screens/settings/screen_settings.dart +++ b/lib/screens/settings/screen_settings.dart @@ -7,7 +7,7 @@ import 'package:lightmeter/screens/settings/components/metering/widget_settings_ import 'package:lightmeter/screens/settings/components/theme/widget_settings_section_theme.dart'; import 'package:lightmeter/screens/settings/flow_settings.dart'; import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:lightmeter/utils/context_utils.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -38,8 +38,7 @@ class _SettingsScreenState extends State { SliverList( delegate: SliverChildListDelegate( [ - if (!IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) - const LightmeterProSettingsSection(), + if (!context.isPro) const LightmeterProSettingsSection(), const MeteringSettingsSection(), const GeneralSettingsSection(), const ThemeSettingsSection(), diff --git a/test/data/shared_prefs_service_test.dart b/test/data/shared_prefs_service_test.dart index 96f4e1e..269d002 100644 --- a/test/data/shared_prefs_service_test.dart +++ b/test/data/shared_prefs_service_test.dart @@ -181,6 +181,25 @@ void main() { }); }); + group('showEv100', () { + test('get default', () { + when(() => sharedPreferences.getBool(UserPreferencesService.showEv100Key)).thenReturn(null); + expect(service.showEv100, false); + }); + + test('get', () { + when(() => sharedPreferences.getBool(UserPreferencesService.showEv100Key)).thenReturn(true); + expect(service.showEv100, true); + }); + + test('set', () { + when(() => sharedPreferences.setBool(UserPreferencesService.showEv100Key, false)) + .thenAnswer((_) => Future.value(true)); + service.showEv100 = false; + verify(() => sharedPreferences.setBool(UserPreferencesService.showEv100Key, false)).called(1); + }); + }); + group('meteringScreenLayout', () { test('get default', () { when( diff --git a/test/providers/user_preferences_provider_test.dart b/test/providers/user_preferences_provider_test.dart index b6dc2ae..1a91419 100644 --- a/test/providers/user_preferences_provider_test.dart +++ b/test/providers/user_preferences_provider_test.dart @@ -27,6 +27,7 @@ void main() { setUp(() { when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + when(() => mockUserPreferencesService.showEv100).thenReturn(false); when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third); when(() => mockUserPreferencesService.meteringScreenLayout).thenReturn({ MeteringScreenLayoutFeature.extremeExposurePairs: true, @@ -164,6 +165,27 @@ void main() { }, ); + testWidgets( + 'Toggle Ev100', + (tester) async { + when(() => mockUserPreferencesService.showEv100).thenReturn(false); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).toggleShowEv100(), + child: Text('${UserPreferencesProvider.showEv100Of(context)}'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text("${false}"), findsOneWidget); + + await tester.tap(find.text("${false}")); + await tester.pumpAndSettle(); + expect(find.text("${true}"), findsOneWidget); + verify(() => mockUserPreferencesService.showEv100 = true).called(1); + }, + ); + testWidgets( 'Set metering screen layout config', (tester) async { From 2b2a5441c79341512784378bba8d63aadd50e3ca Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Mon, 15 Jan 2024 22:57:40 +0100 Subject: [PATCH 6/8] ML-130 Added ff for the "Pro features" tile on the main screen (#149) * added ff for Pro features tile on main screen --- lib/data/models/feature.dart | 4 +-- lib/data/remote_config_service.dart | 2 +- .../widget_container_camera.dart | 8 ++++-- .../widget_container_readings.dart | 4 ++- .../remote_config_provider_test.dart | 26 +++++++++---------- 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/lib/data/models/feature.dart b/lib/data/models/feature.dart index b022db7..429a322 100644 --- a/lib/data/models/feature.dart +++ b/lib/data/models/feature.dart @@ -1,5 +1,5 @@ -enum Feature { unlockProFeaturesText } +enum Feature { showUnlockProOnMainScreen } const featuresDefaultValues = { - Feature.unlockProFeaturesText: true, + Feature.showUnlockProOnMainScreen: false, }; diff --git a/lib/data/remote_config_service.dart b/lib/data/remote_config_service.dart index f8c123b..9bd4051 100644 --- a/lib/data/remote_config_service.dart +++ b/lib/data/remote_config_service.dart @@ -124,7 +124,7 @@ class MockRemoteConfigService implements IRemoteConfigService { extension on RemoteConfigValue { dynamic toValue(Feature feature) { switch (feature) { - case Feature.unlockProFeaturesText: + case Feature.showUnlockProOnMainScreen: return asBool(); } } diff --git a/lib/screens/metering/components/camera_container/widget_container_camera.dart b/lib/screens/metering/components/camera_container/widget_container_camera.dart index 5ed2b43..3e81d48 100644 --- a/lib/screens/metering/components/camera_container/widget_container_camera.dart +++ b/lib/screens/metering/components/camera_container/widget_container_camera.dart @@ -3,8 +3,10 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; +import 'package:lightmeter/data/models/feature.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/platform_config.dart'; +import 'package:lightmeter/providers/remote_config_provider.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/components/camera_controls/widget_camera_controls.dart'; @@ -103,8 +105,10 @@ class CameraContainer extends StatelessWidget { double _meteringContainerHeight(BuildContext context) { double enabledFeaturesHeight = 0; if (!context.isPro) { - enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight; - enabledFeaturesHeight += Dimens.paddingS; + if (RemoteConfig.isEnabled(context, Feature.showUnlockProOnMainScreen)) { + enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight; + enabledFeaturesHeight += Dimens.paddingS; + } } else { if (context.meteringFeature(MeteringScreenLayoutFeature.equipmentProfiles)) { enabledFeaturesHeight += Dimens.readingContainerSingleValueHeight; diff --git a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart index ce373a8..1b9a1c4 100644 --- a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart +++ b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; +import 'package:lightmeter/data/models/feature.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:lightmeter/providers/remote_config_provider.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart'; @@ -35,7 +37,7 @@ class ReadingsContainer extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!context.isPro) ...[ + if (!context.isPro && RemoteConfig.isEnabled(context, Feature.showUnlockProOnMainScreen)) ...[ const LightmeterProAnimatedDialog(), const _InnerPadding(), ], diff --git a/test/providers/remote_config_provider_test.dart b/test/providers/remote_config_provider_test.dart index a215cbe..c8d9b03 100644 --- a/test/providers/remote_config_provider_test.dart +++ b/test/providers/remote_config_provider_test.dart @@ -19,8 +19,8 @@ void main() { setUp(() { when(() => mockRemoteConfigService.fetchConfig()).thenAnswer((_) async {}); - when(() => mockRemoteConfigService.getValue(Feature.unlockProFeaturesText)).thenReturn(false); - when(() => mockRemoteConfigService.getAll()).thenReturn({Feature.unlockProFeaturesText: false}); + when(() => mockRemoteConfigService.getValue(Feature.showUnlockProOnMainScreen)).thenReturn(false); + when(() => mockRemoteConfigService.getAll()).thenReturn({Feature.showUnlockProOnMainScreen: false}); }); tearDown(() { @@ -42,7 +42,7 @@ void main() { when(() => mockRemoteConfigService.onConfigUpdated()).thenAnswer((_) => const Stream.empty()); await pumpTestWidget(tester); - expect(find.text('unlockProFeaturesText: false'), findsOneWidget); + expect(find.text('showUnlockProOnMainScreen: false'), findsOneWidget); }, ); @@ -53,34 +53,34 @@ void main() { when(() => mockRemoteConfigService.onConfigUpdated()).thenAnswer((_) => remoteConfigUpdateController.stream); await pumpTestWidget(tester); - expect(find.text('unlockProFeaturesText: false'), findsOneWidget); + expect(find.text('showUnlockProOnMainScreen: false'), findsOneWidget); - when(() => mockRemoteConfigService.getValue(Feature.unlockProFeaturesText)).thenReturn(true); - remoteConfigUpdateController.add({Feature.unlockProFeaturesText}); + when(() => mockRemoteConfigService.getValue(Feature.showUnlockProOnMainScreen)).thenReturn(true); + remoteConfigUpdateController.add({Feature.showUnlockProOnMainScreen}); await tester.pumpAndSettle(); - expect(find.text('unlockProFeaturesText: true'), findsOneWidget); + expect(find.text('showUnlockProOnMainScreen: true'), findsOneWidget); await remoteConfigUpdateController.close(); }, ); test('RemoteConfig.updateShouldNotifyDependent', () { - const config = RemoteConfig(config: {Feature.unlockProFeaturesText: false}, child: SizedBox()); + const config = RemoteConfig(config: {Feature.showUnlockProOnMainScreen: false}, child: SizedBox()); expect( config.updateShouldNotifyDependent(config, {}), false, ); expect( config.updateShouldNotifyDependent( - const RemoteConfig(config: {Feature.unlockProFeaturesText: false}, child: SizedBox()), - {Feature.unlockProFeaturesText}, + const RemoteConfig(config: {Feature.showUnlockProOnMainScreen: false}, child: SizedBox()), + {Feature.showUnlockProOnMainScreen}, ), false, ); expect( config.updateShouldNotifyDependent( - const RemoteConfig(config: {Feature.unlockProFeaturesText: true}, child: SizedBox()), - {Feature.unlockProFeaturesText}, + const RemoteConfig(config: {Feature.showUnlockProOnMainScreen: true}, child: SizedBox()), + {Feature.showUnlockProOnMainScreen}, ), true, ); @@ -96,7 +96,7 @@ class _Application extends StatelessWidget { home: Scaffold( body: Center( child: Text( - "${Feature.unlockProFeaturesText.name}: ${RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText)}", + "${Feature.showUnlockProOnMainScreen.name}: ${RemoteConfig.isEnabled(context, Feature.showUnlockProOnMainScreen)}", ), ), ), From 85c409fbe8102d15921b7204b74b17e49cfde53b Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Mon, 15 Jan 2024 23:13:26 +0100 Subject: [PATCH 7/8] ML-134 Firebase Remote Config issues (#150) * added try-catch to config fetch --- lib/data/remote_config_service.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/data/remote_config_service.dart b/lib/data/remote_config_service.dart index 9bd4051..1ae86ef 100644 --- a/lib/data/remote_config_service.dart +++ b/lib/data/remote_config_service.dart @@ -52,9 +52,15 @@ class RemoteConfigService implements IRemoteConfigService { @override Future fetchConfig() async { - // https://github.com/firebase/flutterfire/issues/6196#issuecomment-927751667 - await Future.delayed(const Duration(seconds: 1)); - await FirebaseRemoteConfig.instance.fetch(); + try { + // https://github.com/firebase/flutterfire/issues/6196#issuecomment-927751667 + await Future.delayed(const Duration(seconds: 1)); + await FirebaseRemoteConfig.instance.fetch(); + } on FirebaseException catch (e) { + _logError('Firebase exception during Firebase Remote Config fetch: $e'); + } catch (e) { + _logError('Error during Firebase Remote Config fetch: $e'); + } } @override From d66404e0856fe055fcad555251926c2c56f52687 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jan 2024 22:28:33 +0000 Subject: [PATCH 8/8] Version bump --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 93635ef..0698aaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: lightmeter description: Lightmeter app inspired by Material 3 design system. publish_to: "none" -version: 0.16.0+45 +version: 0.17.0+46 environment: sdk: ">=3.0.0 <4.0.0"