From 0c581347330199f6e9c201e8581dc7a30c82cf3f Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Fri, 23 Jun 2023 10:35:33 +0200 Subject: [PATCH 01/10] ML-62 Services tests (#82) * removed redundant `UserPreferencesService` from `MeteringBloc` * wip * post-merge fixes * `MeasureEvent` tests * `MeasureEvent` tests revision * `MeasureEvent` tests added timeout * added stubs for other `MeteringBloc` events * rewritten `MeteringBloc` logic * wip * `IsoChangedEvent` tests * refined `IsoChangedEvent` tests * `NdChangedEvent` tests * `FilmChangedEvent` tests * `MeteringCommunicationBloc` tests * added test run to ci * overriden `==` for `MeasuredState` * `LuxMeteringEvent` tests * refined `LuxMeteringEvent` tests * rename * wip * wip * `InitializeEvent`/`DeinitializeEvent` tests * clamp minZoomLevel * fixed `MeteringCommunicationBloc` tests * wip * `ZoomChangedEvent` tests * `ExposureOffsetChangedEvent`/`ExposureOffsetResetEvent` tests * renamed test groups * added test coverage script * improved `CameraContainerBloc` test coverage * `EquipmentProfileChangedEvent` tests * verify response vibration * fixed running all tests * `MeteringCommunicationBloc` equality tests * `CameraContainerBloc` equality tests * removed generated code from coverage * `MeteringScreenLayoutFeature` tests * `SupportedLocale` tests * `Film` tests * `CaffeineService` tests * `UserPreferencesService` tests (wip) * `LightSensorService` tests (wip) * `migrateOldKeys()` tests * ignore currently unused getters & setters * gradle upgrade * `reset(sharedPreferences);` calls count * typo --- android/app/build.gradle | 6 +- android/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- lib/data/caffeine_service.dart | 4 +- lib/data/shared_prefs_service.dart | 91 ++-- test/data/caffeine_service_test.dart | 76 ++++ test/data/light_sensor_service_test.dart | 71 ++++ test/data/shared_prefs_service_test.dart | 392 ++++++++++++++++++ 8 files changed, 590 insertions(+), 54 deletions(-) create mode 100644 test/data/caffeine_service_test.dart create mode 100644 test/data/light_sensor_service_test.dart create mode 100644 test/data/shared_prefs_service_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 4bc9578..53774dc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,10 +33,6 @@ apply plugin: 'com.google.firebase.crashlytics' apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" -gradle.beforeProject({ project-> - project.setProperty("target-platform", "android-arm,android-arm64") -}) - android { compileSdkVersion 33 ndkVersion flutter.ndkVersion @@ -112,5 +108,5 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "com.android.billingclient:billing-ktx:5.1.0" + implementation "com.android.billingclient:billing-ktx:6.0.0" } diff --git a/android/build.gradle b/android/build.gradle index 346e639..84e662d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.2.1' + classpath 'com.android.tools.build:gradle:7.4.2' classpath 'com.google.gms:google-services:4.3.10' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index cb24abd..3c472b9 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/lib/data/caffeine_service.dart b/lib/data/caffeine_service.dart index cfb0d50..da36968 100644 --- a/lib/data/caffeine_service.dart +++ b/lib/data/caffeine_service.dart @@ -9,7 +9,7 @@ class CaffeineService { return _methodChannel.invokeMethod("isKeepScreenOn").then((value) => value!); } - Future keepScreenOn(bool keep) async { - await _methodChannel.invokeMethod("setKeepScreenOn", keep); + Future keepScreenOn(bool keep) async { + return _methodChannel.invokeMethod("setKeepScreenOn", keep).then((value) => value!); } } diff --git a/lib/data/shared_prefs_service.dart b/lib/data/shared_prefs_service.dart index a86a7d1..e90df0e 100644 --- a/lib/data/shared_prefs_service.dart +++ b/lib/data/shared_prefs_service.dart @@ -10,30 +10,31 @@ import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:shared_preferences/shared_preferences.dart'; class UserPreferencesService { - static const _isoKey = "iso"; - static const _ndFilterKey = "ndFilter"; + static const isoKey = "iso"; + static const ndFilterKey = "ndFilter"; - static const _evSourceTypeKey = "evSourceType"; - static const _cameraEvCalibrationKey = "cameraEvCalibration"; - static const _lightSensorEvCalibrationKey = "lightSensorEvCalibration"; - static const _meteringScreenLayoutKey = "meteringScreenLayout"; - static const _filmKey = "film"; + static const evSourceTypeKey = "evSourceType"; + static const cameraEvCalibrationKey = "cameraEvCalibration"; + static const lightSensorEvCalibrationKey = "lightSensorEvCalibration"; + static const meteringScreenLayoutKey = "meteringScreenLayout"; + static const filmKey = "film"; - static const _caffeineKey = "caffeine"; - static const _hapticsKey = "haptics"; - static const _localeKey = "locale"; + static const caffeineKey = "caffeine"; + static const hapticsKey = "haptics"; + static const localeKey = "locale"; - static const _themeTypeKey = "themeType"; - static const _primaryColorKey = "primaryColor"; - static const _dynamicColorKey = "dynamicColor"; + static const themeTypeKey = "themeType"; + static const primaryColorKey = "primaryColor"; + static const dynamicColorKey = "dynamicColor"; final SharedPreferences _sharedPreferences; UserPreferencesService(this._sharedPreferences) { - _migrateOldKeys(); + migrateOldKeys(); } - Future _migrateOldKeys() async { + @visibleForTesting + Future migrateOldKeys() async { final legacyIsoIndex = _sharedPreferences.getInt("curIsoIndex"); if (legacyIsoIndex != null) { iso = IsoValue.values[legacyIsoIndex]; @@ -69,22 +70,22 @@ class UserPreferencesService { } IsoValue get iso => - IsoValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(_isoKey) ?? 100)); - set iso(IsoValue value) => _sharedPreferences.setInt(_isoKey, value.value); + IsoValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(isoKey) ?? 100)); + set iso(IsoValue value) => _sharedPreferences.setInt(isoKey, value.value); NdValue get ndFilter => - NdValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(_ndFilterKey) ?? 0)); - set ndFilter(NdValue value) => _sharedPreferences.setInt(_ndFilterKey, value.value); + NdValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(ndFilterKey) ?? 0)); + set ndFilter(NdValue value) => _sharedPreferences.setInt(ndFilterKey, value.value); EvSourceType get evSourceType => - EvSourceType.values[_sharedPreferences.getInt(_evSourceTypeKey) ?? 0]; - set evSourceType(EvSourceType value) => _sharedPreferences.setInt(_evSourceTypeKey, value.index); + EvSourceType.values[_sharedPreferences.getInt(evSourceTypeKey) ?? 0]; + set evSourceType(EvSourceType value) => _sharedPreferences.setInt(evSourceTypeKey, value.index); - bool get caffeine => _sharedPreferences.getBool(_caffeineKey) ?? false; - set caffeine(bool value) => _sharedPreferences.setBool(_caffeineKey, value); + bool get caffeine => _sharedPreferences.getBool(caffeineKey) ?? false; + set caffeine(bool value) => _sharedPreferences.setBool(caffeineKey, value); MeteringScreenLayoutConfig get meteringScreenLayout { - final configJson = _sharedPreferences.getString(_meteringScreenLayoutKey); + final configJson = _sharedPreferences.getString(meteringScreenLayoutKey); if (configJson != null) { return MeteringScreenLayoutConfigJson.fromJson( json.decode(configJson) as Map, @@ -98,44 +99,44 @@ class UserPreferencesService { } set meteringScreenLayout(MeteringScreenLayoutConfig value) => - _sharedPreferences.setString(_meteringScreenLayoutKey, json.encode(value.toJson())); + _sharedPreferences.setString(meteringScreenLayoutKey, json.encode(value.toJson())); - bool get haptics => _sharedPreferences.getBool(_hapticsKey) ?? true; - set haptics(bool value) => _sharedPreferences.setBool(_hapticsKey, value); + bool get haptics => _sharedPreferences.getBool(hapticsKey) ?? true; + set haptics(bool value) => _sharedPreferences.setBool(hapticsKey, value); SupportedLocale get locale => SupportedLocale.values.firstWhere( - (e) => e.toString() == _sharedPreferences.getString(_localeKey), + (e) => e.toString() == _sharedPreferences.getString(localeKey), orElse: () => SupportedLocale.en, ); - set locale(SupportedLocale value) => _sharedPreferences.setString(_localeKey, value.toString()); + set locale(SupportedLocale value) => _sharedPreferences.setString(localeKey, value.toString()); - double get cameraEvCalibration => _sharedPreferences.getDouble(_cameraEvCalibrationKey) ?? 0.0; + double get cameraEvCalibration => _sharedPreferences.getDouble(cameraEvCalibrationKey) ?? 0.0; set cameraEvCalibration(double value) => - _sharedPreferences.setDouble(_cameraEvCalibrationKey, value); + _sharedPreferences.setDouble(cameraEvCalibrationKey, value); double get lightSensorEvCalibration => - _sharedPreferences.getDouble(_lightSensorEvCalibrationKey) ?? 0.0; + _sharedPreferences.getDouble(lightSensorEvCalibrationKey) ?? 0.0; set lightSensorEvCalibration(double value) => - _sharedPreferences.setDouble(_lightSensorEvCalibrationKey, value); + _sharedPreferences.setDouble(lightSensorEvCalibrationKey, value); - ThemeType get themeType => ThemeType.values[_sharedPreferences.getInt(_themeTypeKey) ?? 0]; - set themeType(ThemeType value) => _sharedPreferences.setInt(_themeTypeKey, value.index); + ThemeType get themeType => ThemeType.values[_sharedPreferences.getInt(themeTypeKey) ?? 0]; + set themeType(ThemeType value) => _sharedPreferences.setInt(themeTypeKey, value.index); - Color get primaryColor => Color(_sharedPreferences.getInt(_primaryColorKey) ?? 0xff2196f3); - set primaryColor(Color value) => _sharedPreferences.setInt(_primaryColorKey, value.value); + Color get primaryColor => Color(_sharedPreferences.getInt(primaryColorKey) ?? 0xff2196f3); + set primaryColor(Color value) => _sharedPreferences.setInt(primaryColorKey, value.value); - bool get dynamicColor => _sharedPreferences.getBool(_dynamicColorKey) ?? false; - set dynamicColor(bool value) => _sharedPreferences.setBool(_dynamicColorKey, value); + bool get dynamicColor => _sharedPreferences.getBool(dynamicColorKey) ?? false; + set dynamicColor(bool value) => _sharedPreferences.setBool(dynamicColorKey, value); Film get film => Film.values.firstWhere( - (e) => e.name == _sharedPreferences.getString(_filmKey), + (e) => e.name == _sharedPreferences.getString(filmKey), orElse: () => Film.values.first, ); - set film(Film value) => _sharedPreferences.setString(_filmKey, value.name); + set film(Film value) => _sharedPreferences.setString(filmKey, value.name); - String get selectedEquipmentProfileId => ''; - set selectedEquipmentProfileId(String id) {} + String get selectedEquipmentProfileId => ''; // coverage:ignore-line + set selectedEquipmentProfileId(String id) {} // coverage:ignore-line - List get equipmentProfiles => []; - set equipmentProfiles(List profiles) {} + List get equipmentProfiles => []; // coverage:ignore-line + set equipmentProfiles(List profiles) {} // coverage:ignore-line } diff --git a/test/data/caffeine_service_test.dart b/test/data/caffeine_service_test.dart new file mode 100644 index 0000000..0c80fac --- /dev/null +++ b/test/data/caffeine_service_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/caffeine_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late CaffeineService service; + + const methodChannel = MethodChannel('com.vodemn.lightmeter/keepScreenOn'); + Future? methodCallSuccessHandler(MethodCall methodCall) async { + switch (methodCall.method) { + case "isKeepScreenOn": + return true; + case "setKeepScreenOn": + return methodCall.arguments as bool; + default: + return null; + } + } + + setUp(() { + service = const CaffeineService(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, methodCallSuccessHandler); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + }); + + group( + 'isKeepScreenOn()', + () { + test('true', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (methodCall) async { + switch (methodCall.method) { + case "isKeepScreenOn": + return true; + default: + return null; + } + }); + expectLater(service.isKeepScreenOn(), completion(true)); + }); + + test('false', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (methodCall) async { + switch (methodCall.method) { + case "isKeepScreenOn": + return false; + default: + return null; + } + }); + expectLater(service.isKeepScreenOn(), completion(false)); + }); + }, + ); + + group( + 'keepScreenOn()', + () { + test('true', () async => expectLater(service.keepScreenOn(true), completion(true))); + + test('false', () async => expectLater(service.keepScreenOn(false), completion(false))); + }, + ); +} diff --git a/test/data/light_sensor_service_test.dart b/test/data/light_sensor_service_test.dart new file mode 100644 index 0000000..1256e18 --- /dev/null +++ b/test/data/light_sensor_service_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/light_sensor_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late LightSensorService service; + + const methodChannel = MethodChannel('system_feature'); + // TODO: add event channel mock + //const eventChannel = EventChannel('light.eventChannel'); + + setUp(() { + service = const LightSensorService(); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + }); + + group( + 'hasSensor()', + () { + test('true', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (methodCall) async { + switch (methodCall.method) { + case "sensor": + return true; + default: + return null; + } + }); + expectLater(service.hasSensor(), completion(true)); + }); + + test('false', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (methodCall) async { + switch (methodCall.method) { + case "sensor": + return false; + default: + return null; + } + }); + expectLater(service.hasSensor(), completion(false)); + }); + test('null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(methodChannel, (methodCall) async { + switch (methodCall.method) { + case "sensor": + return null; + default: + return null; + } + }); + expectLater(service.hasSensor(), completion(false)); + }); + }, + ); +} diff --git a/test/data/shared_prefs_service_test.dart b/test/data/shared_prefs_service_test.dart new file mode 100644 index 0000000..6143141 --- /dev/null +++ b/test/data/shared_prefs_service_test.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/ev_source_type.dart'; +import 'package:lightmeter/data/models/film.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/data/models/supported_locale.dart'; +import 'package:lightmeter/data/models/theme_type.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/providers/theme_provider.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MockSharedPreferences extends Mock implements SharedPreferences {} + +void main() { + late _MockSharedPreferences sharedPreferences; + late UserPreferencesService service; + + setUpAll(() { + sharedPreferences = _MockSharedPreferences(); + service = UserPreferencesService(sharedPreferences); + }); + + tearDown(() { + reset(sharedPreferences); + }); + + group('migrateOldKeys()', () { + test('no legacy keys', () async { + when(() => sharedPreferences.getInt("curIsoIndex")).thenReturn(null); + when(() => sharedPreferences.getInt("curndIndex")).thenReturn(null); + when(() => sharedPreferences.getDouble("cameraCalibr")).thenReturn(null); + when(() => sharedPreferences.getDouble("sensorCalibr")).thenReturn(null); + when(() => sharedPreferences.getBool("vibrate")).thenReturn(null); + + when(() => sharedPreferences.remove("curIsoIndex")).thenAnswer((_) async => true); + when(() => sharedPreferences.remove("curndIndex")).thenAnswer((_) async => true); + when(() => sharedPreferences.remove("cameraCalibr")).thenAnswer((_) async => true); + when(() => sharedPreferences.remove("sensorCalibr")).thenAnswer((_) async => true); + when(() => sharedPreferences.remove("vibrate")).thenAnswer((_) async => true); + + await service.migrateOldKeys(); + + verifyNever(() => sharedPreferences.remove("curIsoIndex")); + verifyNever(() => sharedPreferences.remove("curndIndex")); + verifyNever(() => sharedPreferences.remove("cameraCalibr")); + verifyNever(() => sharedPreferences.remove("sensorCalibr")); + verifyNever(() => sharedPreferences.remove("vibrate")); + }); + + test('migrate all keys', () async { + when(() => sharedPreferences.getInt("curIsoIndex")).thenReturn(1); + when(() => sharedPreferences.getInt("curndIndex")).thenReturn(0); + when(() => sharedPreferences.getDouble("cameraCalibr")).thenReturn(1.0); + when(() => sharedPreferences.getDouble("sensorCalibr")).thenReturn(-1.0); + when(() => sharedPreferences.getBool("vibrate")).thenReturn(false); + + when( + () => sharedPreferences.setInt(UserPreferencesService.isoKey, IsoValue.values[1].value), + ).thenAnswer((_) => Future.value(true)); + when( + () => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, NdValue.values[0].value), + ).thenAnswer((_) => Future.value(true)); + when( + () => sharedPreferences.setDouble(UserPreferencesService.cameraEvCalibrationKey, 1.0), + ).thenAnswer((_) => Future.value(true)); + when( + () => sharedPreferences.setDouble(UserPreferencesService.lightSensorEvCalibrationKey, -1.0), + ).thenAnswer((_) => Future.value(true)); + when( + () => sharedPreferences.setBool(UserPreferencesService.hapticsKey, false), + ).thenAnswer((_) => Future.value(true)); + + when(() => sharedPreferences.remove("curIsoIndex")).thenAnswer((_) async => true); + when(() => sharedPreferences.remove("curndIndex")).thenAnswer((_) async => true); + when(() => sharedPreferences.remove("cameraCalibr")).thenAnswer((_) async => true); + when(() => sharedPreferences.remove("sensorCalibr")).thenAnswer((_) async => true); + when(() => sharedPreferences.remove("vibrate")).thenAnswer((_) async => true); + + await service.migrateOldKeys(); + + verify(() => sharedPreferences.remove("curIsoIndex")).called(1); + verify(() => sharedPreferences.remove("curndIndex")).called(1); + verify(() => sharedPreferences.remove("cameraCalibr")).called(1); + verify(() => sharedPreferences.remove("sensorCalibr")).called(1); + verify(() => sharedPreferences.remove("vibrate")).called(1); + }); + }); + + group('iso', () { + test('get default', () { + when(() => sharedPreferences.getInt(UserPreferencesService.isoKey)).thenReturn(null); + expect(service.iso, const IsoValue(100, StopType.full)); + }); + + test('get', () { + when(() => sharedPreferences.getInt(UserPreferencesService.isoKey)).thenReturn(100); + expect(service.iso, const IsoValue(100, StopType.full)); + }); + + test('set', () { + when(() => sharedPreferences.setInt(UserPreferencesService.isoKey, 200)) + .thenAnswer((_) => Future.value(true)); + service.iso = const IsoValue(200, StopType.full); + verify(() => sharedPreferences.setInt(UserPreferencesService.isoKey, 200)).called(1); + }); + }); + + group('ndFilter', () { + test('get default', () { + when(() => sharedPreferences.getInt(UserPreferencesService.ndFilterKey)).thenReturn(null); + expect(service.ndFilter, const NdValue(0)); + }); + + test('get', () { + when(() => sharedPreferences.getInt(UserPreferencesService.ndFilterKey)).thenReturn(4); + expect(service.ndFilter, const NdValue(4)); + }); + + test('set', () { + when(() => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, 0)) + .thenAnswer((_) => Future.value(true)); + service.ndFilter = const NdValue(0); + verify(() => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, 0)).called(1); + }); + }); + + group('evSourceType', () { + test('get default', () { + when(() => sharedPreferences.getInt(UserPreferencesService.evSourceTypeKey)).thenReturn(null); + expect(service.evSourceType, EvSourceType.camera); + }); + + test('get', () { + when(() => sharedPreferences.getInt(UserPreferencesService.evSourceTypeKey)).thenReturn(1); + expect(service.evSourceType, EvSourceType.sensor); + }); + + test('set', () { + when(() => sharedPreferences.setInt(UserPreferencesService.evSourceTypeKey, 1)) + .thenAnswer((_) => Future.value(true)); + service.evSourceType = EvSourceType.sensor; + verify(() => sharedPreferences.setInt(UserPreferencesService.evSourceTypeKey, 1)).called(1); + }); + }); + + group('caffeine', () { + test('get default', () { + when(() => sharedPreferences.getBool(UserPreferencesService.caffeineKey)).thenReturn(null); + expect(service.caffeine, false); + }); + + test('get', () { + when(() => sharedPreferences.getBool(UserPreferencesService.caffeineKey)).thenReturn(true); + expect(service.caffeine, true); + }); + + test('set', () { + when(() => sharedPreferences.setBool(UserPreferencesService.caffeineKey, false)) + .thenAnswer((_) => Future.value(true)); + service.caffeine = false; + verify(() => sharedPreferences.setBool(UserPreferencesService.caffeineKey, false)).called(1); + }); + }); + + group('meteringScreenLayout', () { + test('get default', () { + when( + () => sharedPreferences.getString(UserPreferencesService.meteringScreenLayoutKey), + ).thenReturn(null); + expect( + service.meteringScreenLayout, + { + MeteringScreenLayoutFeature.extremeExposurePairs: true, + MeteringScreenLayoutFeature.filmPicker: true, + }, + ); + }); + + test('get', () { + when( + () => sharedPreferences.getString(UserPreferencesService.meteringScreenLayoutKey), + ).thenReturn("""{"0":false,"1":true}"""); + expect( + service.meteringScreenLayout, + { + MeteringScreenLayoutFeature.extremeExposurePairs: false, + MeteringScreenLayoutFeature.filmPicker: true, + }, + ); + }); + + test('set', () { + when( + () => sharedPreferences.setString( + UserPreferencesService.meteringScreenLayoutKey, + """{"0":false,"1":true}""", + ), + ).thenAnswer((_) => Future.value(true)); + service.meteringScreenLayout = { + MeteringScreenLayoutFeature.extremeExposurePairs: false, + MeteringScreenLayoutFeature.filmPicker: true, + }; + verify( + () => sharedPreferences.setString( + UserPreferencesService.meteringScreenLayoutKey, + """{"0":false,"1":true}""", + ), + ).called(1); + }); + }); + + group('haptics', () { + test('get default', () { + when(() => sharedPreferences.getBool(UserPreferencesService.hapticsKey)).thenReturn(null); + expect(service.haptics, true); + }); + + test('get', () { + when(() => sharedPreferences.getBool(UserPreferencesService.hapticsKey)).thenReturn(true); + expect(service.haptics, true); + }); + + test('set', () { + when(() => sharedPreferences.setBool(UserPreferencesService.hapticsKey, false)) + .thenAnswer((_) => Future.value(true)); + service.haptics = false; + verify(() => sharedPreferences.setBool(UserPreferencesService.hapticsKey, false)).called(1); + }); + }); + + group('locale', () { + test('get default', () { + when(() => sharedPreferences.getString(UserPreferencesService.localeKey)).thenReturn(null); + expect(service.locale, SupportedLocale.en); + }); + + test('get', () { + when(() => sharedPreferences.getString(UserPreferencesService.localeKey)) + .thenReturn('SupportedLocale.ru'); + expect(service.locale, SupportedLocale.ru); + }); + + test('set', () { + when( + () => sharedPreferences.setString(UserPreferencesService.localeKey, 'SupportedLocale.en'), + ).thenAnswer((_) => Future.value(true)); + service.locale = SupportedLocale.en; + verify( + () => sharedPreferences.setString(UserPreferencesService.localeKey, 'SupportedLocale.en'), + ).called(1); + }); + }); + + group('cameraEvCalibration', () { + test('get default', () { + when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey)) + .thenReturn(null); + expect(service.cameraEvCalibration, 0.0); + }); + + test('get', () { + when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey)) + .thenReturn(2.0); + expect(service.cameraEvCalibration, 2.0); + }); + + test('set', () { + when( + () => sharedPreferences.setDouble(UserPreferencesService.cameraEvCalibrationKey, 1.0), + ).thenAnswer((_) => Future.value(true)); + service.cameraEvCalibration = 1.0; + verify( + () => sharedPreferences.setDouble(UserPreferencesService.cameraEvCalibrationKey, 1.0), + ).called(1); + }); + }); + + group('lightSensorEvCalibration', () { + test('get default', () { + when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey)) + .thenReturn(null); + expect(service.lightSensorEvCalibration, 0.0); + }); + + test('get', () { + when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey)) + .thenReturn(2.0); + expect(service.lightSensorEvCalibration, 2.0); + }); + + test('set', () { + when( + () => sharedPreferences.setDouble(UserPreferencesService.lightSensorEvCalibrationKey, 1.0), + ).thenAnswer((_) => Future.value(true)); + service.lightSensorEvCalibration = 1.0; + verify( + () => sharedPreferences.setDouble(UserPreferencesService.lightSensorEvCalibrationKey, 1.0), + ).called(1); + }); + }); + + group('themeType', () { + test('get default', () { + when(() => sharedPreferences.getInt(UserPreferencesService.themeTypeKey)).thenReturn(null); + expect(service.themeType, ThemeType.light); + }); + + test('get', () { + when(() => sharedPreferences.getInt(UserPreferencesService.themeTypeKey)).thenReturn(1); + expect(service.themeType, ThemeType.dark); + }); + + test('set', () { + when( + () => sharedPreferences.setInt(UserPreferencesService.themeTypeKey, 1), + ).thenAnswer((_) => Future.value(true)); + service.themeType = ThemeType.dark; + verify( + () => sharedPreferences.setInt(UserPreferencesService.themeTypeKey, 1), + ).called(1); + }); + }); + + group('primaryColor', () { + test('get default', () { + when(() => sharedPreferences.getInt(UserPreferencesService.primaryColorKey)).thenReturn(null); + expect(service.primaryColor, ThemeProvider.primaryColorsList[5]); + }); + + test('get', () { + when(() => sharedPreferences.getInt(UserPreferencesService.primaryColorKey)) + .thenReturn(0xff9c27b0); + expect(service.primaryColor, ThemeProvider.primaryColorsList[2]); + }); + + test('set', () { + when( + () => sharedPreferences.setInt(UserPreferencesService.primaryColorKey, 0xff000000), + ).thenAnswer((_) => Future.value(true)); + service.primaryColor = Colors.black; + verify( + () => sharedPreferences.setInt(UserPreferencesService.primaryColorKey, 0xff000000), + ).called(1); + }); + }); + + group('dynamicColor', () { + test('get default', () { + when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey)) + .thenReturn(null); + expect(service.dynamicColor, false); + }); + + test('get', () { + when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey)) + .thenReturn(true); + expect(service.dynamicColor, true); + }); + + test('set', () { + when(() => sharedPreferences.setBool(UserPreferencesService.dynamicColorKey, false)) + .thenAnswer((_) => Future.value(true)); + service.dynamicColor = false; + verify(() => sharedPreferences.setBool(UserPreferencesService.dynamicColorKey, false)) + .called(1); + }); + }); + + group('film', () { + test('get default', () { + when(() => sharedPreferences.getString(UserPreferencesService.filmKey)).thenReturn(null); + expect(service.film, Film.values.first); + }); + + test('get', () { + when(() => sharedPreferences.getString(UserPreferencesService.filmKey)) + .thenReturn('Fomapan ACTION 400'); + expect(service.film, const FomapanFilm.action400()); + }); + + test('set', () { + when(() => sharedPreferences.setString(UserPreferencesService.filmKey, 'Fomapan ACTION 400')) + .thenAnswer((_) => Future.value(true)); + service.film = const FomapanFilm.action400(); + verify( + () => sharedPreferences.setString(UserPreferencesService.filmKey, 'Fomapan ACTION 400'), + ).called(1); + }); + }); +} From 2735f0b66f418ba13492da4dae6c11c69db94a5d Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Fri, 23 Jun 2023 10:47:34 +0200 Subject: [PATCH 02/10] ML-81 Unsaved fractional stops (#83) * save stop type to sharedPrefs * tests --- lib/data/shared_prefs_service.dart | 4 ++++ lib/providers/stop_type_provider.dart | 20 +++++++++++++------- test/data/shared_prefs_service_test.dart | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/lib/data/shared_prefs_service.dart b/lib/data/shared_prefs_service.dart index e90df0e..8fbb6af 100644 --- a/lib/data/shared_prefs_service.dart +++ b/lib/data/shared_prefs_service.dart @@ -14,6 +14,7 @@ class UserPreferencesService { static const ndFilterKey = "ndFilter"; static const evSourceTypeKey = "evSourceType"; + static const stopTypeKey = "stopType"; static const cameraEvCalibrationKey = "cameraEvCalibration"; static const lightSensorEvCalibrationKey = "lightSensorEvCalibration"; static const meteringScreenLayoutKey = "meteringScreenLayout"; @@ -84,6 +85,9 @@ class UserPreferencesService { bool get caffeine => _sharedPreferences.getBool(caffeineKey) ?? false; set caffeine(bool value) => _sharedPreferences.setBool(caffeineKey, value); + StopType get stopType => StopType.values[_sharedPreferences.getInt(stopTypeKey) ?? 2]; + set stopType(StopType value) => _sharedPreferences.setInt(stopTypeKey, value.index); + MeteringScreenLayoutConfig get meteringScreenLayout { final configJson = _sharedPreferences.getString(meteringScreenLayoutKey); if (configJson != null) { diff --git a/lib/providers/stop_type_provider.dart b/lib/providers/stop_type_provider.dart index 690f65f..3b20dd5 100644 --- a/lib/providers/stop_type_provider.dart +++ b/lib/providers/stop_type_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/utils/inherited_generics.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; @@ -16,14 +17,12 @@ class StopTypeProvider extends StatefulWidget { } class StopTypeProviderState extends State { - StopType _stopType = StopType.third; + late StopType _stopType; - StopType get stopType => _stopType; - - void set(StopType type) { - setState(() { - _stopType = type; - }); + @override + void initState() { + super.initState(); + _stopType = context.get().stopType; } @override @@ -33,4 +32,11 @@ class StopTypeProviderState extends State { child: widget.child, ); } + + void set(StopType type) { + setState(() { + _stopType = type; + }); + context.get().stopType = type; + } } diff --git a/test/data/shared_prefs_service_test.dart b/test/data/shared_prefs_service_test.dart index 6143141..1a2d05b 100644 --- a/test/data/shared_prefs_service_test.dart +++ b/test/data/shared_prefs_service_test.dart @@ -164,6 +164,25 @@ void main() { }); }); + group('stopType', () { + test('get default', () { + when(() => sharedPreferences.getInt(UserPreferencesService.stopTypeKey)).thenReturn(null); + expect(service.stopType, StopType.third); + }); + + test('get', () { + when(() => sharedPreferences.getInt(UserPreferencesService.stopTypeKey)).thenReturn(1); + expect(service.stopType, StopType.half); + }); + + test('set', () { + when(() => sharedPreferences.setInt(UserPreferencesService.stopTypeKey, 0)) + .thenAnswer((_) => Future.value(true)); + service.stopType = StopType.full; + verify(() => sharedPreferences.setInt(UserPreferencesService.stopTypeKey, 0)).called(1); + }); + }); + group('meteringScreenLayout', () { test('get default', () { when( From 8ff387c5c5f37b5d61cfe1ebb46264d516fa6f4b Mon Sep 17 00:00:00 2001 From: Vadim Date: Fri, 23 Jun 2023 11:41:58 +0200 Subject: [PATCH 03/10] Fixed `com.google.gms:google-services` version --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index 84e662d..951d253 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,7 +7,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:7.4.2' - classpath 'com.google.gms:google-services:4.3.10' + classpath 'com.google.gms:google-services:4.3.15' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.8.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } From 79d6034894fc9818f50eed8d1d2ad76d715b763d Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Tue, 27 Jun 2023 12:17:35 +0200 Subject: [PATCH 04/10] ML-61 Update version in pubspec & create Github release from GitHub actions (#84) * Replaced zipping action thedoctor0/zip-release@0.7.1 -> vimtor/action-zip@v1.1 * typo * recursive: false * typo * typo * debugSymbolLevel 'FULL' * Update build.gradle * Version bump * wip * wip * `create-release` job * removed changelog input * added `needs` * Version bump * typo * returned to macos-11 runner * reverted pubspec version * Version bump * download artifacts * Version bump * extended artifacts path * Version bump * added LS * Version bump * Version bump * rename files * Version bump * removed ls * Version bump * revert version * typo --- .github/workflows/cd_prod.yml | 63 ++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cd_prod.yml b/.github/workflows/cd_prod.yml index f8f0274..f2f8ca2 100644 --- a/.github/workflows/cd_prod.yml +++ b/.github/workflows/cd_prod.yml @@ -7,15 +7,20 @@ name: Build prod .aab & .apk on: workflow_dispatch: + inputs: + version: + description: "Version" + required: true + type: string env: BUILD_ARGS: --release --flavor prod --dart-define cameraPreviewAspectRatio=2/3 -t lib/main_prod.dart jobs: build: + name: Build .apk & .aab runs-on: macos-11 timeout-minutes: 30 - steps: # - uses: shaunco/ssh-agent@git-repo-mapping # with: @@ -61,6 +66,9 @@ jobs: echo -n "$FIREBASE_OPTIONS" | base64 --decode --output $FIREBASE_OPTIONS_PATH cp $FIREBASE_OPTIONS_PATH ./lib + - name: Increment build number & replace version number + run: perl -i -pe 's/^(version:\s+)(\d+\.\d+\.\d+)(\+)(\d+)$/$1."${{ github.event.inputs.version }}".$3.($4+1)/e' pubspec.yaml + - name: Install Flutter uses: subosito/flutter-action@v2 with: @@ -89,3 +97,56 @@ jobs: with: name: m3_lightmeter_bundle path: build/app/outputs/bundle/prodRelease/app-prod-release.aab + + update-version-in-repo: + name: Update repo version + needs: [build] + runs-on: macos-11 + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Increment build number & replace version number + run: perl -i -pe 's/^(version:\s+)(\d+\.\d+\.\d+)(\+)(\d+)$/$1."${{ github.event.inputs.version }}".$3.($4+1)/e' pubspec.yaml + + - name: Commit and push changes + run: | + git config --global user.name "vodemn" + git config --global user.email "vadim.turko@gmail.com" + + git add -A + git commit -m "Version bump" + git push + + create-release: + name: Create Github release + needs: [build, update-version-in-repo] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Download apk + uses: actions/download-artifact@v3 + with: + name: m3_lightmeter_apk + + - name: Download app bundle + uses: actions/download-artifact@v3 + with: + name: m3_lightmeter_bundle + + - name: Rename artifacts + run: | + mv app-prod-release.apk m3_lightmeter.apk + mv app-prod-release.aab m3_lightmeter.aab + + - uses: ncipollo/release-action@v1.12.0 + with: + artifacts: "m3_lightmeter.apk, m3_lightmeter.aab" + skipIfReleaseExists: true + tag: "v${{ github.event.inputs.version }}" From ed83540ddea241170acc88de8d2c0b0d6d582a02 Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Wed, 28 Jun 2023 17:53:54 +0200 Subject: [PATCH 05/10] ML-61 Allow pushes from Github Action to protected branch (#85) * Replaced zipping action thedoctor0/zip-release@0.7.1 -> vimtor/action-zip@v1.1 * typo * recursive: false * typo * typo * debugSymbolLevel 'FULL' * Update build.gradle * Version bump * wip * wip * `create-release` job * removed changelog input * added `needs` * Version bump * typo * returned to macos-11 runner * reverted pubspec version * Version bump * download artifacts * Version bump * extended artifacts path * Version bump * added LS * Version bump * Version bump * rename files * Version bump * removed ls * Version bump * revert version * typo * added push to protected branch action * run push on ubuntu-latest * added branch name conditions * Version bump * typo * Version bump --- .github/workflows/cd_prod.yml | 14 ++++++++++---- pubspec.yaml | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cd_prod.yml b/.github/workflows/cd_prod.yml index f2f8ca2..9ff3d6e 100644 --- a/.github/workflows/cd_prod.yml +++ b/.github/workflows/cd_prod.yml @@ -101,7 +101,7 @@ jobs: update-version-in-repo: name: Update repo version needs: [build] - runs-on: macos-11 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: @@ -110,18 +110,24 @@ jobs: - name: Increment build number & replace version number run: perl -i -pe 's/^(version:\s+)(\d+\.\d+\.\d+)(\+)(\d+)$/$1."${{ github.event.inputs.version }}".$3.($4+1)/e' pubspec.yaml - - name: Commit and push changes + - name: Commit changes run: | git config --global user.name "vodemn" git config --global user.email "vadim.turko@gmail.com" - git add -A git commit -m "Version bump" - git push + + - name: Push to main + uses: CasperWA/push-protected@v2 + with: + token: ${{ secrets.PUSH_TO_MAIN_TOKEN }} + branch: ${{ github.ref_name }} + unprotect_reviews: true create-release: name: Create Github release needs: [build, update-version-in-repo] + if: github.ref_name == 'main' runs-on: ubuntu-latest permissions: contents: write diff --git a/pubspec.yaml b/pubspec.yaml index 8c35dd6..8a230c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: lightmeter description: A new Flutter project. publish_to: "none" -version: 0.11.5+28 +version: 0.11.8+30 environment: sdk: ">=3.0.0 <4.0.0" From e001c153fb6041048196b432e602306564b53533 Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Sun, 9 Jul 2023 13:39:33 +0200 Subject: [PATCH 06/10] ML-11 Implement volume buttons actions (#86) * [Android] wip * implemented `VolumeEventsService` * implemented `VolumeKeysListener` (wip) * Added screenshots links * [Android] nullable typo * implemented `VolumeKeysNotifier` * deinitialize camera when on Settings screen * disable volume handling when on Settings screen * used "platform" package to mock `isAndroid` * init/deinit camera on settings open * allow volume action override only on metering screen * lints * cleanup * await dispose * tests * reduced `SwitchListTile.contentPadding` * fixed tests * removed `VolumeAction.zoom` * added social preview * typo * fixed `CameraContainerBloc` tests * added `Stream.empty()` tests --- README.md | 58 ++++++------- .../com/vodemn/lightmeter/MainActivity.kt | 62 +++++++++++++- lib/data/light_sensor_service.dart | 15 +++- lib/data/models/volume_action.dart | 3 + lib/data/shared_prefs_service.dart | 15 +++- lib/data/volume_events_service.dart | 40 +++++++++ lib/interactors/metering_interactor.dart | 18 ++--- lib/interactors/settings_interactor.dart | 18 +++++ lib/l10n/intl_en.arb | 1 + lib/l10n/intl_fr.arb | 1 + lib/l10n/intl_ru.arb | 1 + lib/main_prod.dart | 1 - lib/providers.dart | 31 +++---- lib/screens/metering/bloc_metering.dart | 23 ++++++ .../bloc_communication_metering.dart | 2 + .../event_communication_metering.dart | 8 ++ .../state_communication_metering.dart | 8 ++ .../bloc_container_camera.dart | 60 +++++++++----- .../bloc_container_light_sensor.dart | 31 ++++--- .../event_container_light_sensor.dart | 8 ++ .../notifier_volume_keys.dart | 32 ++++++++ lib/screens/metering/event_metering.dart | 8 ++ lib/screens/metering/flow_metering.dart | 27 ++++--- lib/screens/metering/screen_metering.dart | 7 +- .../caffeine/widget_list_tile_caffeine.dart | 2 + .../haptics/widget_list_tile_haptics.dart | 2 + .../bloc_list_tile_volume_actions.dart | 20 +++++ .../provider_list_tile_volume_actions.dart | 19 +++++ .../widget_list_tile_volume_actions.dart | 22 +++++ .../widget_settings_section_general.dart | 12 ++- .../widget_list_tile_dynamic_color.dart | 2 + lib/screens/settings/flow_settings.dart | 2 + lib/screens/settings/screen_settings.dart | 21 ++++- pubspec.yaml | 1 + resources/social_preview.png | Bin 0 -> 48068 bytes test/data/light_sensor_service_test.dart | 35 +++++++- test/data/volume_events_service_test.dart | 73 +++++++++++++++++ test/screens/metering/bloc_metering_test.dart | 76 +++++++++++++++++- .../bloc_communication_metering_test.dart | 26 ++++++ .../camera/bloc_container_camera_test.dart | 28 ++++++- .../bloc_container_light_sensor_test.dart | 63 +++++++++++++++ 41 files changed, 758 insertions(+), 124 deletions(-) create mode 100644 lib/data/models/volume_action.dart create mode 100644 lib/data/volume_events_service.dart create mode 100644 lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart create mode 100644 lib/screens/settings/components/general/components/volume_actions/bloc_list_tile_volume_actions.dart create mode 100644 lib/screens/settings/components/general/components/volume_actions/provider_list_tile_volume_actions.dart create mode 100644 lib/screens/settings/components/general/components/volume_actions/widget_list_tile_volume_actions.dart create mode 100644 resources/social_preview.png create mode 100644 test/data/volume_events_service_test.dart diff --git a/README.md b/README.md index c801a9c..0b4dae8 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,48 @@ -

- -

-

- Material Lightmeter -

+ # Table of contents - [Table of contents](#table-of-contents) - [Backstory](#backstory) -- [Legacy features](#legacy-features) +- [Screenshots](#screenshots) - [Build](#build) - [Contribution](#contribution) +- [iOS Limitations](#ios-limitations) # Backstory Some time ago I've started developing the [Material Lightmeter](https://play.google.com/store/apps/details?id=com.vodemn.lightmeter&hl=en&gl=US) app. Unfortunately, the last update of this app was almost a year prior to creation of this repo. So after reading some positive review on Google Play saying that "this is an excellent app, too bad it is no longer updated", I've decided to make an update and also make this app open source. Maybe someone sometime will decide to contribute to this project. -But as the existing repo contained some sensitive data, that I've pushed due to lack of experience, I had to make a new one. And if creating a new repo, why not rewrite the app from scratch?) +But as the existing repo contained some sensitive data, that I've pushed due to lack of experience, I had to make a new one. And if creating a new repo, why not rewrite the app from scratch? Without further delay behold my new Lightmeter app inspired by Material You (a.k.a. M3) -# Legacy features +# Screenshots -The list of features that the old lightmeter app has and that have to be implemeneted in the M3 lightmeter. +

+ + + + + +

-### Metering -- [x] ISO selecting -- [x] Reciprocity for different films -- [x] Reflected light metering -- [x] Incident light metering - -### Adjust -- [x] Light sources EV calibration -- [ ] Customizable aperture range -- [ ] Customizable shutter speed range -- [x] ND filter select - -### General -- [x] Caffeine -- [x] Vibration -- [ ] Volume button actions - -### Theme -- [x] Dark theme -- [x] Picking primary color -- [x] Russian language - -## Build +# Build As part of this project is private, you will be able to run this app from the _main_dev.dart_ file (i.e. --flavor dev). Also to avoid fatal errors the _main_prod.dart_ file is excluded from analysis. -## Contribution +# Contribution To report a bug or suggest a new feature open a new [issue](https://github.com/vodemn/m3_lightmeter/issues). In case you want to help develop this project you need to follow this [style guide](doc/style_guide.md). + +# iOS Limitations + +A list of features, that Android version of the app has and that iOS does not. + +## Incident light metering +Apple does not provide API for reading Lux stream form the ambient light sensor. Lux can be calculated based on front camera image stream, but this would be a reflected light. So there is no way incident light metering can be implemented on iOS. + +## Volume buttons action +This can be [implemented](https://stackoverflow.com/questions/70161271/ios-override-hardware-volume-buttons-same-as-zello) but the app will be rejected due to [2.5.9](https://developer.apple.com/app-store/review/guidelines/#software-requirements) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/vodemn/lightmeter/MainActivity.kt b/android/app/src/main/kotlin/com/vodemn/lightmeter/MainActivity.kt index ea040d6..af458c4 100644 --- a/android/app/src/main/kotlin/com/vodemn/lightmeter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/vodemn/lightmeter/MainActivity.kt @@ -1,13 +1,22 @@ package com.vodemn.lightmeter import android.os.Bundle +import android.view.KeyEvent import android.view.WindowManager import androidx.core.view.WindowCompat import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { + private lateinit var keepScreenOnChannel: MethodChannel + private lateinit var volumeHandlingChannel: MethodChannel + private lateinit var volumeEventChannel: EventChannel + private var volumeEventsEmitter: EventSink? = null + private var handleVolume = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) @@ -15,10 +24,11 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel( + keepScreenOnChannel = MethodChannel( flutterEngine.dartExecutor.binaryMessenger, "com.vodemn.lightmeter/keepScreenOn" - ).setMethodCallHandler { call, result -> + ) + keepScreenOnChannel.setMethodCallHandler { call, result -> when (call.method) { "isKeepScreenOn" -> result.success((window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0) "setKeepScreenOn" -> { @@ -33,5 +43,53 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } + + volumeHandlingChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + "com.vodemn.lightmeter/volumeHandling" + ) + volumeHandlingChannel.setMethodCallHandler { call, result -> + when (call.method) { + "setVolumeHandling" -> { + handleVolume = call.arguments as Boolean + result.success(handleVolume) + } + else -> result.notImplemented() + } + } + + volumeEventChannel = EventChannel( + flutterEngine.dartExecutor.binaryMessenger, + "com.vodemn.lightmeter/volumeEvents" + ) + volumeEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(listener: Any?, eventSink: EventSink) { + volumeEventsEmitter = eventSink + } + override fun onCancel(listener: Any?) { + volumeEventsEmitter = null + } + }) + } + + override fun onDestroy() { + keepScreenOnChannel.setMethodCallHandler(null) + volumeHandlingChannel.setMethodCallHandler(null) + volumeEventChannel.setStreamHandler(null) + super.onDestroy() + } + + override fun onKeyDown(code: Int, event: KeyEvent): Boolean { + return when (val keyCode: Int = event.keyCode) { + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> { + if (handleVolume) { + volumeEventsEmitter?.success(keyCode) + true + } else { + super.onKeyDown(code, event) + } + } + else -> super.onKeyDown(code, event) + } } } diff --git a/lib/data/light_sensor_service.dart b/lib/data/light_sensor_service.dart index 69b55c5..c837dde 100644 --- a/lib/data/light_sensor_service.dart +++ b/lib/data/light_sensor_service.dart @@ -1,9 +1,15 @@ import 'package:light_sensor/light_sensor.dart'; +import 'package:platform/platform.dart'; class LightSensorService { - const LightSensorService(); + final LocalPlatform localPlatform; + + const LightSensorService(this.localPlatform); Future hasSensor() async { + if (!localPlatform.isAndroid) { + return false; + } try { return await LightSensor.hasSensor ?? false; } catch (_) { @@ -11,5 +17,10 @@ class LightSensorService { } } - Stream luxStream() => LightSensor.lightSensorStream; + Stream luxStream() { + if (!localPlatform.isAndroid) { + return const Stream.empty(); + } + return LightSensor.lightSensorStream; + } } diff --git a/lib/data/models/volume_action.dart b/lib/data/models/volume_action.dart new file mode 100644 index 0000000..b28f9dc --- /dev/null +++ b/lib/data/models/volume_action.dart @@ -0,0 +1,3 @@ +enum VolumeAction { shutter, none } + +enum VolumeKey { up, down } diff --git a/lib/data/shared_prefs_service.dart b/lib/data/shared_prefs_service.dart index 8fbb6af..76f9045 100644 --- a/lib/data/shared_prefs_service.dart +++ b/lib/data/shared_prefs_service.dart @@ -6,6 +6,7 @@ import 'package:lightmeter/data/models/film.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/data/models/supported_locale.dart'; import 'package:lightmeter/data/models/theme_type.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -22,6 +23,7 @@ class UserPreferencesService { static const caffeineKey = "caffeine"; static const hapticsKey = "haptics"; + static const volumeActionKey = "volumeAction"; static const localeKey = "locale"; static const themeTypeKey = "themeType"; @@ -82,9 +84,6 @@ class UserPreferencesService { EvSourceType.values[_sharedPreferences.getInt(evSourceTypeKey) ?? 0]; set evSourceType(EvSourceType value) => _sharedPreferences.setInt(evSourceTypeKey, value.index); - bool get caffeine => _sharedPreferences.getBool(caffeineKey) ?? false; - set caffeine(bool value) => _sharedPreferences.setBool(caffeineKey, value); - StopType get stopType => StopType.values[_sharedPreferences.getInt(stopTypeKey) ?? 2]; set stopType(StopType value) => _sharedPreferences.setInt(stopTypeKey, value.index); @@ -105,9 +104,19 @@ class UserPreferencesService { set meteringScreenLayout(MeteringScreenLayoutConfig value) => _sharedPreferences.setString(meteringScreenLayoutKey, json.encode(value.toJson())); + bool get caffeine => _sharedPreferences.getBool(caffeineKey) ?? false; + set caffeine(bool value) => _sharedPreferences.setBool(caffeineKey, value); + bool get haptics => _sharedPreferences.getBool(hapticsKey) ?? true; set haptics(bool value) => _sharedPreferences.setBool(hapticsKey, value); + VolumeAction get volumeAction => VolumeAction.values.firstWhere( + (e) => e.toString() == _sharedPreferences.getString(volumeActionKey), + orElse: () => VolumeAction.shutter, + ); + set volumeAction(VolumeAction value) => + _sharedPreferences.setString(volumeActionKey, value.toString()); + SupportedLocale get locale => SupportedLocale.values.firstWhere( (e) => e.toString() == _sharedPreferences.getString(localeKey), orElse: () => SupportedLocale.en, diff --git a/lib/data/volume_events_service.dart b/lib/data/volume_events_service.dart new file mode 100644 index 0000000..d57936a --- /dev/null +++ b/lib/data/volume_events_service.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:platform/platform.dart'; + +class VolumeEventsService { + final LocalPlatform localPlatform; + + @visibleForTesting + static const volumeHandlingChannel = MethodChannel("com.vodemn.lightmeter/volumeHandling"); + + @visibleForTesting + static const volumeEventsChannel = EventChannel("com.vodemn.lightmeter/volumeEvents"); + + const VolumeEventsService(this.localPlatform); + + /// If set to `false` we allow system to handle key events. + /// Returns current status of volume handling. + Future setVolumeHandling(bool enableHandling) async { + if (!localPlatform.isAndroid) { + return false; + } + return volumeHandlingChannel + .invokeMethod("setVolumeHandling", enableHandling) + .then((value) => value!); + } + + /// Emits new events on + /// KEYCODE_VOLUME_UP = 24; + /// KEYCODE_VOLUME_DOWN = 25; + /// pressed + Stream volumeButtonsEventStream() { + if (!localPlatform.isAndroid) { + return const Stream.empty(); + } + return volumeEventsChannel + .receiveBroadcastStream() + .cast() + .where((event) => event == 24 || event == 25); + } +} diff --git a/lib/interactors/metering_interactor.dart b/lib/interactors/metering_interactor.dart index ca94f8e..34b03ec 100644 --- a/lib/interactors/metering_interactor.dart +++ b/lib/interactors/metering_interactor.dart @@ -1,12 +1,12 @@ -import 'dart:io'; - import 'package:app_settings/app_settings.dart'; import 'package:lightmeter/data/caffeine_service.dart'; import 'package:lightmeter/data/haptics_service.dart'; import 'package:lightmeter/data/light_sensor_service.dart'; import 'package:lightmeter/data/models/film.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; import 'package:lightmeter/data/permissions_service.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -16,6 +16,7 @@ class MeteringInteractor { final HapticsService _hapticsService; final PermissionsService _permissionsService; final LightSensorService _lightSensorService; + final VolumeEventsService _volumeEventsService; MeteringInteractor( this._userPreferencesService, @@ -23,10 +24,13 @@ class MeteringInteractor { this._hapticsService, this._permissionsService, this._lightSensorService, + this._volumeEventsService, ) { if (_userPreferencesService.caffeine) { _caffeineService.keepScreenOn(true); } + _volumeEventsService + .setVolumeHandling(_userPreferencesService.volumeAction != VolumeAction.none); } double get cameraEvCalibration => _userPreferencesService.cameraEvCalibration; @@ -42,6 +46,8 @@ class MeteringInteractor { Film get film => _userPreferencesService.film; set film(Film value) => _userPreferencesService.film = value; + VolumeAction get volumeAction => _userPreferencesService.volumeAction; + /// Executes vibration if haptics are enabled in settings Future quickVibration() async { if (_userPreferencesService.haptics) await _hapticsService.quickVibration(); @@ -73,13 +79,7 @@ class MeteringInteractor { AppSettings.openAppSettings(); } - Future hasAmbientLightSensor() async { - if (Platform.isAndroid) { - return _lightSensorService.hasSensor(); - } else { - return false; - } - } + Future hasAmbientLightSensor() async => _lightSensorService.hasSensor(); Stream luxStream() => _lightSensorService.luxStream(); } diff --git a/lib/interactors/settings_interactor.dart b/lib/interactors/settings_interactor.dart index db99a7a..4eeb8b9 100644 --- a/lib/interactors/settings_interactor.dart +++ b/lib/interactors/settings_interactor.dart @@ -1,16 +1,20 @@ import 'package:lightmeter/data/caffeine_service.dart'; import 'package:lightmeter/data/haptics_service.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; class SettingsInteractor { final UserPreferencesService _userPreferencesService; final CaffeineService _caffeineService; final HapticsService _hapticsService; + final VolumeEventsService _volumeEventsService; const SettingsInteractor( this._userPreferencesService, this._caffeineService, this._hapticsService, + this._volumeEventsService, ); double get cameraEvCalibration => _userPreferencesService.cameraEvCalibration; @@ -27,6 +31,20 @@ class SettingsInteractor { }); } + Future disableVolumeHandling() async { + await _volumeEventsService.setVolumeHandling(false); + } + Future restoreVolumeHandling() async { + await _volumeEventsService + .setVolumeHandling(_userPreferencesService.volumeAction != VolumeAction.none); + } + + VolumeAction get volumeAction => _userPreferencesService.volumeAction; + Future setVolumeAction(VolumeAction value) async { + await _volumeEventsService.setVolumeHandling(value != VolumeAction.none); + _userPreferencesService.volumeAction = value; + } + bool get isHapticsEnabled => _userPreferencesService.haptics; void enableHaptics(bool enable) { _userPreferencesService.haptics = enable; diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index c404d33..39700b8 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -56,6 +56,7 @@ "general": "General", "keepScreenOn": "Keep screen on", "haptics": "Haptics", + "volumeKeysAction": "Shutter by volume keys", "language": "Language", "chooseLanguage": "Choose language", "theme": "Theme", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 5102a59..5957d0d 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -56,6 +56,7 @@ "general": "Général", "keepScreenOn": "Garder l'écran allumé", "haptics": "Haptiques", + "volumeKeysAction": "Obturateur par boutons de volume", "language": "Langue", "chooseLanguage": "Choisissez la langue", "theme": "Thème", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index ca746dd..f6b3833 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -56,6 +56,7 @@ "general": "Общие", "keepScreenOn": "Запрет блокировки", "haptics": "Вибрация", + "volumeKeysAction": "Затвор по кнопкам громкости", "language": "Язык", "chooseLanguage": "Выберите язык", "theme": "Тема", diff --git a/lib/main_prod.dart b/lib/main_prod.dart index bf02374..a47d421 100644 --- a/lib/main_prod.dart +++ b/lib/main_prod.dart @@ -6,6 +6,5 @@ import 'package:lightmeter/firebase.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await initializeFirebase(); - runApp(const Application(Environment.prod())); } diff --git a/lib/providers.dart b/lib/providers.dart index 7805e69..d7907ab 100644 --- a/lib/providers.dart +++ b/lib/providers.dart @@ -1,11 +1,10 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:lightmeter/data/caffeine_service.dart'; import 'package:lightmeter/data/haptics_service.dart'; import 'package:lightmeter/data/light_sensor_service.dart'; import 'package:lightmeter/data/permissions_service.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; import 'package:lightmeter/environment.dart'; import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/providers/ev_source_type_provider.dart'; @@ -14,6 +13,7 @@ import 'package:lightmeter/providers/stop_type_provider.dart'; import 'package:lightmeter/providers/supported_locale_provider.dart'; import 'package:lightmeter/providers/theme_provider.dart'; import 'package:lightmeter/utils/inherited_generics.dart'; +import 'package:platform/platform.dart'; import 'package:shared_preferences/shared_preferences.dart'; class LightmeterProviders extends StatelessWidget { @@ -27,7 +27,7 @@ class LightmeterProviders extends StatelessWidget { return FutureBuilder( future: Future.wait([ SharedPreferences.getInstance(), - if (Platform.isAndroid) const LightSensorService().hasSensor() else Future.value(false), + const LightSensorService(LocalPlatform()).hasSensor(), ]), builder: (_, snapshot) { if (snapshot.data != null) { @@ -36,21 +36,24 @@ class LightmeterProviders extends StatelessWidget { child: InheritedWidgetBase( data: UserPreferencesService(snapshot.data![0] as SharedPreferences), child: InheritedWidgetBase( - data: const LightSensorService(), + data: const LightSensorService(LocalPlatform()), child: InheritedWidgetBase( data: const CaffeineService(), child: InheritedWidgetBase( data: const HapticsService(), - child: InheritedWidgetBase( - data: const PermissionsService(), - child: MeteringScreenLayoutProvider( - child: StopTypeProvider( - child: EquipmentProfileProvider( - child: EvSourceTypeProvider( - child: SupportedLocaleProvider( - child: ThemeProvider( - child: Builder( - builder: (context) => builder(context, true), + child: InheritedWidgetBase( + data: const VolumeEventsService(LocalPlatform()), + child: InheritedWidgetBase( + data: const PermissionsService(), + child: MeteringScreenLayoutProvider( + child: StopTypeProvider( + child: EquipmentProfileProvider( + child: EvSourceTypeProvider( + child: SupportedLocaleProvider( + child: ThemeProvider( + child: Builder( + builder: (context) => builder(context, true), + ), ), ), ), diff --git a/lib/screens/metering/bloc_metering.dart b/lib/screens/metering/bloc_metering.dart index 042991a..8e4a2f6 100644 --- a/lib/screens/metering/bloc_metering.dart +++ b/lib/screens/metering/bloc_metering.dart @@ -4,23 +4,27 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/film.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_events; import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; +import 'package:lightmeter/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart'; import 'package:lightmeter/screens/metering/event_metering.dart'; import 'package:lightmeter/screens/metering/state_metering.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringBloc extends Bloc { final MeteringInteractor _meteringInteractor; + final VolumeKeysNotifier _volumeKeysNotifier; final MeteringCommunicationBloc _communicationBloc; late final StreamSubscription _communicationSubscription; MeteringBloc( this._meteringInteractor, + this._volumeKeysNotifier, this._communicationBloc, ) : super( MeteringDataState( @@ -31,6 +35,7 @@ class MeteringBloc extends Bloc { isMetering: false, ), ) { + _volumeKeysNotifier.addListener(onVolumeKey); _communicationSubscription = _communicationBloc.stream .where((state) => state is communication_states.ScreenState) .map((state) => state as communication_states.ScreenState) @@ -43,6 +48,8 @@ class MeteringBloc extends Bloc { on(_onMeasure, transformer: droppable()); on(_onMeasured); on(_onMeasureError); + on(_onSettingsOpened); + on(_onSettingsClosed); } @override @@ -64,6 +71,7 @@ class MeteringBloc extends Bloc { @override Future close() async { + _volumeKeysNotifier.removeListener(onVolumeKey); await _communicationSubscription.cancel(); return super.close(); } @@ -220,4 +228,19 @@ class MeteringBloc extends Bloc { ), ); } + + @visibleForTesting + void onVolumeKey() { + if (_meteringInteractor.volumeAction == VolumeAction.shutter) { + add(const MeasureEvent()); + } + } + + void _onSettingsOpened(SettingsOpenedEvent _, Emitter __) { + _communicationBloc.add(const communication_events.SettingsOpenedEvent()); + } + + void _onSettingsClosed(SettingsClosedEvent _, Emitter __) { + _communicationBloc.add(const communication_events.SettingsClosedEvent()); + } } diff --git a/lib/screens/metering/communication/bloc_communication_metering.dart b/lib/screens/metering/communication/bloc_communication_metering.dart index 1c54dc3..11ebe37 100644 --- a/lib/screens/metering/communication/bloc_communication_metering.dart +++ b/lib/screens/metering/communication/bloc_communication_metering.dart @@ -11,5 +11,7 @@ class MeteringCommunicationBloc on((_, emit) => emit(MeasureState())); on((event, emit) => emit(MeteringInProgressState(event.ev100))); on((event, emit) => emit(MeteringEndedState(event.ev100))); + on((_, emit) => emit(const SettingsOpenedState())); + on((_, emit) => emit(const SettingsClosedState())); } } diff --git a/lib/screens/metering/communication/event_communication_metering.dart b/lib/screens/metering/communication/event_communication_metering.dart index ac63b57..c7e0fd8 100644 --- a/lib/screens/metering/communication/event_communication_metering.dart +++ b/lib/screens/metering/communication/event_communication_metering.dart @@ -47,3 +47,11 @@ class MeteringEndedEvent extends MeasuredEvent { @override int get hashCode => Object.hash(ev100, runtimeType); } + +class SettingsOpenedEvent extends ScreenEvent { + const SettingsOpenedEvent(); +} + +class SettingsClosedEvent extends ScreenEvent { + const SettingsClosedEvent(); +} diff --git a/lib/screens/metering/communication/state_communication_metering.dart b/lib/screens/metering/communication/state_communication_metering.dart index 2923cf1..2d3991e 100644 --- a/lib/screens/metering/communication/state_communication_metering.dart +++ b/lib/screens/metering/communication/state_communication_metering.dart @@ -51,3 +51,11 @@ class MeteringEndedState extends MeasuredState { @override int get hashCode => Object.hash(ev100, runtimeType); } + +class SettingsOpenedState extends SourceState { + const SettingsOpenedState(); +} + +class SettingsClosedState extends SourceState { + const SettingsClosedState(); +} 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 2bb8ed1..7f00256 100644 --- a/lib/screens/metering/components/camera_container/bloc_container_camera.dart +++ b/lib/screens/metering/components/camera_container/bloc_container_camera.dart @@ -23,6 +23,7 @@ import 'package:lightmeter/utils/log_2.dart'; class CameraContainerBloc extends EvSourceBlocBase { final MeteringInteractor _meteringInteractor; late final _WidgetsBindingObserver _observer; + CameraController? _cameraController; static const _maxZoom = 7.0; @@ -36,6 +37,8 @@ class CameraContainerBloc extends EvSourceBlocBase _onDeinitialize(DeinitializeEvent _, Emitter emit) async { - emit(const CameraLoadingState()); - unawaited(_cameraController?.dispose().then((_) => _cameraController = null)); + emit(const CameraInitState()); + communicationBloc.add(communication_event.MeteringEndedEvent(_ev100)); + await _cameraController?.dispose().then((_) => _cameraController = null); } Future _onZoomChanged(ZoomChangedEvent event, Emitter emit) async { - if (_cameraController != null) { + if (_cameraController != null && + event.value >= _zoomRange!.start && + event.value <= _zoomRange!.end) { _cameraController!.setZoomLevel(event.value); _currentZoom = event.value; _emitActiveState(emit); @@ -215,13 +229,15 @@ class CameraContainerBloc extends EvSourceBlocBase _appLifecycleStateObserver(AppLifecycleState state) async { - switch (state) { - case AppLifecycleState.resumed: - add(const InitializeEvent()); - case AppLifecycleState.paused: - case AppLifecycleState.detached: - add(const DeinitializeEvent()); - default: + if (!_settingsOpened) { + switch (state) { + case AppLifecycleState.resumed: + add(const InitializeEvent()); + case AppLifecycleState.paused: + case AppLifecycleState.detached: + add(const DeinitializeEvent()); + default: + } } } } diff --git a/lib/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart b/lib/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart index d0e4031..23cd796 100644 --- a/lib/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart +++ b/lib/screens/metering/components/light_sensor_container/bloc_container_light_sensor.dart @@ -25,37 +25,44 @@ class LightSensorContainerBloc communicationBloc, const LightSensorContainerState(null), ) { + on(_onStartLuxMeteringEvent); on(_onLuxMeteringEvent); + on(_onCancelLuxMeteringEvent); } @override void onCommunicationState(communication_states.SourceState communicationState) { - if (communicationState is communication_states.MeasureState) { - if (_luxSubscriptions == null) { - _startMetering(); - } else { - _cancelMetering(); - } + switch (communicationState) { + case communication_states.MeasureState(): + if (_luxSubscriptions == null) { + add(const StartLuxMeteringEvent()); + } else { + add(const CancelLuxMeteringEvent()); + } + case communication_states.SettingsOpenedState(): + add(const CancelLuxMeteringEvent()); + default: } } @override Future close() async { - _cancelMetering(); + communicationBloc.add(communication_event.MeteringEndedEvent(state.ev100)); + _luxSubscriptions?.cancel().then((_) => _luxSubscriptions = null); return super.close(); } + void _onStartLuxMeteringEvent(StartLuxMeteringEvent event, _) { + _luxSubscriptions = _meteringInteractor.luxStream().listen((lux) => add(LuxMeteringEvent(lux))); + } + void _onLuxMeteringEvent(LuxMeteringEvent event, Emitter emit) { final ev100 = log2(event.lux.toDouble() / 2.5) + _meteringInteractor.lightSensorEvCalibration; emit(LightSensorContainerState(ev100)); communicationBloc.add(communication_event.MeteringInProgressEvent(ev100)); } - void _startMetering() { - _luxSubscriptions = _meteringInteractor.luxStream().listen((lux) => add(LuxMeteringEvent(lux))); - } - - void _cancelMetering() { + void _onCancelLuxMeteringEvent(CancelLuxMeteringEvent event, _) { communicationBloc.add(communication_event.MeteringEndedEvent(state.ev100)); _luxSubscriptions?.cancel().then((_) => _luxSubscriptions = null); } diff --git a/lib/screens/metering/components/light_sensor_container/event_container_light_sensor.dart b/lib/screens/metering/components/light_sensor_container/event_container_light_sensor.dart index 8db83b3..bffcadd 100644 --- a/lib/screens/metering/components/light_sensor_container/event_container_light_sensor.dart +++ b/lib/screens/metering/components/light_sensor_container/event_container_light_sensor.dart @@ -2,8 +2,16 @@ abstract class LightSensorContainerEvent { const LightSensorContainerEvent(); } +class StartLuxMeteringEvent extends LightSensorContainerEvent { + const StartLuxMeteringEvent(); +} + class LuxMeteringEvent extends LightSensorContainerEvent { final int lux; const LuxMeteringEvent(this.lux); } + +class CancelLuxMeteringEvent extends LightSensorContainerEvent { + const CancelLuxMeteringEvent(); +} diff --git a/lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart b/lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart new file mode 100644 index 0000000..df64fdf --- /dev/null +++ b/lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; + +class VolumeKeysNotifier extends ChangeNotifier with RouteAware { + final VolumeEventsService volumeEventsService; + late final StreamSubscription _volumeKeysSubscription; + VolumeKey _value = VolumeKey.up; + + VolumeKeysNotifier(this.volumeEventsService) { + _volumeKeysSubscription = volumeEventsService + .volumeButtonsEventStream() + .map((event) => event == 24 ? VolumeKey.up : VolumeKey.down) + .listen((event) { + value = event; + }); + } + + VolumeKey get value => _value; + set value(VolumeKey newValue) { + _value = newValue; + notifyListeners(); + } + + @override + Future dispose() async { + await _volumeKeysSubscription.cancel(); + super.dispose(); + } +} diff --git a/lib/screens/metering/event_metering.dart b/lib/screens/metering/event_metering.dart index e12ba35..852b5e4 100644 --- a/lib/screens/metering/event_metering.dart +++ b/lib/screens/metering/event_metering.dart @@ -45,3 +45,11 @@ class MeasureErrorEvent extends MeteringEvent { const MeasureErrorEvent({required this.isMetering}); } + +class SettingsOpenedEvent extends MeteringEvent { + const SettingsOpenedEvent(); +} + +class SettingsClosedEvent extends MeteringEvent { + const SettingsClosedEvent(); +} diff --git a/lib/screens/metering/flow_metering.dart b/lib/screens/metering/flow_metering.dart index ac6cb0d..780f537 100644 --- a/lib/screens/metering/flow_metering.dart +++ b/lib/screens/metering/flow_metering.dart @@ -5,9 +5,11 @@ import 'package:lightmeter/data/haptics_service.dart'; import 'package:lightmeter/data/light_sensor_service.dart'; import 'package:lightmeter/data/permissions_service.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/bloc_metering.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; +import 'package:lightmeter/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart'; import 'package:lightmeter/screens/metering/screen_metering.dart'; import 'package:lightmeter/utils/inherited_generics.dart'; @@ -28,18 +30,23 @@ class _MeteringFlowState extends State { context.get(), context.get(), context.get(), + context.get(), ), - child: MultiBlocProvider( - providers: [ - BlocProvider(create: (_) => MeteringCommunicationBloc()), - BlocProvider( - create: (context) => MeteringBloc( - context.get(), - context.read(), + child: InheritedWidgetBase( + data: VolumeKeysNotifier(context.get()), + child: MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => MeteringCommunicationBloc()), + BlocProvider( + create: (context) => MeteringBloc( + context.get(), + context.get(), + context.read(), + ), ), - ), - ], - child: const MeteringScreen(), + ], + child: const MeteringScreen(), + ), ), ); } diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart index 134d789..4fb1e85 100644 --- a/lib/screens/metering/screen_metering.dart +++ b/lib/screens/metering/screen_metering.dart @@ -50,7 +50,12 @@ class MeteringScreen extends StatelessWidget { ? EvSourceTypeProvider.of(context).toggleType : null, onMeasure: () => context.read().add(const MeasureEvent()), - onSettings: () => Navigator.pushNamed(context, 'settings'), + onSettings: () { + context.read().add(const SettingsOpenedEvent()); + Navigator.pushNamed(context, 'settings').then((value) { + context.read().add(const SettingsClosedEvent()); + }); + }, ), ), ], diff --git a/lib/screens/settings/components/general/components/caffeine/widget_list_tile_caffeine.dart b/lib/screens/settings/components/general/components/caffeine/widget_list_tile_caffeine.dart index 7de7450..ee0483d 100644 --- a/lib/screens/settings/components/general/components/caffeine/widget_list_tile_caffeine.dart +++ b/lib/screens/settings/components/general/components/caffeine/widget_list_tile_caffeine.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/settings/components/general/components/caffeine/bloc_list_tile_caffeine.dart'; @@ -15,6 +16,7 @@ class CaffeineListTile extends StatelessWidget { title: Text(S.of(context).keepScreenOn), value: state, onChanged: context.read().onCaffeineChanged, + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), ), ); } diff --git a/lib/screens/settings/components/general/components/haptics/widget_list_tile_haptics.dart b/lib/screens/settings/components/general/components/haptics/widget_list_tile_haptics.dart index a33b1ce..ec7ec60 100644 --- a/lib/screens/settings/components/general/components/haptics/widget_list_tile_haptics.dart +++ b/lib/screens/settings/components/general/components/haptics/widget_list_tile_haptics.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/settings/components/general/components/haptics/bloc_list_tile_haptics.dart'; @@ -15,6 +16,7 @@ class HapticsListTile extends StatelessWidget { title: Text(S.of(context).haptics), value: state, onChanged: context.read().onHapticsChanged, + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), ), ); } diff --git a/lib/screens/settings/components/general/components/volume_actions/bloc_list_tile_volume_actions.dart b/lib/screens/settings/components/general/components/volume_actions/bloc_list_tile_volume_actions.dart new file mode 100644 index 0000000..a8b8e3e --- /dev/null +++ b/lib/screens/settings/components/general/components/volume_actions/bloc_list_tile_volume_actions.dart @@ -0,0 +1,20 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; +import 'package:lightmeter/interactors/settings_interactor.dart'; + +class VolumeActionsListTileBloc extends Cubit { + final SettingsInteractor _settingsInteractor; + + VolumeActionsListTileBloc( + this._settingsInteractor, + ) : super(_settingsInteractor.volumeAction == VolumeAction.shutter); + + void onVolumeActionChanged(bool value) { + _settingsInteractor.setVolumeAction(value ? VolumeAction.shutter : VolumeAction.none); + + // while in settings we allow system to handle volume + // so that volume keys action works only when necessary - on the metering screen + _settingsInteractor.disableVolumeHandling(); + emit(value); + } +} diff --git a/lib/screens/settings/components/general/components/volume_actions/provider_list_tile_volume_actions.dart b/lib/screens/settings/components/general/components/volume_actions/provider_list_tile_volume_actions.dart new file mode 100644 index 0000000..790ad4f --- /dev/null +++ b/lib/screens/settings/components/general/components/volume_actions/provider_list_tile_volume_actions.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/interactors/settings_interactor.dart'; + +import 'package:lightmeter/screens/settings/components/general/components/volume_actions/bloc_list_tile_volume_actions.dart'; +import 'package:lightmeter/screens/settings/components/general/components/volume_actions/widget_list_tile_volume_actions.dart'; +import 'package:lightmeter/utils/inherited_generics.dart'; + +class VolumeActionsListTileProvider extends StatelessWidget { + const VolumeActionsListTileProvider({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => VolumeActionsListTileBloc(context.get()), + child: const VolumeActionsListTile(), + ); + } +} diff --git a/lib/screens/settings/components/general/components/volume_actions/widget_list_tile_volume_actions.dart b/lib/screens/settings/components/general/components/volume_actions/widget_list_tile_volume_actions.dart new file mode 100644 index 0000000..0502b60 --- /dev/null +++ b/lib/screens/settings/components/general/components/volume_actions/widget_list_tile_volume_actions.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/components/general/components/volume_actions/bloc_list_tile_volume_actions.dart'; + +class VolumeActionsListTile extends StatelessWidget { + const VolumeActionsListTile({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => SwitchListTile( + secondary: const Icon(Icons.volume_up), + title: Text(S.of(context).volumeKeysAction), + value: state, + onChanged: context.read().onVolumeActionChanged, + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), + ), + ); + } +} diff --git a/lib/screens/settings/components/general/widget_settings_section_general.dart b/lib/screens/settings/components/general/widget_settings_section_general.dart index b873d3f..bc123d7 100644 --- a/lib/screens/settings/components/general/widget_settings_section_general.dart +++ b/lib/screens/settings/components/general/widget_settings_section_general.dart @@ -1,8 +1,11 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/screens/settings/components/general/components/caffeine/provider_list_tile_caffeine.dart'; import 'package:lightmeter/screens/settings/components/general/components/haptics/provider_list_tile_haptics.dart'; import 'package:lightmeter/screens/settings/components/general/components/language/widget_list_tile_language.dart'; +import 'package:lightmeter/screens/settings/components/general/components/volume_actions/provider_list_tile_volume_actions.dart'; import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart'; class GeneralSettingsSection extends StatelessWidget { @@ -12,10 +15,11 @@ class GeneralSettingsSection extends StatelessWidget { Widget build(BuildContext context) { return SettingsSection( title: S.of(context).general, - children: const [ - CaffeineListTileProvider(), - HapticsListTileProvider(), - LanguageListTile(), + children: [ + const CaffeineListTileProvider(), + const HapticsListTileProvider(), + if (Platform.isAndroid) const VolumeActionsListTileProvider(), + const LanguageListTile(), ], ); } diff --git a/lib/screens/settings/components/theme/components/dynamic_color/widget_list_tile_dynamic_color.dart b/lib/screens/settings/components/theme/components/dynamic_color/widget_list_tile_dynamic_color.dart index f15186e..d230f63 100644 --- a/lib/screens/settings/components/theme/components/dynamic_color/widget_list_tile_dynamic_color.dart +++ b/lib/screens/settings/components/theme/components/dynamic_color/widget_list_tile_dynamic_color.dart @@ -2,6 +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/theme_provider.dart'; +import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/utils/inherited_generics.dart'; class DynamicColorListTile extends StatelessWidget { @@ -14,6 +15,7 @@ class DynamicColorListTile extends StatelessWidget { title: Text(S.of(context).dynamicColor), value: context.listen() == DynamicColorState.enabled, onChanged: ThemeProvider.of(context).enableDynamicColor, + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingM), ); } } diff --git a/lib/screens/settings/flow_settings.dart b/lib/screens/settings/flow_settings.dart index 5b9d5b3..3195c25 100644 --- a/lib/screens/settings/flow_settings.dart +++ b/lib/screens/settings/flow_settings.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/data/caffeine_service.dart'; import 'package:lightmeter/data/haptics_service.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; import 'package:lightmeter/interactors/settings_interactor.dart'; import 'package:lightmeter/screens/settings/screen_settings.dart'; import 'package:lightmeter/utils/inherited_generics.dart'; @@ -16,6 +17,7 @@ class SettingsFlow extends StatelessWidget { context.get(), context.get(), context.get(), + context.get(), ), child: const SettingsScreen(), ); diff --git a/lib/screens/settings/screen_settings.dart b/lib/screens/settings/screen_settings.dart index 2adf906..3c745bd 100644 --- a/lib/screens/settings/screen_settings.dart +++ b/lib/screens/settings/screen_settings.dart @@ -1,14 +1,33 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/interactors/settings_interactor.dart'; import 'package:lightmeter/screens/settings/components/about/widget_settings_section_about.dart'; import 'package:lightmeter/screens/settings/components/general/widget_settings_section_general.dart'; import 'package:lightmeter/screens/settings/components/metering/widget_settings_section_metering.dart'; import 'package:lightmeter/screens/settings/components/theme/widget_settings_section_theme.dart'; import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; +import 'package:lightmeter/utils/inherited_generics.dart'; -class SettingsScreen extends StatelessWidget { +class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + context.get().disableVolumeHandling(); + } + + @override + void deactivate() { + context.get().restoreVolumeHandling(); + super.deactivate(); + } + @override Widget build(BuildContext context) { return ScaffoldMessenger( diff --git a/pubspec.yaml b/pubspec.yaml index 8a230c8..bd72758 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: material_color_utilities: 0.2.0 package_info_plus: 4.0.1 permission_handler: 10.2.0 + platform: 3.1.0 shared_preferences: 2.1.1 url_launcher: 6.1.11 uuid: 3.0.7 diff --git a/resources/social_preview.png b/resources/social_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..90dc77b56de62c65182804b557c98d71bcc3c52b GIT binary patch literal 48068 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ℑXSU($I18-;PS@I%hz` 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 literal 0 HcmV?d00001 diff --git a/test/data/light_sensor_service_test.dart b/test/data/light_sensor_service_test.dart index 1256e18..d29b50f 100644 --- a/test/data/light_sensor_service_test.dart +++ b/test/data/light_sensor_service_test.dart @@ -1,10 +1,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lightmeter/data/light_sensor_service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:platform/platform.dart'; + +class _MockLocalPlatform extends Mock implements LocalPlatform {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); + late _MockLocalPlatform localPlatform; late LightSensorService service; const methodChannel = MethodChannel('system_feature'); @@ -12,7 +17,8 @@ void main() { //const eventChannel = EventChannel('light.eventChannel'); setUp(() { - service = const LightSensorService(); + localPlatform = _MockLocalPlatform(); + service = LightSensorService(localPlatform); }); tearDown(() { @@ -23,7 +29,8 @@ void main() { group( 'hasSensor()', () { - test('true', () async { + test('true - Android', () async { + when(() => localPlatform.isAndroid).thenReturn(true); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -38,7 +45,8 @@ void main() { expectLater(service.hasSensor(), completion(true)); }); - test('false', () async { + test('false - Android', () async { + when(() => localPlatform.isAndroid).thenReturn(true); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -52,7 +60,9 @@ void main() { }); expectLater(service.hasSensor(), completion(false)); }); - test('null', () async { + + test('null - Android', () async { + when(() => localPlatform.isAndroid).thenReturn(true); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, null); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -66,6 +76,23 @@ void main() { }); expectLater(service.hasSensor(), completion(false)); }); + + test('false - iOS', () async { + when(() => localPlatform.isAndroid).thenReturn(false); + expectLater(service.hasSensor(), completion(false)); + }); }, ); + + group('luxStream', () { + // test('Android', () async { + // when(() => localPlatform.isAndroid).thenReturn(true); + // expect(service.luxStream(), const Stream.empty()); + // }); + + test('iOS', () async { + when(() => localPlatform.isAndroid).thenReturn(false); + expect(service.luxStream(), const Stream.empty()); + }); + }); } diff --git a/test/data/volume_events_service_test.dart b/test/data/volume_events_service_test.dart new file mode 100644 index 0000000..f9ef3d6 --- /dev/null +++ b/test/data/volume_events_service_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:platform/platform.dart'; + +class _MockLocalPlatform extends Mock implements LocalPlatform {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockLocalPlatform localPlatform; + late VolumeEventsService service; + + Future? methodCallSuccessHandler(MethodCall methodCall) async { + switch (methodCall.method) { + case "setVolumeHandling": + return methodCall.arguments as bool; + default: + throw UnimplementedError(); + } + } + + setUp(() { + localPlatform = _MockLocalPlatform(); + service = VolumeEventsService(localPlatform); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + VolumeEventsService.volumeHandlingChannel, + methodCallSuccessHandler, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + VolumeEventsService.volumeHandlingChannel, + null, + ); + }); + + group('setVolumeHandling', () { + test('true - Android', () async { + when(() => localPlatform.isAndroid).thenReturn(true); + expectLater(service.setVolumeHandling(true), completion(true)); + }); + + test('true - iOS', () async { + when(() => localPlatform.isAndroid).thenReturn(false); + expectLater(service.setVolumeHandling(true), completion(false)); + }); + + test('false - Android', () async { + when(() => localPlatform.isAndroid).thenReturn(true); + expectLater(service.setVolumeHandling(false), completion(false)); + }); + + test('false - iOS', () async { + when(() => localPlatform.isAndroid).thenReturn(false); + expectLater(service.setVolumeHandling(false), completion(false)); + }); + }); + + group('volumeButtonsEventStream', () { + // test('Android', () async { + // when(() => localPlatform.isAndroid).thenReturn(true); + // expect(service.volumeButtonsEventStream(), const Stream.empty()); + // }); + + test('iOS', () async { + when(() => localPlatform.isAndroid).thenReturn(false); + expect(service.volumeButtonsEventStream(), const Stream.empty()); + }); + }); +} diff --git a/test/screens/metering/bloc_metering_test.dart b/test/screens/metering/bloc_metering_test.dart index 7e87a27..3593c91 100644 --- a/test/screens/metering/bloc_metering_test.dart +++ b/test/screens/metering/bloc_metering_test.dart @@ -1,5 +1,6 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:lightmeter/data/models/film.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/bloc_metering.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; @@ -7,20 +8,24 @@ import 'package:lightmeter/screens/metering/communication/event_communication_me as communication_events; import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; +import 'package:lightmeter/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart'; import 'package:lightmeter/screens/metering/event_metering.dart'; import 'package:lightmeter/screens/metering/state_metering.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; +class _MockMeteringInteractor extends Mock implements MeteringInteractor {} + +class _MockVolumeKeysNotifier extends Mock implements VolumeKeysNotifier {} + class _MockMeteringCommunicationBloc extends MockBloc< communication_events.MeteringCommunicationEvent, communication_states.MeteringCommunicationState> implements MeteringCommunicationBloc {} -class _MockMeteringInteractor extends Mock implements MeteringInteractor {} - void main() { late _MockMeteringInteractor meteringInteractor; + late _MockVolumeKeysNotifier volumeKeysNotifier; late _MockMeteringCommunicationBloc communicationBloc; late MeteringBloc bloc; const iso100 = IsoValue(100, StopType.full); @@ -34,16 +39,19 @@ void main() { when(meteringInteractor.responseVibration).thenAnswer((_) async {}); when(meteringInteractor.errorVibration).thenAnswer((_) async {}); + volumeKeysNotifier = _MockVolumeKeysNotifier(); communicationBloc = _MockMeteringCommunicationBloc(); - + bloc = MeteringBloc( meteringInteractor, + volumeKeysNotifier, communicationBloc, ); }); tearDown(() { bloc.close(); + //volumeKeysNotifier.dispose(); communicationBloc.close(); }); @@ -606,4 +614,66 @@ void main() { ); }, ); + + group( + '`Volume keys shutter action`', + () { + blocTest( + 'Add/remove listener', + build: () => bloc, + verify: (_) { + verify(() => volumeKeysNotifier.addListener(bloc.onVolumeKey)).called(1); + verify(() => volumeKeysNotifier.removeListener(bloc.onVolumeKey)).called(1); + }, + expect: () => [], + ); + + blocTest( + 'onVolumeKey & VolumeAction.shutter', + build: () => bloc, + act: (bloc) async { + bloc.onVolumeKey(); + }, + setUp: () { + when(() => meteringInteractor.volumeAction).thenReturn(VolumeAction.shutter); + }, + verify: (_) {}, + expect: () => [isA()], + ); + + blocTest( + 'onVolumeKey & VolumeAction.none', + build: () => bloc, + act: (bloc) async { + bloc.onVolumeKey(); + }, + setUp: () { + when(() => meteringInteractor.volumeAction).thenReturn(VolumeAction.none); + }, + verify: (_) {}, + expect: () => [], + ); + }, + ); + + group( + '`SettingOpenedEvent`/`SettingsClosedEvent`', + () { + blocTest( + 'Settings opened & closed', + build: () => bloc, + act: (bloc) async { + bloc.add(const SettingsOpenedEvent()); + bloc.add(const SettingsClosedEvent()); + }, + verify: (_) { + verify(() => communicationBloc.add(const communication_events.SettingsOpenedEvent())) + .called(1); + verify(() => communicationBloc.add(const communication_events.SettingsClosedEvent())) + .called(1); + }, + expect: () => [], + ); + }, + ); } diff --git a/test/screens/metering/communication/bloc_communication_metering_test.dart b/test/screens/metering/communication/bloc_communication_metering_test.dart index 604de75..a04aab7 100644 --- a/test/screens/metering/communication/bloc_communication_metering_test.dart +++ b/test/screens/metering/communication/bloc_communication_metering_test.dart @@ -98,4 +98,30 @@ void main() { ); }, ); + + group( + '`SettingsOpenedEvent`/`SettingsClosedEvent`', + () { + blocTest( + 'Multiple consequtive settings events', + build: () => bloc, + act: (bloc) async { + bloc.add(const SettingsOpenedEvent()); + bloc.add(const SettingsOpenedEvent()); + bloc.add(const SettingsOpenedEvent()); + bloc.add(const SettingsClosedEvent()); + bloc.add(const SettingsClosedEvent()); + bloc.add(const SettingsClosedEvent()); + bloc.add(const SettingsOpenedEvent()); + bloc.add(const SettingsClosedEvent()); + }, + expect: () => [ + isA(), + isA(), + isA(), + isA(), + ], + ); + }, + ); } diff --git a/test/screens/metering/components/camera/bloc_container_camera_test.dart b/test/screens/metering/components/camera/bloc_container_camera_test.dart index e720ef5..fa08edf 100644 --- a/test/screens/metering/components/camera/bloc_container_camera_test.dart +++ b/test/screens/metering/components/camera/bloc_container_camera_test.dart @@ -14,12 +14,12 @@ import 'package:lightmeter/screens/metering/components/camera_container/models/c import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart'; import 'package:mocktail/mocktail.dart'; +class _MockMeteringInteractor extends Mock implements MeteringInteractor {} + class _MockMeteringCommunicationBloc extends MockBloc< communication_events.MeteringCommunicationEvent, communication_states.MeteringCommunicationState> implements MeteringCommunicationBloc {} -class _MockMeteringInteractor extends Mock implements MeteringInteractor {} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -310,6 +310,30 @@ void main() { }, expect: () => [ ...initializedStateSequence, + const CameraInitState(), + ...initializedStateSequence, + ], + ); + + blocTest( + 'onCommunicationState', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + }, + build: () => bloc, + act: (bloc) async { + bloc.add(const InitializeEvent()); + await Future.delayed(Duration.zero); + bloc.onCommunicationState(const communication_states.SettingsOpenedState()); + await Future.delayed(Duration.zero); + bloc.onCommunicationState(const communication_states.SettingsClosedState()); + }, + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(2); + }, + expect: () => [ + ...initializedStateSequence, + const CameraInitState(), ...initializedStateSequence, ], ); diff --git a/test/screens/metering/components/light_sensor/bloc_container_light_sensor_test.dart b/test/screens/metering/components/light_sensor/bloc_container_light_sensor_test.dart index f60a79e..70dd31d 100644 --- a/test/screens/metering/components/light_sensor/bloc_container_light_sensor_test.dart +++ b/test/screens/metering/components/light_sensor/bloc_container_light_sensor_test.dart @@ -78,4 +78,67 @@ void main() { ); }, ); + + group( + '`communication_states.SettingsOpenedState()`', + () { + const List luxIterable = [1, 2, 2, 2, 3]; + final List resultList = luxIterable.map((lux) => log2(lux / 2.5)).toList(); + blocTest( + 'Metering is already canceled', + build: () => bloc, + setUp: () { + when(() => meteringInteractor.luxStream()) + .thenAnswer((_) => Stream.fromIterable(luxIterable)); + when(() => meteringInteractor.lightSensorEvCalibration).thenReturn(0.0); + }, + act: (bloc) async { + bloc.onCommunicationState(const communication_states.SettingsOpenedState()); + }, + verify: (_) { + verifyNever(() => meteringInteractor.luxStream().listen((_) {})); + verifyNever(() => meteringInteractor.lightSensorEvCalibration); + verify(() { + communicationBloc.add(const communication_events.MeteringEndedEvent(null)); + }).called(2); // +1 from dispose + }, + expect: () => [], + ); + + blocTest( + 'Metering is in progress', + build: () => bloc, + setUp: () { + when(() => meteringInteractor.luxStream()) + .thenAnswer((_) => Stream.fromIterable(luxIterable)); + when(() => meteringInteractor.lightSensorEvCalibration).thenReturn(0.0); + }, + act: (bloc) async { + bloc.onCommunicationState(const communication_states.MeasureState()); + await Future.delayed(Duration.zero); + bloc.onCommunicationState(const communication_states.SettingsOpenedState()); + bloc.onCommunicationState(const communication_states.SettingsClosedState()); + }, + verify: (_) { + verify(() => meteringInteractor.luxStream().listen((_) {})).called(1); + verify(() => meteringInteractor.lightSensorEvCalibration).called(5); + verify(() { + communicationBloc.add(communication_events.MeteringInProgressEvent(resultList.first)); + }).called(1); + verify(() { + communicationBloc.add(communication_events.MeteringInProgressEvent(resultList[1])); + }).called(3); + verify(() { + communicationBloc.add(communication_events.MeteringInProgressEvent(resultList.last)); + }).called(1); + verify(() { + communicationBloc.add(communication_events.MeteringEndedEvent(resultList.last)); + }).called(3); // +1 from settings closed, +1 from dispose + }, + expect: () => resultList.map( + (e) => isA().having((state) => state.ev100, 'ev100', e), + ), + ); + }, + ); } From b53603b3f516e32f4820f19e48d3cae98d5a317d Mon Sep 17 00:00:00 2001 From: vodemn Date: Sun, 9 Jul 2023 11:50:22 +0000 Subject: [PATCH 07/10] Version bump --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index bd72758..222ac3c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: lightmeter description: A new Flutter project. publish_to: "none" -version: 0.11.8+30 +version: 0.12.0+31 environment: sdk: ">=3.0.0 <4.0.0" From a25ccc0fadd890ccbb6d65b2f0cb7ff9d36ee8a3 Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Mon, 10 Jul 2023 13:08:03 +0200 Subject: [PATCH 08/10] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ba40f08 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug report +about: Create a bug report to help improve the app +title: '' +labels: bug +assignees: vodemn + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Device:** + - Device: [e.g. Pixel 6] + - OS: [e.g. Android 12] + +**App version** From 37fe6a4a5dcaf56bc17e495a452238b886d02b5b Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Mon, 10 Jul 2023 16:01:41 +0200 Subject: [PATCH 09/10] Update issue templates --- .../feature-request-or-improvement.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature-request-or-improvement.md diff --git a/.github/ISSUE_TEMPLATE/feature-request-or-improvement.md b/.github/ISSUE_TEMPLATE/feature-request-or-improvement.md new file mode 100644 index 0000000..381e939 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request-or-improvement.md @@ -0,0 +1,20 @@ +--- +name: Feature request or improvement +about: Suggest an idea for this project +title: '' +labels: feature +assignees: vodemn + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From 47f7b612304fbcee26cb6a8057b42f46e8c96fc9 Mon Sep 17 00:00:00 2001 From: Vadim Date: Mon, 10 Jul 2023 16:15:57 +0200 Subject: [PATCH 10/10] Updated `issuesReportUrl` to lead to _/new/choose_ --- lib/environment.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/environment.dart b/lib/environment.dart index c2e43c7..69ba143 100644 --- a/lib/environment.dart +++ b/lib/environment.dart @@ -19,14 +19,14 @@ class Environment { const Environment.dev() : buildType = BuildType.dev, sourceCodeUrl = 'https://github.com/vodemn/m3_lightmeter', - issuesReportUrl = 'https://github.com/vodemn/m3_lightmeter/issues', + issuesReportUrl = 'https://github.com/vodemn/m3_lightmeter/issues/new/choose', contactEmail = 'contact.vodemn@gmail.com', hasLightSensor = false; const Environment.prod() : buildType = BuildType.prod, sourceCodeUrl = 'https://github.com/vodemn/m3_lightmeter', - issuesReportUrl = 'https://github.com/vodemn/m3_lightmeter/issues', + issuesReportUrl = 'https://github.com/vodemn/m3_lightmeter/issues/new/choose', contactEmail = 'contact.vodemn@gmail.com', hasLightSensor = false;