diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index caf7a97..113bba0 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -15,7 +15,7 @@ jobs: analyze_and_test: name: Analyze & test runs-on: macos-11 - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: 8BitJonny/gh-get-current-pr@2.2.0 id: PR diff --git a/.gitignore b/.gitignore index 703a8b6..4904e66 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,5 @@ ios/Runner/GoogleService-Info.plist /lib/firebase_options.dart coverage/ -screenshots/ \ No newline at end of file +test/coverage_helper_test.dart +screenshots/*.png \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 97cf9fc..0ddf5c0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,10 +7,9 @@ "files.watcherExclude": { "**/.fvm": true }, - "dart.lineLength": 100, + "dart.lineLength": 120, "[dart]": { "editor.rulers": [ - 100, 120, ], "editor.selectionHighlight": true, diff --git a/README.md b/README.md index dae7291..ef38b93 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ +![](https://github.com/vodemn/m3_lightmeter/actions/workflows/pr_check.yml/badge.svg) +![](https://github.com/vodemn/m3_lightmeter/actions/workflows/create_release.yml/badge.svg) + # Table of contents - [Table of contents](#table-of-contents) diff --git a/android/app/build.gradle b/android/app/build.gradle index 5ccd388..be85756 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -75,12 +75,13 @@ android { flavorDimensions "app" productFlavors { dev { - applicationId "com.vodemn.lightmeter.dev" + resValue "string", "app_name", "Lightmeter (DEV)" dimension "app" signingConfig signingConfigs.release + applicationIdSuffix ".dev" } prod { - applicationId "com.vodemn.lightmeter" + resValue "string", "app_name", "Lightmeter" dimension "app" signingConfig signingConfigs.release } diff --git a/android/app/src/dev/ic_launcher-playstore.png b/android/app/src/dev/ic_launcher-playstore.png new file mode 100644 index 0000000..00039fd Binary files /dev/null and b/android/app/src/dev/ic_launcher-playstore.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to android/app/src/dev/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/android/app/src/dev/res/mipmap-hdpi/ic_launcher.png b/android/app/src/dev/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..6dec4cd Binary files /dev/null and b/android/app/src/dev/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/dev/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/dev/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7fd4342 Binary files /dev/null and b/android/app/src/dev/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..dd78094 Binary files /dev/null and b/android/app/src/dev/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/dev/res/mipmap-mdpi/ic_launcher.png b/android/app/src/dev/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..5169915 Binary files /dev/null and b/android/app/src/dev/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/dev/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/dev/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c7d106b Binary files /dev/null and b/android/app/src/dev/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..89c0f9f Binary files /dev/null and b/android/app/src/dev/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/dev/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/dev/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..1da9378 Binary files /dev/null and b/android/app/src/dev/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..79cc56f Binary files /dev/null and b/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..61ea400 Binary files /dev/null and b/android/app/src/dev/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..f1c2f64 Binary files /dev/null and b/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f73d67b Binary files /dev/null and b/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..672e3e1 Binary files /dev/null and b/android/app/src/dev/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..a2e0a13 Binary files /dev/null and b/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9203230 Binary files /dev/null and b/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5711a66 Binary files /dev/null and b/android/app/src/dev/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/dev/res/values/ic_launcher_background.xml b/android/app/src/dev/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..58043be --- /dev/null +++ b/android/app/src/dev/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #212121 + \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6dba1a5..a7d420e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ package="com.vodemn.lightmeter"> - - - - diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index de34bbe..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index ae5b426..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 5ab8dd2..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index d49e5da..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index b57a18c..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index f068022..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index afaeb3c..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index f75d3c2..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index d7dd8be..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 20eff01..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 3dfccd0..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 36e8d33..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 0110c49..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index ac6e5bc..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index a2162bf..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/android/app/src/prod/ic_launcher-playstore.png b/android/app/src/prod/ic_launcher-playstore.png new file mode 100644 index 0000000..4f082f8 Binary files /dev/null and b/android/app/src/prod/ic_launcher-playstore.png differ diff --git a/android/app/src/prod/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/prod/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/prod/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/prod/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/prod/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/android/app/src/prod/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/prod/res/mipmap-hdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b86b175 Binary files /dev/null and b/android/app/src/prod/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/prod/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1392a3b Binary files /dev/null and b/android/app/src/prod/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/prod/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/prod/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..e791add Binary files /dev/null and b/android/app/src/prod/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/prod/res/mipmap-mdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..31b5f50 Binary files /dev/null and b/android/app/src/prod/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/prod/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3399a7d Binary files /dev/null and b/android/app/src/prod/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/prod/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/prod/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..e22fb78 Binary files /dev/null and b/android/app/src/prod/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/prod/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..a2ace48 Binary files /dev/null and b/android/app/src/prod/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/prod/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..c6e5d43 Binary files /dev/null and b/android/app/src/prod/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/prod/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/prod/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..db39634 Binary files /dev/null and b/android/app/src/prod/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..2eb0bfb Binary files /dev/null and b/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9a5a827 Binary files /dev/null and b/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fdd534d Binary files /dev/null and b/android/app/src/prod/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..9d3e572 Binary files /dev/null and b/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..fe2a50c Binary files /dev/null and b/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..74f9d28 Binary files /dev/null and b/android/app/src/prod/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/prod/res/values/ic_launcher_background.xml b/android/app/src/prod/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..58043be --- /dev/null +++ b/android/app/src/prod/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #212121 + \ No newline at end of file diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..decb62c --- /dev/null +++ b/assets/README.md @@ -0,0 +1,11 @@ +# Assets + +## Launcher icons + +### Android + +Resources for Android are generated in Android Studio from the 512x512 source image as described in this [guide](https://developer.android.com/studio/write/create-app-icons). + +### iOS + +Resources for iOS are generated in XCode from the 1024x1024 source image as described in this [guide](https://developer.apple.com/documentation/xcode/configuring-your-app-icon). diff --git a/assets/launcher_icon_circle.png b/assets/launcher_icon_circle.png deleted file mode 100644 index ab23e26..0000000 Binary files a/assets/launcher_icon_circle.png and /dev/null differ diff --git a/assets/launcher_icon_dev_1024.png b/assets/launcher_icon_dev_1024.png new file mode 100644 index 0000000..0923f36 Binary files /dev/null and b/assets/launcher_icon_dev_1024.png differ diff --git a/assets/launcher_icon_dev_512.png b/assets/launcher_icon_dev_512.png new file mode 100644 index 0000000..1c234a0 Binary files /dev/null and b/assets/launcher_icon_dev_512.png differ diff --git a/assets/launcher_icon_prod_1024.png b/assets/launcher_icon_prod_1024.png new file mode 100755 index 0000000..a54026f Binary files /dev/null and b/assets/launcher_icon_prod_1024.png differ diff --git a/assets/launcher_icon_prod_512.png b/assets/launcher_icon_prod_512.png new file mode 100644 index 0000000..706987b Binary files /dev/null and b/assets/launcher_icon_prod_512.png differ diff --git a/assets/launcher_icon_square.png b/assets/launcher_icon_square.png deleted file mode 100644 index e8ac5f8..0000000 Binary files a/assets/launcher_icon_square.png and /dev/null differ diff --git a/iap/lib/m3_lightmeter_iap.dart b/iap/lib/m3_lightmeter_iap.dart index 171fe47..43e69aa 100644 --- a/iap/lib/m3_lightmeter_iap.dart +++ b/iap/lib/m3_lightmeter_iap.dart @@ -1,34 +1,9 @@ library m3_lightmeter_iap; -import 'package:flutter/material.dart'; -import 'package:m3_lightmeter_iap/src/providers/equipment_profile_provider.dart'; -import 'package:m3_lightmeter_iap/src/providers/films_provider.dart'; -import 'package:m3_lightmeter_iap/src/providers/iap_products_provider.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; export 'src/data/models/iap_product.dart'; - -export 'src/providers/equipment_profile_provider.dart'; -export 'src/providers/films_provider.dart'; export 'src/providers/iap_products_provider.dart'; +export 'src/data/iap_storage_service.dart'; -class IAPProviders extends StatelessWidget { - final Object sharedPreferences; - final Widget child; - - const IAPProviders({ - required this.sharedPreferences, - required this.child, - super.key, - }); - - @override - Widget build(BuildContext context) { - return IAPProductsProvider( - child: FilmsProvider( - child: EquipmentProfileProvider( - child: child, - ), - ), - ); - } -} +const List films = []; diff --git a/iap/lib/src/data/iap_storage_service.dart b/iap/lib/src/data/iap_storage_service.dart new file mode 100644 index 0000000..f62f622 --- /dev/null +++ b/iap/lib/src/data/iap_storage_service.dart @@ -0,0 +1,17 @@ +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class IAPStorageService { + const IAPStorageService(Object _); + + String get selectedEquipmentProfileId => ''; + set selectedEquipmentProfileId(String id) {} + + List get equipmentProfiles => []; + set equipmentProfiles(List profiles) {} + + Film get selectedFilm => const Film.other(); + set selectedFilm(Film value) {} + + List get filmsInUse => []; + set filmsInUse(List profiles) {} +} diff --git a/iap/lib/src/providers/equipment_profile_provider.dart b/iap/lib/src/providers/equipment_profile_provider.dart deleted file mode 100644 index 0a037a9..0000000 --- a/iap/lib/src/providers/equipment_profile_provider.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:m3_lightmeter_iap/src/providers/selectable_provider.dart'; -import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; - -class EquipmentProfileProvider extends StatefulWidget { - final Widget child; - - const EquipmentProfileProvider({required this.child, super.key}); - - static EquipmentProfileProviderState of(BuildContext context) { - return context.findAncestorStateOfType()!; - } - - @override - State createState() => EquipmentProfileProviderState(); -} - -class EquipmentProfileProviderState extends State { - static const EquipmentProfile _defaultProfile = EquipmentProfile( - id: '', - name: '', - apertureValues: ApertureValue.values, - ndValues: NdValue.values, - shutterSpeedValues: ShutterSpeedValue.values, - isoValues: IsoValue.values, - ); - - @override - Widget build(BuildContext context) { - return EquipmentProfiles( - values: const [_defaultProfile], - selected: _defaultProfile, - child: widget.child, - ); - } - - void setProfile(EquipmentProfile data) {} - - void addProfile(String name, [EquipmentProfile? copyFrom]) {} - - void updateProdile(EquipmentProfile data) {} - - void deleteProfile(EquipmentProfile data) {} -} - -class EquipmentProfiles extends SelectableInheritedModel { - const EquipmentProfiles({ - super.key, - required super.values, - required super.selected, - required super.child, - }); - - static List of(BuildContext context) { - return InheritedModel.inheritFrom(context, aspect: SelectableAspect.list)!.values; - } - - static EquipmentProfile selectedOf(BuildContext context) { - return InheritedModel.inheritFrom(context, aspect: SelectableAspect.selected)!.selected; - } -} diff --git a/iap/lib/src/providers/films_provider.dart b/iap/lib/src/providers/films_provider.dart deleted file mode 100644 index e75ccd3..0000000 --- a/iap/lib/src/providers/films_provider.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:m3_lightmeter_iap/src/providers/selectable_provider.dart'; -import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; - -class FilmsProvider extends StatefulWidget { - final Widget child; - - const FilmsProvider({ - required this.child, - super.key, - }); - - static FilmsProviderState of(BuildContext context) { - return context.findAncestorStateOfType()!; - } - - @override - State createState() => FilmsProviderState(); -} - -class FilmsProviderState extends State { - @override - Widget build(BuildContext context) { - return Films( - values: const [Film.other()], - filmsInUse: const [Film.other()], - selected: const Film.other(), - child: widget.child, - ); - } - - void setFilm(Film film) {} - - void saveFilms(List films) {} -} - -class Films extends SelectableInheritedModel { - final List filmsInUse; - - const Films({ - super.key, - required super.values, - required this.filmsInUse, - required super.selected, - required super.child, - }); - - /// [Film.other()] + all the custom fields with actual reciprocity formulas - static List of(BuildContext context) { - return InheritedModel.inheritFrom(context)!.values; - } - - /// [Film.other()] + films in use selected by user - static List inUseOf(BuildContext context) { - return InheritedModel.inheritFrom( - context, - aspect: SelectableAspect.list, - )! - .filmsInUse; - } - - static Film selectedOf(BuildContext context) { - return InheritedModel.inheritFrom(context, aspect: SelectableAspect.selected)!.selected; - } -} diff --git a/iap/lib/src/providers/iap_products_provider.dart b/iap/lib/src/providers/iap_products_provider.dart index 4895fdf..9d381ae 100644 --- a/iap/lib/src/providers/iap_products_provider.dart +++ b/iap/lib/src/providers/iap_products_provider.dart @@ -6,8 +6,10 @@ class IAPProductsProvider extends StatefulWidget { const IAPProductsProvider({required this.child, super.key}); - static IAPProductsProviderState of(BuildContext context) { - return context.findAncestorStateOfType()!; + static IAPProductsProviderState of(BuildContext context) => IAPProductsProvider.maybeOf(context)!; + + static IAPProductsProviderState? maybeOf(BuildContext context) { + return context.findAncestorStateOfType(); } @override @@ -54,8 +56,7 @@ class IAPProducts extends InheritedModel { bool updateShouldNotify(IAPProducts oldWidget) => false; @override - bool updateShouldNotifyDependent(IAPProducts oldWidget, Set dependencies) => - false; + bool updateShouldNotifyDependent(IAPProducts oldWidget, Set dependencies) => false; IAPProduct? _findProduct(IAPProductType type) { try { diff --git a/integration_test/generate_screenshots.dart b/integration_test/generate_screenshots.dart deleted file mode 100644 index 7838f8d..0000000 --- a/integration_test/generate_screenshots.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:lightmeter/application.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/ev_source_type.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: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/generated/l10n.dart'; -import 'package:lightmeter/providers/services_provider.dart'; -import 'package:lightmeter/providers/user_preferences_provider.dart'; -import 'package:lightmeter/res/dimens.dart'; -import 'package:lightmeter/res/theme.dart'; -import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart'; -import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart'; -import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart'; -import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/widget_container_equipment_profile.dart'; -import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; -import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'utils/widget_tester_extension.dart'; - -class _MockSharedPreferences extends Mock implements SharedPreferences {} - -class _MockUserPreferencesService extends Mock implements UserPreferencesService {} - -class _MockCaffeineService extends Mock implements CaffeineService {} - -class _MockHapticsService extends Mock implements HapticsService {} - -class _MockPermissionsService extends Mock implements PermissionsService {} - -class _MockLightSensorService extends Mock implements LightSensorService {} - -class _MockVolumeEventsService extends Mock implements VolumeEventsService {} - -//https://stackoverflow.com/a/67186625/13167574 -void main() { - late _MockUserPreferencesService mockUserPreferencesService; - late _MockCaffeineService mockCaffeineService; - late _MockHapticsService mockHapticsService; - late _MockPermissionsService mockPermissionsService; - late _MockLightSensorService mockLightSensorService; - late _MockVolumeEventsService mockVolumeEventsService; - - final binding = IntegrationTestWidgetsFlutterBinding(); - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - setUpAll(() { - mockUserPreferencesService = _MockUserPreferencesService(); - when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); - when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third); - when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en); - when(() => mockUserPreferencesService.caffeine).thenReturn(true); - when(() => mockUserPreferencesService.volumeAction).thenReturn(VolumeAction.shutter); - when(() => mockUserPreferencesService.cameraEvCalibration).thenReturn(0.0); - when(() => mockUserPreferencesService.lightSensorEvCalibration).thenReturn(0.0); - when(() => mockUserPreferencesService.iso).thenReturn(const IsoValue(400, StopType.full)); - when(() => mockUserPreferencesService.ndFilter).thenReturn(NdValue.values.first); - when(() => mockUserPreferencesService.haptics).thenReturn(true); - when(() => mockUserPreferencesService.meteringScreenLayout).thenReturn({ - MeteringScreenLayoutFeature.equipmentProfiles: true, - MeteringScreenLayoutFeature.extremeExposurePairs: true, - MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: false, - }); - when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); - when(() => mockUserPreferencesService.dynamicColor).thenReturn(false); - - mockCaffeineService = _MockCaffeineService(); - when(() => mockCaffeineService.isKeepScreenOn()).thenAnswer((_) async => false); - when(() => mockCaffeineService.keepScreenOn(true)).thenAnswer((_) async => true); - when(() => mockCaffeineService.keepScreenOn(false)).thenAnswer((_) async => false); - - mockHapticsService = _MockHapticsService(); - when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {}); - when(() => mockHapticsService.responseVibration()).thenAnswer((_) async {}); - when(() => mockHapticsService.errorVibration()).thenAnswer((_) async {}); - - mockPermissionsService = _MockPermissionsService(); - when(() => mockPermissionsService.requestCameraPermission()) - .thenAnswer((_) async => PermissionStatus.granted); - when(() => mockPermissionsService.checkCameraPermission()) - .thenAnswer((_) async => PermissionStatus.granted); - - mockLightSensorService = _MockLightSensorService(); - when(() => mockLightSensorService.hasSensor()).thenAnswer((_) async => true); - when(() => mockLightSensorService.luxStream()).thenAnswer((_) => Stream.fromIterable([100])); - - mockVolumeEventsService = _MockVolumeEventsService(); - when(() => mockVolumeEventsService.setVolumeHandling(true)).thenAnswer((_) async => true); - when(() => mockVolumeEventsService.setVolumeHandling(false)).thenAnswer((_) async => false); - when(() => mockVolumeEventsService.volumeButtonsEventStream()) - .thenAnswer((_) => const Stream.empty()); - - when(() => mockHapticsService.quickVibration()).thenAnswer((_) async {}); - when(() => mockHapticsService.responseVibration()).thenAnswer((_) async {}); - }); - - Future pumpApplication(WidgetTester tester) async { - await tester.pumpWidget( - IAPProviders( - sharedPreferences: _MockSharedPreferences(), - child: EquipmentProfiles( - selected: _mockEquipmentProfiles[0], - values: _mockEquipmentProfiles, - child: Films( - selected: const Film('Ilford HP5+', 400), - values: const [Film.other(), Film('Ilford HP5+', 400)], - filmsInUse: const [Film.other(), Film('Ilford HP5+', 400)], - child: ServicesProvider( - environment: const Environment.prod().copyWith(hasLightSensor: true), - userPreferencesService: mockUserPreferencesService, - caffeineService: mockCaffeineService, - hapticsService: mockHapticsService, - permissionsService: mockPermissionsService, - lightSensorService: mockLightSensorService, - volumeEventsService: mockVolumeEventsService, - child: const UserPreferencesProvider(child: Application()), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - } - - /// Generates several screenshots with the light theme - /// and the additionally the first one with the dark theme - void generateScreenshots(Color color) { - testWidgets('${color.value}_light', (tester) async { - when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); - when(() => mockUserPreferencesService.primaryColor).thenReturn(color); - await pumpApplication(tester); - - await tester.takePhoto(); - await tester.takeScreenshot(binding, '${color.value}_metering_reflected'); - - await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor)); - await tester.pumpAndSettle(); - await tester.tap(find.byType(MeteringMeasureButton)); - await tester.tap(find.byType(MeteringMeasureButton)); - await tester.takeScreenshot(binding, '${color.value}_metering_incident'); - - expect(find.byType(IsoValuePicker), findsOneWidget); - await tester.tap(find.byType(IsoValuePicker)); - await tester.pumpAndSettle(Dimens.durationL); - expect(find.byType(DialogPicker), findsOneWidget); - await tester.takeScreenshot(binding, '${color.value}_metering_iso_picker'); - - await tester.tapCancelButton(); - expect(find.byType(DialogPicker), findsNothing); - await tester.openSettings(); - await tester.takeScreenshot(binding, '${color.value}_settings'); - - await tester.tapListTile(S.current.meteringScreenLayout); - await tester.takeScreenshot(binding, '${color.value}_settings_metering_screen_layout'); - - await tester.tapCancelButton(); - await tester.tapListTile(S.current.equipmentProfiles); - expect(find.byType(EquipmentProfilesScreen), findsOneWidget); - await tester.tap(find.byType(EquipmentProfileContainer).first); - await tester.pumpAndSettle(); - await tester.takeScreenshot(binding, '${color.value}-equipment_profiles'); - - await tester.tap(find.byIcon(Icons.iso).first); - await tester.pumpAndSettle(); - await tester.takeScreenshot(binding, '${color.value}_equipment_profiles_iso_picker'); - }); - - testWidgets( - '${color.value}_dark', - (tester) async { - when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.dark); - when(() => mockUserPreferencesService.primaryColor).thenReturn(color); - await pumpApplication(tester); - - await tester.takePhoto(); - await tester.takeScreenshot(binding, '${color.value}_metering_reflected_dark'); - - await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor)); - await tester.pumpAndSettle(); - await tester.tap(find.byType(MeteringMeasureButton)); - await tester.tap(find.byType(MeteringMeasureButton)); - await tester.takeScreenshot(binding, '${color.value}_metering_incident_dark'); - }, - ); - } - - generateScreenshots(primaryColorsList[5]); - generateScreenshots(primaryColorsList[3]); - generateScreenshots(primaryColorsList[9]); -} - -final _mockEquipmentProfiles = [ - const EquipmentProfile( - id: '', - name: '', - apertureValues: ApertureValue.values, - ndValues: NdValue.values, - shutterSpeedValues: ShutterSpeedValue.values, - isoValues: IsoValue.values, - ), - EquipmentProfile( - id: '1', - name: 'Praktica + Zenitar', - apertureValues: ApertureValue.values.sublist( - ApertureValue.values.indexOf(const ApertureValue(1.7, StopType.half)), - ApertureValue.values.indexOf(const ApertureValue(16, StopType.full)) + 1, - ), - ndValues: NdValue.values.sublist(0, 3), - shutterSpeedValues: ShutterSpeedValue.values.sublist( - ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)), - ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1, - ), - isoValues: const [ - IsoValue(50, StopType.full), - IsoValue(100, StopType.full), - IsoValue(200, StopType.full), - IsoValue(250, StopType.third), - IsoValue(400, StopType.full), - IsoValue(500, StopType.third), - IsoValue(800, StopType.full), - IsoValue(1600, StopType.full), - IsoValue(3200, StopType.full), - ], - ), - const EquipmentProfile( - id: '2', - name: 'Praktica + Jupiter', - apertureValues: ApertureValue.values, - ndValues: NdValue.values, - shutterSpeedValues: ShutterSpeedValue.values, - isoValues: IsoValue.values, - ), -]; diff --git a/integration_test/mocks/paid_features_mock.dart b/integration_test/mocks/paid_features_mock.dart new file mode 100644 index 0000000..5d38c52 --- /dev/null +++ b/integration_test/mocks/paid_features_mock.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:lightmeter/providers/films_provider.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockIAPStorageService extends Mock implements IAPStorageService {} + +class MockIAPProviders extends StatefulWidget { + final String selectedEquipmentProfileId; + final Film selectedFilm; + final Widget child; + + const MockIAPProviders({ + this.selectedEquipmentProfileId = '', + this.selectedFilm = const Film.other(), + required this.child, + super.key, + }); + + @override + State createState() => _MockIAPProvidersState(); +} + +class _MockIAPProvidersState extends State { + late final _MockIAPStorageService mockIAPStorageService; + + @override + void initState() { + super.initState(); + mockIAPStorageService = _MockIAPStorageService(); + when(() => mockIAPStorageService.equipmentProfiles).thenReturn(mockEquipmentProfiles); + when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(widget.selectedEquipmentProfileId); + when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); + when(() => mockIAPStorageService.selectedFilm).thenReturn(widget.selectedFilm); + } + + @override + Widget build(BuildContext context) { + return EquipmentProfileProvider( + storageService: mockIAPStorageService, + child: FilmsProvider( + storageService: mockIAPStorageService, + availableFilms: mockFilms, + child: widget.child, + ), + ); + } +} + +const defaultEquipmentProfile = EquipmentProfile( + id: '', + name: '', + apertureValues: ApertureValue.values, + ndValues: NdValue.values, + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values, +); + +final mockEquipmentProfiles = [ + EquipmentProfile( + id: '1', + name: 'Praktica + Zenitar', + apertureValues: ApertureValue.values.sublist( + ApertureValue.values.indexOf(const ApertureValue(1.7, StopType.half)), + ApertureValue.values.indexOf(const ApertureValue(16, StopType.full)) + 1, + ), + ndValues: NdValue.values.sublist(0, 3), + shutterSpeedValues: ShutterSpeedValue.values.sublist( + ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)), + ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1, + ), + isoValues: const [ + IsoValue(50, StopType.full), + IsoValue(100, StopType.full), + IsoValue(200, StopType.full), + IsoValue(250, StopType.third), + IsoValue(400, StopType.full), + IsoValue(500, StopType.third), + IsoValue(800, StopType.full), + IsoValue(1600, StopType.full), + IsoValue(3200, StopType.full), + ], + ), + const EquipmentProfile( + id: '2', + name: 'Praktica + Jupiter', + apertureValues: ApertureValue.values, + ndValues: NdValue.values, + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values, + ), +]; + +const mockFilms = [_MockFilm(100, 2), _MockFilm(400, 2), _MockFilm(3, 800), _MockFilm(400, 1.5)]; + +class _MockFilm extends Film { + final double reciprocityMultiplier; + + const _MockFilm(int iso, this.reciprocityMultiplier) : super('Mock film $iso x$reciprocityMultiplier', iso); + + @override + double reciprocityFormula(double t) => t * reciprocityMultiplier; +} diff --git a/integration_test/utils/platform_channel_mock.dart b/integration_test/utils/platform_channel_mock.dart new file mode 100644 index 0000000..383f243 --- /dev/null +++ b/integration_test/utils/platform_channel_mock.dart @@ -0,0 +1,59 @@ +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:light_sensor/light_sensor.dart'; + +void setLightSensorAvilability({required bool hasSensor}) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + LightSensor.methodChannel, + (methodCall) async { + switch (methodCall.method) { + case "sensor": + return hasSensor; + default: + return null; + } + }, + ); +} + +void resetLightSensorAvilability() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + LightSensor.methodChannel, + null, + ); +} + +Future sendMockIncidentEv(double ev) => sendMockLux((2.5 * pow(2, ev)).toInt()); + +Future sendMockLux([int lux = 100]) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + LightSensor.eventChannel.name, + const StandardMethodCodec().encodeSuccessEnvelope(lux), + (ByteData? data) {}, + ); +} + +void setupLightSensorStreamHandler() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(LightSensor.eventChannel.name), + (methodCall) async { + switch (methodCall.method) { + case "listen": + return; + case "cancel": + return; + default: + return null; + } + }, + ); +} + +void resetLightSensorStreamHandler() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannel(LightSensor.eventChannel.name), + null, + ); +} diff --git a/integration_test/utils/widget_tester_actions.dart b/integration_test/utils/widget_tester_actions.dart new file mode 100644 index 0000000..8411634 --- /dev/null +++ b/integration_test/utils/widget_tester_actions.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/application.dart'; +import 'package:lightmeter/application_wrapper.dart'; +import 'package:lightmeter/environment.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/components/bottom_controls/components/measure_button/widget_button_measure.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +import '../mocks/paid_features_mock.dart'; +import 'platform_channel_mock.dart'; + +extension WidgetTesterCommonActions on WidgetTester { + Future pumpApplication({ + IAPProductStatus productStatus = IAPProductStatus.purchased, + String selectedEquipmentProfileId = '', + Film selectedFilm = const Film.other(), + }) async { + await pumpWidget( + IAPProducts( + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + status: productStatus, + ), + ], + child: ApplicationWrapper( + const Environment.dev(), + child: MockIAPProviders( + selectedEquipmentProfileId: selectedEquipmentProfileId, + selectedFilm: selectedFilm, + child: const Application(), + ), + ), + ), + ); + await pumpAndSettle(); + } + + Future takePhoto() async { + await tap(find.byType(MeteringMeasureButton)); + await pump(const Duration(seconds: 2)); // wait for circular progress indicator + await pump(const Duration(seconds: 1)); // wait for circular progress indicator + await pumpAndSettle(); + } + + Future toggleIncidentMetering(double ev) async { + await tap(find.byType(MeteringMeasureButton)); + await sendMockIncidentEv(ev); + await tap(find.byType(MeteringMeasureButton)); + await pumpAndSettle(); + } + + Future openAnimatedPicker() async { + await tap(find.byType(T)); + await pumpAndSettle(Dimens.durationL); + } +} + +extension WidgetTesterListTileActions on WidgetTester { + /// Useful for tapping a specific [ListTile] inside a specific screen or dialog + Future tapDescendantTextOf(String text) async { + await tap(find.descendant(of: find.byType(T), matching: find.text(text))); + } +} + +extension WidgetTesterTextButtonActions on WidgetTester { + Future tapSelectButton() => _tapTextButton(S.current.select); + + Future tapCancelButton() => _tapTextButton(S.current.cancel); + + Future tapSaveButton() => _tapTextButton(S.current.save); + + Future _tapTextButton(String text) async { + final button = find.byWidgetPredicate( + (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == text, + ); + expect(button, findsOneWidget); + await tap(button); + await pumpAndSettle(); + } +} diff --git a/ios/Podfile b/ios/Podfile index e9b0728..462df98 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -40,6 +40,12 @@ post_install do |installer| # Start of the permission_handler configuration target.build_configurations.each do |config| + # https://github.com/CocoaPods/CocoaPods/issues/12012 + xcconfig_path = config.base_configuration_reference.real_path + xcconfig = File.read(xcconfig_path) + xcconfig_mod = xcconfig.gsub(/DT_TOOLCHAIN_DIR/, "TOOLCHAIN_DIR") + File.open(xcconfig_path, "w") { |file| file << xcconfig_mod } + # Preprocessor definitions can be found in: https://github.com/Baseflow/flutter-permission-handler/blob/master/permission_handler_apple/ios/Classes/PermissionHandlerEnums.h config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 73ce339..0b4c62e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -44,7 +44,7 @@ 8C539F8FF42AB22E298D5A5E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146EE1CF9000F007C117D /* Lightmeter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Lightmeter.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -95,7 +95,7 @@ 97C146EF1CF9000F007C117D /* Products */ = { isa = PBXGroup; children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, + 97C146EE1CF9000F007C117D /* Lightmeter.app */, ); name = Products; sourceTree = ""; @@ -154,6 +154,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 45F53C083F2EA48EF231DA16 /* [CP] Embed Pods Frameworks */, + FF00F85CE432774850A0EDB7 /* [firebase_crashlytics] Crashlytics Upload Symbols */, ); buildRules = ( ); @@ -161,7 +162,7 @@ ); name = Runner; productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productReference = 97C146EE1CF9000F007C117D /* Lightmeter.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -242,6 +243,7 @@ files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -282,6 +284,29 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + FF00F85CE432774850A0EDB7 /* [firebase_crashlytics] Crashlytics Upload Symbols */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}\"", + "\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/\"", + "\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"", + "\"$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)\"", + "\"$(PROJECT_DIR)/firebase_app_id_file.json\"", + ); + name = "[firebase_crashlytics] Crashlytics Upload Symbols"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$PODS_ROOT/FirebaseCrashlytics/upload-symbols\" --flutter-project \"$PROJECT_DIR/firebase_app_id_file.json\" "; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -370,18 +395,23 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-prod"; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = Lightmeter; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -499,18 +529,23 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-prod"; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = Lightmeter; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -522,18 +557,23 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-prod"; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = Lightmeter; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -599,18 +639,23 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = "Lightmeter (DEV)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -674,18 +719,23 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = "Lightmeter (DEV)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -746,18 +796,23 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Lightmeter (DEV)"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter.dev; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_NAME = "Lightmeter (DEV)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json new file mode 100644 index 0000000..c89c0fc --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Icon square (dev).png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon square (dev).png b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon square (dev).png new file mode 100644 index 0000000..0923f36 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon square (dev).png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/Contents.json new file mode 100644 index 0000000..e85412b --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Icon square.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/Icon square.png b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/Icon square.png new file mode 100644 index 0000000..a54026f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon-prod.appiconset/Icon square.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fa..0000000 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index 21fba1c..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 8836ffd..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 826dc2b..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index ddb081d..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index e9dcbdb..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index d8669e5..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 6178967..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 826dc2b..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index d865bad..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 7863936..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png deleted file mode 100644 index 37d3359..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png deleted file mode 100644 index cd9bbfe..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png deleted file mode 100644 index 687010b..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png deleted file mode 100644 index 284f7ce..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 7863936..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 2ea76d6..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png deleted file mode 100644 index e5c29c0..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png deleted file mode 100644 index b895962..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 16a1ae1..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 9debf8c..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index e32e837..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9b13e6e..f2e1bd9 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,51 +1,51 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Lightmeter - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - lightmeter - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSCameraUsageDescription - Provide camera permissions in order to make measurements - - \ No newline at end of file + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(PRODUCT_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + lightmeter + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + NSCameraUsageDescription + Provide camera permissions in order to make measurements + + diff --git a/lib/application_wrapper.dart b/lib/application_wrapper.dart index f8627f9..ae7d1ae 100644 --- a/lib/application_wrapper.dart +++ b/lib/application_wrapper.dart @@ -1,11 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/data/analytics/analytics.dart'; +import 'package:lightmeter/data/analytics/api/analytics_firebase.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/remote_config_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/films_provider.dart'; +import 'package:lightmeter/providers/remote_config_provider.dart'; import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; @@ -21,25 +27,38 @@ class ApplicationWrapper extends StatelessWidget { @override Widget build(BuildContext context) { return FutureBuilder( - future: Future.wait([ + future: Future.wait([ SharedPreferences.getInstance(), const LightSensorService(LocalPlatform()).hasSensor(), + if (env.buildType != BuildType.dev) const RemoteConfigService().activeAndFetchFeatures(), ]), builder: (_, snapshot) { if (snapshot.data != null) { - return IAPProviders( - sharedPreferences: snapshot.data![0] as SharedPreferences, - child: ServicesProvider( - caffeineService: const CaffeineService(), - environment: env.copyWith(hasLightSensor: snapshot.data![1] as bool), - hapticsService: const HapticsService(), - lightSensorService: const LightSensorService(LocalPlatform()), - permissionsService: const PermissionsService(), - userPreferencesService: - UserPreferencesService(snapshot.data![0] as SharedPreferences), - volumeEventsService: const VolumeEventsService(LocalPlatform()), - child: UserPreferencesProvider( - child: child, + final iapService = IAPStorageService(snapshot.data![0] as SharedPreferences); + final userPreferencesService = UserPreferencesService(snapshot.data![0] as SharedPreferences); + final hasLightSensor = snapshot.data![1] as bool; + return ServicesProvider( + analytics: const LightmeterAnalytics(api: LightmeterAnalyticsFirebase()), + caffeineService: const CaffeineService(), + environment: env.copyWith(hasLightSensor: hasLightSensor), + hapticsService: const HapticsService(), + lightSensorService: const LightSensorService(LocalPlatform()), + permissionsService: const PermissionsService(), + userPreferencesService: userPreferencesService, + volumeEventsService: const VolumeEventsService(LocalPlatform()), + child: RemoteConfigProvider( + remoteConfigService: + env.buildType != BuildType.dev ? const RemoteConfigService() : const MockRemoteConfigService(), + child: EquipmentProfileProvider( + storageService: iapService, + child: FilmsProvider( + storageService: iapService, + child: UserPreferencesProvider( + hasLightSensor: hasLightSensor, + userPreferencesService: userPreferencesService, + child: child, + ), + ), ), ), ); diff --git a/lib/data/analytics/analytics.dart b/lib/data/analytics/analytics.dart new file mode 100644 index 0000000..1bd6496 --- /dev/null +++ b/lib/data/analytics/analytics.dart @@ -0,0 +1,34 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:lightmeter/data/analytics/api/analytics_api_interface.dart'; +import 'package:lightmeter/data/analytics/entity/analytics_event.dart'; + +class LightmeterAnalytics { + final ILightmeterAnalyticsApi _api; + + const LightmeterAnalytics({required ILightmeterAnalyticsApi api}) : _api = api; + + Future logEvent( + LightmeterAnalyticsEvent event, { + Map? parameters, + }) async { + if (kDebugMode) { + log(' logEvent: ${event.name} / $parameters'); + return; + } + + return _api.logEvent( + event: event, + parameters: parameters, + ); + } + + Future logUnlockProFeatures(String listTileTitle) async { + return logEvent( + LightmeterAnalyticsEvent.unlockProFeatures, + parameters: {"listTileTitle": listTileTitle}, + ); + } +} diff --git a/lib/data/analytics/api/analytics_api_interface.dart b/lib/data/analytics/api/analytics_api_interface.dart new file mode 100644 index 0000000..1aa007f --- /dev/null +++ b/lib/data/analytics/api/analytics_api_interface.dart @@ -0,0 +1,8 @@ +import 'package:lightmeter/data/analytics/entity/analytics_event.dart'; + +abstract class ILightmeterAnalyticsApi { + Future logEvent({ + required LightmeterAnalyticsEvent event, + Map? parameters, + }); +} diff --git a/lib/data/analytics/api/analytics_firebase.dart b/lib/data/analytics/api/analytics_firebase.dart new file mode 100644 index 0000000..fb11d02 --- /dev/null +++ b/lib/data/analytics/api/analytics_firebase.dart @@ -0,0 +1,26 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:lightmeter/data/analytics/api/analytics_api_interface.dart'; +import 'package:lightmeter/data/analytics/entity/analytics_event.dart'; + +class LightmeterAnalyticsFirebase implements ILightmeterAnalyticsApi { + const LightmeterAnalyticsFirebase(); + + @override + Future logEvent({ + required LightmeterAnalyticsEvent event, + Map? parameters, + }) async { + try { + await FirebaseAnalytics.instance.logEvent( + name: event.name, + parameters: parameters, + ); + } on FirebaseException catch (e) { + debugPrint('Firebase Analytics Exception: $e'); + } catch (e) { + debugPrint(e.toString()); + } + } +} diff --git a/lib/data/analytics/entity/analytics_event.dart b/lib/data/analytics/entity/analytics_event.dart new file mode 100644 index 0000000..8275869 --- /dev/null +++ b/lib/data/analytics/entity/analytics_event.dart @@ -0,0 +1,3 @@ +enum LightmeterAnalyticsEvent { + unlockProFeatures, +} diff --git a/lib/data/light_sensor_service.dart b/lib/data/light_sensor_service.dart index c837dde..a4b95d7 100644 --- a/lib/data/light_sensor_service.dart +++ b/lib/data/light_sensor_service.dart @@ -11,7 +11,7 @@ class LightSensorService { return false; } try { - return await LightSensor.hasSensor ?? false; + return await LightSensor.hasSensor(); } catch (_) { return false; } @@ -21,6 +21,6 @@ class LightSensorService { if (!localPlatform.isAndroid) { return const Stream.empty(); } - return LightSensor.lightSensorStream; + return LightSensor.luxStream(); } } diff --git a/lib/data/models/camera_feature.dart b/lib/data/models/camera_feature.dart new file mode 100644 index 0000000..20193bf --- /dev/null +++ b/lib/data/models/camera_feature.dart @@ -0,0 +1,13 @@ +enum CameraFeature { + spotMetering, + histogram, +} + +typedef CameraFeaturesConfig = Map; + +extension CameraFeaturesConfigJson on CameraFeaturesConfig { + static CameraFeaturesConfig fromJson(Map data) => + {for (final f in CameraFeature.values) f: data[f.name] as bool? ?? false}; + + Map toJson() => map((key, value) => MapEntry(key.name, value)); +} diff --git a/lib/data/models/feature.dart b/lib/data/models/feature.dart new file mode 100644 index 0000000..b022db7 --- /dev/null +++ b/lib/data/models/feature.dart @@ -0,0 +1,5 @@ +enum Feature { unlockProFeaturesText } + +const featuresDefaultValues = { + Feature.unlockProFeaturesText: true, +}; diff --git a/lib/data/models/metering_screen_layout_config.dart b/lib/data/models/metering_screen_layout_config.dart index 7802195..7d550e4 100644 --- a/lib/data/models/metering_screen_layout_config.dart +++ b/lib/data/models/metering_screen_layout_config.dart @@ -1,18 +1,31 @@ enum MeteringScreenLayoutFeature { - extremeExposurePairs, - filmPicker, - histogram, - equipmentProfiles, + extremeExposurePairs, // 0 + filmPicker, // 1 + equipmentProfiles, // 3 } typedef MeteringScreenLayoutConfig = Map; extension MeteringScreenLayoutConfigJson on MeteringScreenLayoutConfig { - static MeteringScreenLayoutConfig fromJson(Map data) => - { - for (final f in MeteringScreenLayoutFeature.values) - f: data[f.index.toString()] as bool? ?? true - }; + static MeteringScreenLayoutConfig fromJson(Map data) { + int? migratedIndex(MeteringScreenLayoutFeature feature) { + switch (feature) { + case MeteringScreenLayoutFeature.extremeExposurePairs: + return 0; + case MeteringScreenLayoutFeature.filmPicker: + return 1; + case MeteringScreenLayoutFeature.equipmentProfiles: + return 3; + default: + return null; + } + } - Map toJson() => map((key, value) => MapEntry(key.index.toString(), value)); + return { + for (final f in MeteringScreenLayoutFeature.values) + f: (data[migratedIndex(f).toString()] ?? data[f.name]) as bool? ?? true + }; + } + + Map toJson() => map((key, value) => MapEntry(key.name, value)); } diff --git a/lib/data/remote_config_service.dart b/lib/data/remote_config_service.dart new file mode 100644 index 0000000..f8c123b --- /dev/null +++ b/lib/data/remote_config_service.dart @@ -0,0 +1,131 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:flutter/foundation.dart'; +import 'package:lightmeter/data/models/feature.dart'; + +abstract class IRemoteConfigService { + const IRemoteConfigService(); + + Future activeAndFetchFeatures(); + + Future fetchConfig(); + + dynamic getValue(Feature feature); + + Map getAll(); + + Stream> onConfigUpdated(); + + bool isEnabled(Feature feature); +} + +class RemoteConfigService implements IRemoteConfigService { + const RemoteConfigService(); + + @override + Future activeAndFetchFeatures() async { + final FirebaseRemoteConfig remoteConfig = FirebaseRemoteConfig.instance; + const cacheStaleDuration = kDebugMode ? Duration(minutes: 1) : Duration(hours: 12); + + try { + await remoteConfig.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: const Duration(seconds: 15), + minimumFetchInterval: cacheStaleDuration, + ), + ); + await remoteConfig.setDefaults(featuresDefaultValues.map((key, value) => MapEntry(key.name, value))); + await remoteConfig.activate(); + await remoteConfig.ensureInitialized(); + + log('Firebase remote config initialized successfully'); + } on FirebaseException catch (e) { + _logError('Firebase exception during Firebase Remote Config initialization: $e'); + } catch (e) { + _logError('Error during Firebase Remote Config initialization: $e'); + } + } + + @override + Future fetchConfig() async { + // https://github.com/firebase/flutterfire/issues/6196#issuecomment-927751667 + await Future.delayed(const Duration(seconds: 1)); + await FirebaseRemoteConfig.instance.fetch(); + } + + @override + dynamic getValue(Feature feature) => FirebaseRemoteConfig.instance.getValue(feature.name).toValue(feature); + + @override + Map getAll() { + final Map result = {}; + for (final value in FirebaseRemoteConfig.instance.getAll().entries) { + try { + final feature = Feature.values.firstWhere((f) => f.name == value.key); + result[feature] = value.value.toValue(feature); + } catch (e) { + log(e.toString()); + } + } + return result; + } + + @override + Stream> onConfigUpdated() => FirebaseRemoteConfig.instance.onConfigUpdated.asyncMap( + (event) async { + await FirebaseRemoteConfig.instance.activate(); + final Set updatedFeatures = {}; + for (final key in event.updatedKeys) { + try { + updatedFeatures.add(Feature.values.firstWhere((element) => element.name == key)); + } catch (e) { + log(e.toString()); + } + } + return updatedFeatures; + }, + ); + + @override + bool isEnabled(Feature feature) => FirebaseRemoteConfig.instance.getBool(feature.name); + + void _logError(dynamic throwable, {StackTrace? stackTrace}) { + FirebaseCrashlytics.instance.recordError(throwable, stackTrace); + } +} + +class MockRemoteConfigService implements IRemoteConfigService { + const MockRemoteConfigService(); + + @override + Future activeAndFetchFeatures() async {} + + @override + Future fetchConfig() async {} + + @override + Map getAll() => featuresDefaultValues; + + @override + dynamic getValue(Feature feature) => featuresDefaultValues[feature]; + + @override + // ignore: cast_nullable_to_non_nullable + bool isEnabled(Feature feature) => featuresDefaultValues[feature] as bool; + + @override + Stream> onConfigUpdated() => const Stream.empty(); +} + +extension on RemoteConfigValue { + dynamic toValue(Feature feature) { + switch (feature) { + case Feature.unlockProFeaturesText: + return asBool(); + } + } +} diff --git a/lib/data/shared_prefs_service.dart b/lib/data/shared_prefs_service.dart index 96d7d9d..443f2e5 100644 --- a/lib/data/shared_prefs_service.dart +++ b/lib/data/shared_prefs_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/camera_feature.dart'; import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/data/models/supported_locale.dart'; @@ -18,6 +19,7 @@ class UserPreferencesService { static const cameraEvCalibrationKey = "cameraEvCalibration"; static const lightSensorEvCalibrationKey = "lightSensorEvCalibration"; static const meteringScreenLayoutKey = "meteringScreenLayout"; + static const cameraFeaturesKey = "cameraFeatures"; static const caffeineKey = "caffeine"; static const hapticsKey = "haptics"; @@ -70,16 +72,13 @@ class UserPreferencesService { } } - IsoValue get iso => - IsoValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(isoKey) ?? 100)); + IsoValue get iso => IsoValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(isoKey) ?? 100)); set iso(IsoValue value) => _sharedPreferences.setInt(isoKey, value.value); - NdValue get ndFilter => - NdValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(ndFilterKey) ?? 0)); + NdValue get ndFilter => NdValue.values.firstWhere((v) => v.value == (_sharedPreferences.getInt(ndFilterKey) ?? 0)); set ndFilter(NdValue value) => _sharedPreferences.setInt(ndFilterKey, value.value); - EvSourceType get evSourceType => - EvSourceType.values[_sharedPreferences.getInt(evSourceTypeKey) ?? 0]; + EvSourceType get evSourceType => EvSourceType.values[_sharedPreferences.getInt(evSourceTypeKey) ?? 0]; set evSourceType(EvSourceType value) => _sharedPreferences.setInt(evSourceTypeKey, value.index); StopType get stopType => StopType.values[_sharedPreferences.getInt(stopTypeKey) ?? 2]; @@ -96,7 +95,6 @@ class UserPreferencesService { MeteringScreenLayoutFeature.equipmentProfiles: true, MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: true, }; } } @@ -104,6 +102,21 @@ class UserPreferencesService { set meteringScreenLayout(MeteringScreenLayoutConfig value) => _sharedPreferences.setString(meteringScreenLayoutKey, json.encode(value.toJson())); + CameraFeaturesConfig get cameraFeatures { + final configJson = _sharedPreferences.getString(cameraFeaturesKey); + if (configJson != null) { + return CameraFeaturesConfigJson.fromJson(json.decode(configJson) as Map); + } else { + return { + CameraFeature.spotMetering: false, + CameraFeature.histogram: false, + }; + } + } + + set cameraFeatures(CameraFeaturesConfig value) => + _sharedPreferences.setString(cameraFeaturesKey, json.encode(value.toJson())); + bool get caffeine => _sharedPreferences.getBool(caffeineKey) ?? false; set caffeine(bool value) => _sharedPreferences.setBool(caffeineKey, value); @@ -114,8 +127,7 @@ class UserPreferencesService { (e) => e.toString() == _sharedPreferences.getString(volumeActionKey), orElse: () => VolumeAction.shutter, ); - set volumeAction(VolumeAction value) => - _sharedPreferences.setString(volumeActionKey, value.toString()); + set volumeAction(VolumeAction value) => _sharedPreferences.setString(volumeActionKey, value.toString()); SupportedLocale get locale => SupportedLocale.values.firstWhere( (e) => e.toString() == _sharedPreferences.getString(localeKey), @@ -124,13 +136,10 @@ class UserPreferencesService { set locale(SupportedLocale value) => _sharedPreferences.setString(localeKey, value.toString()); double get cameraEvCalibration => _sharedPreferences.getDouble(cameraEvCalibrationKey) ?? 0.0; - set cameraEvCalibration(double value) => - _sharedPreferences.setDouble(cameraEvCalibrationKey, value); + set cameraEvCalibration(double value) => _sharedPreferences.setDouble(cameraEvCalibrationKey, value); - double get lightSensorEvCalibration => - _sharedPreferences.getDouble(lightSensorEvCalibrationKey) ?? 0.0; - set lightSensorEvCalibration(double value) => - _sharedPreferences.setDouble(lightSensorEvCalibrationKey, value); + double get lightSensorEvCalibration => _sharedPreferences.getDouble(lightSensorEvCalibrationKey) ?? 0.0; + set lightSensorEvCalibration(double value) => _sharedPreferences.setDouble(lightSensorEvCalibrationKey, value); ThemeType get themeType => ThemeType.values[_sharedPreferences.getInt(themeTypeKey) ?? 0]; set themeType(ThemeType value) => _sharedPreferences.setInt(themeTypeKey, value.index); diff --git a/lib/data/volume_events_service.dart b/lib/data/volume_events_service.dart index d57936a..360de75 100644 --- a/lib/data/volume_events_service.dart +++ b/lib/data/volume_events_service.dart @@ -3,7 +3,7 @@ import 'package:flutter/services.dart'; import 'package:platform/platform.dart'; class VolumeEventsService { - final LocalPlatform localPlatform; + final LocalPlatform _localPlatform; @visibleForTesting static const volumeHandlingChannel = MethodChannel("com.vodemn.lightmeter/volumeHandling"); @@ -11,12 +11,12 @@ class VolumeEventsService { @visibleForTesting static const volumeEventsChannel = EventChannel("com.vodemn.lightmeter/volumeEvents"); - const VolumeEventsService(this.localPlatform); + 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) { + if (!_localPlatform.isAndroid) { return false; } return volumeHandlingChannel @@ -29,7 +29,7 @@ class VolumeEventsService { /// KEYCODE_VOLUME_DOWN = 25; /// pressed Stream volumeButtonsEventStream() { - if (!localPlatform.isAndroid) { + if (!_localPlatform.isAndroid) { return const Stream.empty(); } return volumeEventsChannel diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 2f2fa27..02bb95a 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -39,7 +39,11 @@ "meteringScreenLayoutHintEquipmentProfiles": "Equipment profile picker", "meteringScreenFeatureExtremeExposurePairs": "Fastest & shortest exposure pairs", "meteringScreenFeatureFilmPicker": "Film picker", - "meteringScreenFeatureHistogram": "Histogram", + "cameraFeatures": "Camera features", + "cameraFeatureSpotMetering": "Spot metering", + "cameraFeatureSpotMeteringHint": "Long press the camera view to remove metering spot", + "cameraFeatureHistogram": "Histogram", + "cameraFeatureHistogramHint": "Enabling histogram can encrease battery drain", "film": "Film", "filmPush": "Film (push)", "filmPull": "Film (pull)", @@ -92,10 +96,14 @@ } } }, - "buyLightmeterPro": "Buy Lightmeter Pro", "lightmeterPro": "Lightmeter Pro", - "lightmeterProDescription": "Unlocks extra features, such as equipment profiles containing filters for aperture, shutter speed, and more; and a list of films with compensation for what's known as reciprocity failure.\n\nThe source code of Lightmeter is available on GitHub. You are welcome to compile it yourself. However, if you want to support the development and receive new features and updates, consider purchasing Lightmeter Pro.", + "buyLightmeterPro": "Buy Lightmeter Pro", + "lightmeterProDescription": "Unlocks extra features:\n \u2022 Equipment profiles containing filters for aperture, shutter speed, and more\n \u2022 List of films with compensation for what's known as reciprocity failure\n \u2022 Spot metering\n \u2022 Histogram\n\nThe source code of Lightmeter is available on GitHub. You are welcome to compile it yourself. However, if you want to support the development and receive new features and updates, consider purchasing Lightmeter Pro.", "buy": "Buy", + "proFeatures": "Pro features", + "unlockProFeatures": "Unlock Pro features", + "unlockProFeaturesDescription": "Unlock professional features:\n \u2022 Equipment profiles containing filters for aperture, shutter speed, and more\n \u2022 List of films with compensation for what's known as reciprocity failure\n \u2022 Spot metering\n \u2022 Histogram\n\nBy unlocking Pro features you support the development and make it possible to add new features to the app.", + "unlock": "Unlock", "tooltipAdd": "Add", "tooltipClose": "Close", "tooltipExpand": "Expand", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index 895b8e2..f8b52c3 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -39,7 +39,11 @@ "meteringScreenLayoutHintEquipmentProfiles": "Sélecteur de profil de l'équipement", "meteringScreenFeatureExtremeExposurePairs": "Paires d'exposition les plus rapides et les plus courtes", "meteringScreenFeatureFilmPicker": "Sélecteur de film", - "meteringScreenFeatureHistogram": "Histogramme", + "cameraFeatures": "Fonctionnalités de la caméra", + "cameraFeatureSpotMetering": "Mesure spot", + "cameraFeatureSpotMeteringHint": "Appuyez longuement sur la vue de l'appareil photo pour supprimer le spot de mesure", + "cameraFeatureHistogram": "Histogramme", + "cameraFeatureHistogramHint": "L'activation de l'histogramme peut augmenter la consommation de la batterie", "film": "Pellicule", "filmPush": "Pellicule (push)", "filmPull": "Pellicule (pull)", @@ -94,8 +98,12 @@ }, "buyLightmeterPro": "Acheter Lightmeter Pro", "lightmeterPro": "Lightmeter Pro", - "lightmeterProDescription": "Déverrouille des fonctionnalités supplémentaires, telles que des profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore, ainsi qu'une liste de films avec une compensation pour ce que l'on appelle l'échec de réciprocité.\n\nLe code source du Lightmeter est disponible sur GitHub. Vous pouvez le compiler vous-même. Cependant, si vous souhaitez soutenir le développement et recevoir de nouvelles fonctionnalités et mises à jour, envisagez d'acheter Lightmeter Pro.", + "lightmeterProDescription": "Débloque des fonctionnalités supplémentaires:\n \u2022 Profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore\n \u2022 Liste de films avec une compensation pour ce que l'on appelle l'échec de réciprocité\n \u2022 Mesure spot\n \u2022 Histogramme\n\nLe code source du Lightmeter est disponible sur GitHub. Vous pouvez le compiler vous-même. Cependant, si vous souhaitez soutenir le développement et recevoir de nouvelles fonctionnalités et mises à jour, envisagez d'acheter Lightmeter Pro.", "buy": "Acheter", + "proFeatures": "Fonctionnalités professionnelles", + "unlockProFeatures": "Déverrouiller les fonctionnalités professionnelles", + "unlockProFeaturesDescription": "Déverrouillez des fonctions professionnelles:\n \u2022 Profils d'équipement contenant des filtres pour l'ouverture, la vitesse d'obturation et plus encore, ainsi qu'une liste de films avec compensation pour ce que l'on appelle l'échec de réciprocité\n \u2022 Mesure spot\n \u2022 Histogramme\n\nEn débloquant les fonctionnalités Pro, vous soutenez le développement et permettez d'ajouter de nouvelles fonctionnalités à l'application.", + "unlock": "Déverrouiller", "tooltipAdd": "Ajouter", "tooltipClose": "Fermer", "tooltipExpand": "Élargir", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index f4c7b0b..f25dc8e 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -39,7 +39,11 @@ "meteringScreenLayoutHintEquipmentProfiles": "Выбор профиля оборудования", "meteringScreenFeatureExtremeExposurePairs": "Длинная и короткая выдержки", "meteringScreenFeatureFilmPicker": "Выбор пленки", - "meteringScreenFeatureHistogram": "Гистограмма", + "cameraFeatures": "Возможности камеры", + "cameraFeatureSpotMetering": "Точечный замер", + "cameraFeatureSpotMeteringHint": "Используйте долгое нажатие, чтобы удалить точку замера", + "cameraFeatureHistogram": "Гистограмма", + "cameraFeatureHistogramHint": "Использование гистограммы может увеличить расход аккумулятора", "film": "Пленка", "filmPush": "Пленка (push)", "filmPull": "Пленка (pull)", @@ -94,8 +98,12 @@ }, "buyLightmeterPro": "Купить Lightmeter Pro", "lightmeterPro": "Lightmeter Pro", - "lightmeterProDescription": "Даёт доступ к таким функциям как профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений, а также набору пленок с компенсацией эффекта Шварцшильда.\n\nИсходный код Lightmeter доступен на GitHub. Вы можете собрать его самостоятельно. Однако если вы хотите поддержать разработку и получать новые функции и обновления, то приобретите Lightmeter Pro.", + "lightmeterProDescription": "Даёт доступ к различным функциям:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nИсходный код Lightmeter доступен на GitHub. Вы можете собрать его самостоятельно. Однако если вы хотите поддержать разработку и получать новые функции и обновления, то приобретите Lightmeter Pro.", "buy": "Купить", + "proFeatures": "Профессиональные настройки", + "unlockProFeatures": "Разблокировать профессиональные настройки", + "unlockProFeaturesDescription": "Вы можете разблокировать профессиональные настройки:\n \u2022 Профили оборудования, содержащие фильтры для диафрагмы, выдержки и других значений\n \u2022 Список пленок с компенсацией эффекта Шварцшильда\n \u2022 Точечный замер\n \u2022 Гистограмма\n\nПолучая доступ к профессиональным настройкам, вы поддерживаете разработку и делаете возможным появление новых функций в приложении.", + "unlock": "Разблокировать", "tooltipAdd": "Добавить", "tooltipClose": "Закрыть", "tooltipExpand": "Развернуть", diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index 0701781..3844a76 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -39,7 +39,11 @@ "meteringScreenLayoutHintEquipmentProfiles": "设备配置选择", "meteringScreenFeatureExtremeExposurePairs": "最快 & 最慢曝光组合", "meteringScreenFeatureFilmPicker": "胶片选择", - "meteringScreenFeatureHistogram": "直方图", + "cameraFeatures": "相机功能", + "cameraFeatureSpotMetering": "点测光", + "cameraFeatureSpotMeteringHint": "长按相机视图可移除测光点", + "cameraFeatureHistogram": "直方图", + "cameraFeatureHistogramHint": "启用直方图会增加电池消耗", "film": "胶片", "filmPush": "胶片 (push)", "filmPull": "胶片 (pull)", @@ -94,8 +98,12 @@ }, "buyLightmeterPro": "购买 Lightmeter Pro", "lightmeterPro": "Lightmeter Pro", - "lightmeterProDescription": "购买以解锁额外功能。例如包含光圈、快门速度等参数的配置文件;以及一个胶卷预设列表来提供倒易率失效时的曝光补偿。\n\n您可以在 GitHub 上获取 Lightmeter 的源代码,欢迎自行编译。不过,如果您想支持开发并获得新功能和更新,请考虑购买 Lightmeter Pro。", + "lightmeterProDescription": "解锁额外功能:\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶卷列表,对胶片倒易率失效进行曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n您可以在 GitHub 上获取 Lightmeter 的源代码,欢迎自行编译。不过,如果您想支持开发并获得新功能和更新,请考虑购买 Lightmeter Pro。", "buy": "购买", + "proFeatures": "专业功能", + "unlockProFeatures": "解锁专业功能", + "unlockProFeaturesDescription": "\n \u2022 配置文件,其中包含光圈、快门速度等参数\n \u2022 胶卷列表,对胶片倒易率失效进行曝光补偿\n \u2022 点测光\n \u2022 直方图\n\n通过解锁专业版功能,您可以支持开发工作,帮助为应用程序添加新功能。", + "unlock": "解锁", "tooltipAdd": "添加", "tooltipClose": "关闭", "tooltipExpand": "展开", diff --git a/lib/main_dev.dart b/lib/main_dev.dart index 9ef3faf..b43352f 100644 --- a/lib/main_dev.dart +++ b/lib/main_dev.dart @@ -2,8 +2,17 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/application.dart'; import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - runApp(const ApplicationWrapper(Environment.dev(), child: Application())); + runApp( + IAPProducts( + products: [IAPProduct(storeId: IAPProductType.paidFeatures.storeId)], + child: const ApplicationWrapper( + Environment.dev(), + child: Application(), + ), + ), + ); } diff --git a/lib/main_prod.dart b/lib/main_prod.dart index b75513e..3460f32 100644 --- a/lib/main_prod.dart +++ b/lib/main_prod.dart @@ -3,9 +3,17 @@ import 'package:lightmeter/application.dart'; import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; import 'package:lightmeter/firebase.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await initializeFirebase(handleErrors: true); - runApp(const ApplicationWrapper(Environment.prod(), child: Application())); + runApp( + const IAPProductsProvider( + child: ApplicationWrapper( + Environment.prod(), + child: Application(), + ), + ), + ); } diff --git a/lib/main_release.dart b/lib/main_release.dart index bb6384a..eea83e2 100644 --- a/lib/main_release.dart +++ b/lib/main_release.dart @@ -3,9 +3,17 @@ import 'package:lightmeter/application.dart'; import 'package:lightmeter/application_wrapper.dart'; import 'package:lightmeter/environment.dart'; import 'package:lightmeter/firebase.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await initializeFirebase(handleErrors: false); - runApp(const ApplicationWrapper(Environment.prod(), child: Application())); + runApp( + const IAPProductsProvider( + child: ApplicationWrapper( + Environment.prod(), + child: Application(), + ), + ), + ); } diff --git a/lib/providers/equipment_profile_provider.dart b/lib/providers/equipment_profile_provider.dart new file mode 100644 index 0000000..564c5ef --- /dev/null +++ b/lib/providers/equipment_profile_provider.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/utils/selectable_provider.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:uuid/uuid.dart'; + +class EquipmentProfileProvider extends StatefulWidget { + final IAPStorageService storageService; + final Widget child; + + const EquipmentProfileProvider({ + required this.storageService, + required this.child, + super.key, + }); + + static EquipmentProfileProviderState of(BuildContext context) { + return context.findAncestorStateOfType()!; + } + + @override + State createState() => EquipmentProfileProviderState(); +} + +class EquipmentProfileProviderState extends State { + static const EquipmentProfile _defaultProfile = EquipmentProfile( + id: '', + name: '', + apertureValues: ApertureValue.values, + ndValues: NdValue.values, + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values, + ); + + List _customProfiles = []; + String _selectedId = ''; + + EquipmentProfile get _selectedProfile => _customProfiles.firstWhere( + (e) => e.id == _selectedId, + orElse: () => _defaultProfile, + ); + + @override + void initState() { + super.initState(); + _selectedId = widget.storageService.selectedEquipmentProfileId; + _customProfiles = widget.storageService.equipmentProfiles; + } + + @override + Widget build(BuildContext context) { + return EquipmentProfiles( + values: [ + _defaultProfile, + if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ..._customProfiles, + ], + selected: IAPProducts.isPurchased(context, IAPProductType.paidFeatures) ? _selectedProfile : _defaultProfile, + child: widget.child, + ); + } + + void setProfile(EquipmentProfile data) { + if (_selectedId != data.id) { + setState(() { + _selectedId = data.id; + }); + widget.storageService.selectedEquipmentProfileId = _selectedProfile.id; + } + } + + /// Creates a default equipment profile + void addProfile(String name, [EquipmentProfile? copyFrom]) { + _customProfiles.add( + EquipmentProfile( + id: const Uuid().v1(), + name: name, + apertureValues: copyFrom?.apertureValues ?? ApertureValue.values, + ndValues: copyFrom?.ndValues ?? NdValue.values, + shutterSpeedValues: copyFrom?.shutterSpeedValues ?? ShutterSpeedValue.values, + isoValues: copyFrom?.isoValues ?? IsoValue.values, + ), + ); + _refreshSavedProfiles(); + } + + void updateProfile(EquipmentProfile data) { + final indexToUpdate = _customProfiles.indexWhere((element) => element.id == data.id); + if (indexToUpdate >= 0) { + _customProfiles[indexToUpdate] = data; + _refreshSavedProfiles(); + } + } + + void deleteProfile(EquipmentProfile data) { + if (data.id == _selectedId) { + _selectedId = _defaultProfile.id; + widget.storageService.selectedEquipmentProfileId = _defaultProfile.id; + } + _customProfiles.remove(data); + _refreshSavedProfiles(); + } + + void _refreshSavedProfiles() { + widget.storageService.equipmentProfiles = _customProfiles; + setState(() {}); + } +} + +class EquipmentProfiles extends SelectableInheritedModel { + const EquipmentProfiles({ + super.key, + required super.values, + required super.selected, + required super.child, + }); + + /// [_defaultProfile] + profiles created by the user + static List of(BuildContext context) { + return InheritedModel.inheritFrom(context, aspect: SelectableAspect.list)!.values; + } + + static EquipmentProfile selectedOf(BuildContext context) { + return InheritedModel.inheritFrom( + context, + aspect: SelectableAspect.selected, + )! + .selected; + } +} diff --git a/lib/providers/films_provider.dart b/lib/providers/films_provider.dart new file mode 100644 index 0000000..aff6d01 --- /dev/null +++ b/lib/providers/films_provider.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/utils/selectable_provider.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +class FilmsProvider extends StatefulWidget { + final IAPStorageService storageService; + final List? availableFilms; + final Widget child; + + const FilmsProvider({ + required this.storageService, + this.availableFilms, + required this.child, + super.key, + }); + + static FilmsProviderState of(BuildContext context) { + return context.findAncestorStateOfType()!; + } + + @override + State createState() => FilmsProviderState(); +} + +class FilmsProviderState extends State { + late List _filmsInUse; + late Film _selected; + + @override + void initState() { + super.initState(); + _filmsInUse = widget.storageService.filmsInUse; + _selected = widget.storageService.selectedFilm; + _discardSelectedIfNotIncluded(); + } + + @override + Widget build(BuildContext context) { + return Films( + values: [ + const Film.other(), + ...widget.availableFilms ?? films, + ], + filmsInUse: [ + const Film.other(), + if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ..._filmsInUse, + ], + selected: IAPProducts.isPurchased(context, IAPProductType.paidFeatures) + ? _selected + : const Film.other(), + child: widget.child, + ); + } + + void setFilm(Film film) { + if (_selected != film) { + _selected = film; + widget.storageService.selectedFilm = film; + setState(() {}); + } + } + + void saveFilms(List films) { + _filmsInUse = films; + widget.storageService.filmsInUse = films; + _discardSelectedIfNotIncluded(); + setState(() {}); + } + + void _discardSelectedIfNotIncluded() { + if (_selected != const Film.other() && !_filmsInUse.contains(_selected)) { + _selected = const Film.other(); + widget.storageService.selectedFilm = const Film.other(); + } + } +} + +class Films extends SelectableInheritedModel { + final List filmsInUse; + + const Films({ + super.key, + required super.values, + required this.filmsInUse, + required super.selected, + required super.child, + }); + + /// [Film.other()] + all the custom fields with actual reciprocity formulas + static List of(BuildContext context) { + return InheritedModel.inheritFrom(context)!.values; + } + + /// [Film.other()] + films in use selected by user + static List inUseOf(BuildContext context) { + return InheritedModel.inheritFrom( + context, + aspect: SelectableAspect.list, + )! + .filmsInUse; + } + + static Film selectedOf(BuildContext context) { + return InheritedModel.inheritFrom(context, aspect: SelectableAspect.selected)!.selected; + } +} diff --git a/lib/providers/remote_config_provider.dart b/lib/providers/remote_config_provider.dart new file mode 100644 index 0000000..4557ed6 --- /dev/null +++ b/lib/providers/remote_config_provider.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/feature.dart'; +import 'package:lightmeter/data/remote_config_service.dart'; + +class RemoteConfigProvider extends StatefulWidget { + final IRemoteConfigService remoteConfigService; + final Widget child; + + const RemoteConfigProvider({ + required this.remoteConfigService, + required this.child, + super.key, + }); + + @override + State createState() => RemoteConfigProviderState(); +} + +class RemoteConfigProviderState extends State { + late final Map _config = widget.remoteConfigService.getAll(); + late final StreamSubscription> _updatesSubscription; + + @override + void initState() { + super.initState(); + widget.remoteConfigService.fetchConfig(); + _updatesSubscription = widget.remoteConfigService.onConfigUpdated().listen( + _updateFeatures, + onError: (e) => log(e.toString()), + ); + } + + @override + void dispose() { + _updatesSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RemoteConfig( + config: _config, + child: widget.child, + ); + } + + void _updateFeatures(Set updatedFeatures) { + for (final feature in updatedFeatures) { + _config[feature] = widget.remoteConfigService.getValue(feature); + } + setState(() {}); + } +} + +class RemoteConfig extends InheritedModel { + final Map _config; + + const RemoteConfig({ + super.key, + required Map config, + required super.child, + }) : _config = config; + + static bool isEnabled(BuildContext context, Feature feature) { + return InheritedModel.inheritFrom(context)!._config[feature] as bool; + } + + @override + bool updateShouldNotify(RemoteConfig oldWidget) => true; + + @override + bool updateShouldNotifyDependent(RemoteConfig oldWidget, Set features) { + for (final feature in features) { + if (oldWidget._config[feature] != _config[feature]) { + return true; + } + } + return false; + } +} diff --git a/lib/providers/services_provider.dart b/lib/providers/services_provider.dart index c2c548f..36f5674 100644 --- a/lib/providers/services_provider.dart +++ b/lib/providers/services_provider.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/data/caffeine_service.dart'; import 'package:lightmeter/data/haptics_service.dart'; import 'package:lightmeter/data/light_sensor_service.dart'; @@ -7,7 +8,9 @@ import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/data/volume_events_service.dart'; import 'package:lightmeter/environment.dart'; +// coverage:ignore-start class ServicesProvider extends InheritedWidget { + final LightmeterAnalytics analytics; final CaffeineService caffeineService; final Environment environment; final HapticsService hapticsService; @@ -17,6 +20,7 @@ class ServicesProvider extends InheritedWidget { final VolumeEventsService volumeEventsService; const ServicesProvider({ + required this.analytics, required this.caffeineService, required this.environment, required this.hapticsService, @@ -34,3 +38,4 @@ class ServicesProvider extends InheritedWidget { @override bool updateShouldNotify(ServicesProvider oldWidget) => false; } +// coverage:ignore-end diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index af644d1..764f282 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -1,6 +1,7 @@ import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:lightmeter/data/models/camera_feature.dart'; import 'package:lightmeter/data/models/dynamic_colors_state.dart'; import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; @@ -8,14 +9,21 @@ 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/generated/l10n.dart'; -import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/res/theme.dart'; +import 'package:lightmeter/utils/map_model.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class UserPreferencesProvider extends StatefulWidget { + final bool hasLightSensor; + final UserPreferencesService userPreferencesService; final Widget child; - const UserPreferencesProvider({required this.child, super.key}); + const UserPreferencesProvider({ + required this.hasLightSensor, + required this.userPreferencesService, + required this.child, + super.key, + }); static _UserPreferencesProviderState of(BuildContext context) { return context.findAncestorStateOfType<_UserPreferencesProviderState>()!; @@ -38,14 +46,21 @@ class UserPreferencesProvider extends StatefulWidget { } static bool meteringScreenFeatureOf(BuildContext context, MeteringScreenLayoutFeature feature) { - return InheritedModel.inheritFrom<_MeteringScreenLayoutModel>(context, aspect: feature)! - .data[feature]!; + return InheritedModel.inheritFrom<_MeteringScreenLayoutModel>(context, aspect: feature)!.data[feature]!; } static StopType stopTypeOf(BuildContext context) { return _inheritFromEnumsModel(context, _Aspect.stopType).stopType; } + static CameraFeaturesConfig cameraConfigOf(BuildContext context) { + return context.findAncestorWidgetOfExactType<_CameraFeaturesModel>()!.data; + } + + static bool cameraFeatureOf(BuildContext context, CameraFeature feature) { + return InheritedModel.inheritFrom<_CameraFeaturesModel>(context, aspect: feature)!.data[feature]!; + } + static ThemeData themeOf(BuildContext context) { return _inheritFromEnumsModel(context, _Aspect.theme).theme; } @@ -65,28 +80,22 @@ class UserPreferencesProvider extends StatefulWidget { State createState() => _UserPreferencesProviderState(); } -class _UserPreferencesProviderState extends State - with WidgetsBindingObserver { - UserPreferencesService get userPreferencesService => - ServicesProvider.of(context).userPreferencesService; - - late bool dynamicColor = userPreferencesService.dynamicColor; - late EvSourceType evSourceType; - late MeteringScreenLayoutConfig meteringScreenLayout = - userPreferencesService.meteringScreenLayout; - late Color primaryColor = userPreferencesService.primaryColor; - late StopType stopType = userPreferencesService.stopType; - late SupportedLocale locale = userPreferencesService.locale; - late ThemeType themeType = userPreferencesService.themeType; +class _UserPreferencesProviderState extends State with WidgetsBindingObserver { + late EvSourceType _evSourceType; + late StopType _stopType = widget.userPreferencesService.stopType; + late MeteringScreenLayoutConfig _meteringScreenLayout = widget.userPreferencesService.meteringScreenLayout; + late CameraFeaturesConfig _cameraFeatures = widget.userPreferencesService.cameraFeatures; + late SupportedLocale _locale = widget.userPreferencesService.locale; + late ThemeType _themeType = widget.userPreferencesService.themeType; + late Color _primaryColor = widget.userPreferencesService.primaryColor; + late bool _dynamicColor = widget.userPreferencesService.dynamicColor; @override void initState() { super.initState(); - evSourceType = userPreferencesService.evSourceType; - evSourceType = evSourceType == EvSourceType.sensor && - !ServicesProvider.of(context).environment.hasLightSensor - ? EvSourceType.camera - : evSourceType; + _evSourceType = widget.userPreferencesService.evSourceType; + _evSourceType = + _evSourceType == EvSourceType.sensor && !widget.hasLightSensor ? EvSourceType.camera : _evSourceType; WidgetsBinding.instance.addObserver(this); } @@ -109,9 +118,8 @@ class _UserPreferencesProviderState extends State late final DynamicColorState state; late final Color? dynamicPrimaryColor; if (lightDynamic != null && darkDynamic != null) { - if (dynamicColor) { - dynamicPrimaryColor = - (_themeBrightness == Brightness.light ? lightDynamic : darkDynamic).primary; + if (_dynamicColor) { + dynamicPrimaryColor = (_themeBrightness == Brightness.light ? lightDynamic : darkDynamic).primary; state = DynamicColorState.enabled; } else { dynamicPrimaryColor = null; @@ -124,14 +132,17 @@ class _UserPreferencesProviderState extends State return _UserPreferencesModel( brightness: _themeBrightness, dynamicColorState: state, - evSourceType: evSourceType, - locale: locale, - primaryColor: dynamicPrimaryColor ?? primaryColor, - stopType: stopType, - themeType: themeType, + evSourceType: _evSourceType, + locale: _locale, + primaryColor: dynamicPrimaryColor ?? _primaryColor, + stopType: _stopType, + themeType: _themeType, child: _MeteringScreenLayoutModel( - data: meteringScreenLayout, - child: widget.child, + data: _meteringScreenLayout, + child: _CameraFeaturesModel( + data: _cameraFeatures, + child: widget.child, + ), ), ); }, @@ -140,65 +151,72 @@ class _UserPreferencesProviderState extends State void enableDynamicColor(bool enable) { setState(() { - dynamicColor = enable; + _dynamicColor = enable; }); - userPreferencesService.dynamicColor = enable; + widget.userPreferencesService.dynamicColor = enable; } void toggleEvSourceType() { - if (!ServicesProvider.of(context).environment.hasLightSensor) { + if (!widget.hasLightSensor) { return; } setState(() { - switch (evSourceType) { + switch (_evSourceType) { case EvSourceType.camera: - evSourceType = EvSourceType.sensor; + _evSourceType = EvSourceType.sensor; case EvSourceType.sensor: - evSourceType = EvSourceType.camera; + _evSourceType = EvSourceType.camera; } }); - userPreferencesService.evSourceType = evSourceType; + widget.userPreferencesService.evSourceType = _evSourceType; } void setLocale(SupportedLocale locale) { S.load(Locale(locale.intlName)).then((value) { setState(() { - this.locale = locale; + _locale = locale; }); - userPreferencesService.locale = locale; + widget.userPreferencesService.locale = locale; }); } void setMeteringScreenLayout(MeteringScreenLayoutConfig config) { setState(() { - meteringScreenLayout = config; + _meteringScreenLayout = config; }); - userPreferencesService.meteringScreenLayout = meteringScreenLayout; + widget.userPreferencesService.meteringScreenLayout = _meteringScreenLayout; + } + + void setCameraFeature(CameraFeaturesConfig config) { + setState(() { + _cameraFeatures = config; + }); + widget.userPreferencesService.cameraFeatures = _cameraFeatures; } void setPrimaryColor(Color primaryColor) { setState(() { - this.primaryColor = primaryColor; + _primaryColor = primaryColor; }); - userPreferencesService.primaryColor = primaryColor; + widget.userPreferencesService.primaryColor = primaryColor; } void setStopType(StopType stopType) { setState(() { - this.stopType = stopType; + _stopType = stopType; }); - userPreferencesService.stopType = stopType; + widget.userPreferencesService.stopType = stopType; } void setThemeType(ThemeType themeType) { setState(() { - this.themeType = themeType; + _themeType = themeType; }); - userPreferencesService.themeType = themeType; + widget.userPreferencesService.themeType = themeType; } Brightness get _themeBrightness { - switch (themeType) { + switch (_themeType) { case ThemeType.light: return Brightness.light; case ThemeType.dark: @@ -258,8 +276,7 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> { _UserPreferencesModel oldWidget, Set<_Aspect> dependencies, ) { - return (dependencies.contains(_Aspect.dynamicColorState) && - dynamicColorState != oldWidget.dynamicColorState) || + return (dependencies.contains(_Aspect.dynamicColorState) && dynamicColorState != oldWidget.dynamicColorState) || (dependencies.contains(_Aspect.evSourceType) && evSourceType != oldWidget.evSourceType) || (dependencies.contains(_Aspect.locale) && locale != oldWidget.locale) || (dependencies.contains(_Aspect.stopType) && stopType != oldWidget.stopType) || @@ -269,27 +286,16 @@ class _UserPreferencesModel extends InheritedModel<_Aspect> { } } -class _MeteringScreenLayoutModel extends InheritedModel { - final Map data; - +class _MeteringScreenLayoutModel extends MapModel { const _MeteringScreenLayoutModel({ - required this.data, + required super.data, + required super.child, + }); +} + +class _CameraFeaturesModel extends MapModel { + const _CameraFeaturesModel({ + required super.data, required super.child, }); - - @override - bool updateShouldNotify(_MeteringScreenLayoutModel oldWidget) => oldWidget.data != data; - - @override - bool updateShouldNotifyDependent( - _MeteringScreenLayoutModel oldWidget, - Set dependencies, - ) { - for (final dependecy in dependencies) { - if (oldWidget.data[dependecy] != data[dependecy]) { - return true; - } - } - return false; - } } diff --git a/lib/res/theme.dart b/lib/res/theme.dart index a6320c1..52e0e92 100644 --- a/lib/res/theme.dart +++ b/lib/res/theme.dart @@ -23,7 +23,7 @@ const primaryColorsList = [ ThemeData themeFrom(Color primaryColor, Brightness brightness) { final scheme = _colorSchemeFromColor(primaryColor, brightness); - return ThemeData( + final theme = ThemeData( useMaterial3: true, brightness: scheme.brightness, primaryColor: primaryColor, @@ -60,12 +60,18 @@ ThemeData themeFrom(Color primaryColor, Brightness brightness) { ), scaffoldBackgroundColor: scheme.surface, ); + return theme.copyWith( + listTileTheme: ListTileThemeData( + style: ListTileStyle.list, + iconColor: scheme.onSurface, + textColor: scheme.onSurface, + subtitleTextStyle: theme.textTheme.bodyMedium!.copyWith(color: scheme.onSurfaceVariant), + ), + ); } ColorScheme _colorSchemeFromColor(Color primaryColor, Brightness brightness) { - final scheme = brightness == Brightness.light - ? Scheme.light(primaryColor.value) - : Scheme.dark(primaryColor.value); + final scheme = brightness == Brightness.light ? Scheme.light(primaryColor.value) : Scheme.dark(primaryColor.value); return ColorScheme( brightness: brightness, diff --git a/lib/screens/metering/bloc_metering.dart b/lib/screens/metering/bloc_metering.dart index cbb0356..753a7e7 100644 --- a/lib/screens/metering/bloc_metering.dart +++ b/lib/screens/metering/bloc_metering.dart @@ -10,9 +10,9 @@ 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:lightmeter/screens/metering/utils/notifier_volume_keys.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringBloc extends Bloc { diff --git a/lib/screens/metering/components/camera_container/bloc_container_camera.dart b/lib/screens/metering/components/camera_container/bloc_container_camera.dart index 1d7d4b5..2e6b051 100644 --- a/lib/screens/metering/components/camera_container/bloc_container_camera.dart +++ b/lib/screens/metering/components/camera_container/bloc_container_camera.dart @@ -11,10 +11,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; -import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' - as communication_event; -import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' - as communication_states; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_event; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart'; import 'package:lightmeter/screens/metering/components/camera_container/state_container_camera.dart'; @@ -57,6 +55,7 @@ class CameraContainerBloc extends EvSourceBlocBase(_onZoomChanged); on(_onExposureOffsetChanged); on(_onExposureOffsetResetEvent); + on(_onExposureSpotChangedEvent); } @override @@ -166,9 +165,7 @@ class CameraContainerBloc extends EvSourceBlocBase _onZoomChanged(ZoomChangedEvent event, Emitter emit) async { - if (_cameraController != null && - event.value >= _zoomRange!.start && - event.value <= _zoomRange!.end) { + if (_cameraController != null && event.value >= _zoomRange!.start && event.value <= _zoomRange!.end) { _cameraController!.setZoomLevel(event.value); _currentZoom = event.value; _emitActiveState(emit); @@ -188,6 +185,13 @@ class CameraContainerBloc extends EvSourceBlocBase _onExposureSpotChangedEvent(ExposureSpotChangedEvent event, Emitter emit) async { + if (_cameraController != null) { + _cameraController!.setExposurePoint(event.offset); + _cameraController!.setFocusPoint(event.offset); + } + } + void _emitActiveState(Emitter emit) { emit( CameraActiveState( diff --git a/lib/screens/metering/components/camera_container/components/camera_controls/components/exposure_offset_slider/widget_slider_exposure_offset.dart b/lib/screens/metering/components/camera_container/components/camera_controls/components/exposure_offset_slider/widget_slider_exposure_offset.dart index 484ee4b..40ec1f0 100644 --- a/lib/screens/metering/components/camera_container/components/camera_controls/components/exposure_offset_slider/widget_slider_exposure_offset.dart +++ b/lib/screens/metering/components/camera_container/components/camera_controls/components/exposure_offset_slider/widget_slider_exposure_offset.dart @@ -72,7 +72,7 @@ class _Ruler extends StatelessWidget { children: [ if (showValue) Text( - (index + min).toStringSigned(), + (index + min).toStringSignedAsFixed(0), style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(width: Dimens.grid8), diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart b/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart new file mode 100644 index 0000000..0ec7f17 --- /dev/null +++ b/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/res/dimens.dart'; + +class CameraSpotDetector extends StatefulWidget { + final ValueChanged onSpotTap; + + const CameraSpotDetector({ + required this.onSpotTap, + super.key, + }); + + @override + State createState() => _CameraSpotDetectorState(); +} + +class _CameraSpotDetectorState extends State { + Offset? spot; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (_, constraints) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (TapDownDetails details) => onViewFinderTap(details, constraints), + onLongPress: () => onViewFinderTap(null, constraints), + child: Stack( + children: [ + if (spot != null) + AnimatedPositioned( + duration: Dimens.durationS, + left: spot!.dx - Dimens.grid16 / 2, + top: spot!.dy - Dimens.grid16 / 2, + height: Dimens.grid16, + width: Dimens.grid16, + child: const _Spot(), + ), + ], + ), + ), + ); + } + + void onViewFinderTap(TapDownDetails? details, BoxConstraints constraints) { + setState(() { + spot = details?.localPosition; + }); + + widget.onSpotTap( + details != null + ? Offset( + details.localPosition.dx / constraints.maxWidth, + details.localPosition.dy / constraints.maxHeight, + ) + : null, + ); + } +} + +class _Spot extends StatelessWidget { + const _Spot(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + color: Colors.white70, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart b/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart index 7c06062..c054f3d 100644 --- a/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart +++ b/lib/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart @@ -12,14 +12,17 @@ class CameraView extends StatelessWidget { final value = controller.value; return ValueListenableBuilder( valueListenable: controller, - builder: (_, __, ___) => AspectRatio( + builder: (_, __, Widget? child) => AspectRatio( aspectRatio: _isLandscape(value) ? value.aspectRatio : (1 / value.aspectRatio), - child: value.isInitialized - ? RotatedBox( - quarterTurns: _getQuarterTurns(value), - child: controller.buildPreview(), - ) - : const SizedBox.shrink(), + child: Stack( + children: [ + RotatedBox( + quarterTurns: _getQuarterTurns(value), + child: controller.buildPreview(), + ), + child ?? const SizedBox(), + ], + ), ), ); } @@ -42,8 +45,6 @@ class CameraView extends StatelessWidget { DeviceOrientation _getApplicableOrientation(CameraValue value) { return value.isRecordingVideo ? value.recordingOrientation! - : (value.previewPauseOrientation ?? - value.lockedCaptureOrientation ?? - value.deviceOrientation); + : (value.previewPauseOrientation ?? value.lockedCaptureOrientation ?? value.deviceOrientation); } } diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart b/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart index 3e9538f..e6f1699 100644 --- a/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart +++ b/lib/screens/metering/components/camera_container/components/camera_preview/widget_camera_preview.dart @@ -1,19 +1,27 @@ import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; -import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/data/models/camera_feature.dart'; import 'package:lightmeter/platform_config.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/camera_spot_detector/widget_camera_spot_detector.dart'; import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/camera_view/widget_camera_view.dart'; import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/camera_view_placeholder/widget_placeholder_camera_view.dart'; import 'package:lightmeter/screens/metering/components/camera_container/components/camera_preview/components/histogram/widget_histogram.dart'; import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; class CameraPreview extends StatefulWidget { final CameraController? controller; final CameraErrorType? error; + final ValueChanged onSpotTap; - const CameraPreview({this.controller, this.error, super.key}); + const CameraPreview({ + this.controller, + this.error, + required this.onSpotTap, + super.key, + }); @override State createState() => _CameraPreviewState(); @@ -31,7 +39,10 @@ class _CameraPreviewState extends State { AnimatedSwitcher( duration: Dimens.switchDuration, child: widget.controller != null - ? _CameraPreviewBuilder(controller: widget.controller!) + ? _CameraPreviewBuilder( + controller: widget.controller!, + onSpotTap: widget.onSpotTap, + ) : CameraViewPlaceholder(error: widget.error), ), ], @@ -43,16 +54,19 @@ class _CameraPreviewState extends State { class _CameraPreviewBuilder extends StatefulWidget { final CameraController controller; + final ValueChanged onSpotTap; - const _CameraPreviewBuilder({required this.controller}); + const _CameraPreviewBuilder({ + required this.controller, + required this.onSpotTap, + }); @override State<_CameraPreviewBuilder> createState() => _CameraPreviewBuilderState(); } class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> { - late final ValueNotifier _initializedNotifier = - ValueNotifier(widget.controller.value.isInitialized); + late final ValueNotifier _initializedNotifier = ValueNotifier(widget.controller.value.isInitialized); @override void initState() { @@ -79,16 +93,23 @@ class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> { alignment: Alignment.bottomCenter, children: [ CameraView(controller: widget.controller), - if (UserPreferencesProvider.meteringScreenFeatureOf( - context, - MeteringScreenLayoutFeature.histogram, - )) - Positioned( - left: Dimens.grid8, - right: Dimens.grid8, - bottom: Dimens.grid16, - child: CameraHistogram(controller: widget.controller), - ), + if (IAPProducts.isPurchased(context, IAPProductType.paidFeatures)) ...[ + if (UserPreferencesProvider.cameraFeatureOf( + context, + CameraFeature.histogram, + )) + Positioned( + left: Dimens.grid8, + right: Dimens.grid8, + bottom: Dimens.grid16, + child: CameraHistogram(controller: widget.controller), + ), + if (UserPreferencesProvider.cameraFeatureOf( + context, + CameraFeature.spotMetering, + )) + CameraSpotDetector(onSpotTap: widget.onSpotTap) + ], ], ) : const SizedBox.shrink(), diff --git a/lib/screens/metering/components/camera_container/event_container_camera.dart b/lib/screens/metering/components/camera_container/event_container_camera.dart index d3e5995..fe0713d 100644 --- a/lib/screens/metering/components/camera_container/event_container_camera.dart +++ b/lib/screens/metering/components/camera_container/event_container_camera.dart @@ -1,3 +1,5 @@ +import 'package:flutter/gestures.dart'; + abstract class CameraContainerEvent { const CameraContainerEvent(); } @@ -53,3 +55,19 @@ class ExposureOffsetChangedEvent extends CameraContainerEvent { class ExposureOffsetResetEvent extends CameraContainerEvent { const ExposureOffsetResetEvent(); } + +class ExposureSpotChangedEvent extends CameraContainerEvent { + final Offset? offset; + + const ExposureSpotChangedEvent(this.offset); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is ExposureSpotChangedEvent && other.offset == offset; + } + + @override + int get hashCode => Object.hash(offset, runtimeType); +} diff --git a/lib/screens/metering/components/camera_container/widget_container_camera.dart b/lib/screens/metering/components/camera_container/widget_container_camera.dart index 944aa34..a23545e 100644 --- a/lib/screens/metering/components/camera_container/widget_container_camera.dart +++ b/lib/screens/metering/components/camera_container/widget_container_camera.dart @@ -143,6 +143,9 @@ class _CameraViewBuilder extends StatelessWidget { builder: (context, state) => CameraPreview( controller: state is CameraInitializedState ? state.controller : null, error: state is CameraErrorState ? state.error : null, + onSpotTap: (value) { + context.read().add(ExposureSpotChangedEvent(value)); + }, ), ); } diff --git a/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart b/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart index 4051c5e..cb78275 100644 --- a/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart +++ b/lib/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/films_provider.dart'; import 'package:lightmeter/res/dimens.dart'; - import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/components/exposure_pairs_list_item/widget_item_list_exposure_pairs.dart'; import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; class ExposurePairsList extends StatelessWidget { final List exposurePairs; @@ -33,6 +32,7 @@ class ExposurePairsList extends StatelessWidget { alignment: Alignment.center, children: [ Row( + key: ValueKey(index), mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( diff --git a/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart b/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart index f47cd53..adbe1d2 100644 --- a/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart +++ b/lib/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class EquipmentProfilePicker extends StatelessWidget { diff --git a/lib/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart b/lib/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart index 54c786c..5c701e2 100644 --- a/lib/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart +++ b/lib/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/films_provider.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; class ExtremeExposurePairsContainer extends StatelessWidget { final ExposurePair? fastest; diff --git a/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart b/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart index ae1e6fe..13a9366 100644 --- a/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart +++ b/lib/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/films_provider.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class FilmPicker extends StatelessWidget { diff --git a/lib/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart b/lib/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart index eda016c..fdd8f0a 100644 --- a/lib/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart +++ b/lib/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart @@ -27,14 +27,13 @@ class NdValuePicker extends StatelessWidget { value.value == 0 ? S.of(context).none : value.value.toString(), ), // using descending order, because ND filter darkens image & lowers EV - itemTrailingBuilder: (selected, value) => value.value != selected.value - ? Text(S.of(context).evValue(value.toStringDifference(selected))) - : null, + itemTrailingBuilder: (selected, value) => + value.value != selected.value ? Text(S.of(context).evValue(value.toStringDifference(selected))) : null, onChanged: onChanged, closedChild: ReadingValueContainer.singleValue( value: ReadingValue( label: S.of(context).nd, - value: selectedValue.value.toString(), + value: selectedValue.value == 0 ? S.of(context).none : selectedValue.value.toString(), ), ), ); diff --git a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart index f10546d..cb8af05 100644 --- a/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart +++ b/lib/screens/metering/components/shared/readings_container/widget_container_readings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart'; @@ -8,7 +9,6 @@ import 'package:lightmeter/screens/metering/components/shared/readings_container import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart'; import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class ReadingsContainer extends StatelessWidget { diff --git a/lib/screens/metering/flow_metering.dart b/lib/screens/metering/flow_metering.dart index cca5675..f78482d 100644 --- a/lib/screens/metering/flow_metering.dart +++ b/lib/screens/metering/flow_metering.dart @@ -4,8 +4,8 @@ import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/providers/services_provider.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/screens/metering/utils/notifier_volume_keys.dart'; class MeteringFlow extends StatefulWidget { const MeteringFlow({super.key}); diff --git a/lib/screens/metering/screen_metering.dart b/lib/screens/metering/screen_metering.dart index f1d11fb..380411f 100644 --- a/lib/screens/metering/screen_metering.dart +++ b/lib/screens/metering/screen_metering.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/data/models/exposure_pair.dart'; -import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/providers/services_provider.dart'; import 'package:lightmeter/providers/user_preferences_provider.dart'; import 'package:lightmeter/screens/metering/bloc_metering.dart'; @@ -13,9 +13,7 @@ import 'package:lightmeter/screens/metering/components/camera_container/provider import 'package:lightmeter/screens/metering/components/light_sensor_container/provider_container_light_sensor.dart'; import 'package:lightmeter/screens/metering/event_metering.dart'; import 'package:lightmeter/screens/metering/state_metering.dart'; -import 'package:lightmeter/screens/metering/utils/listener_metering_layout_feature.dart'; -import 'package:lightmeter/screens/metering/utils/listsner_equipment_profiles.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:lightmeter/screens/metering/utils/listener_equipment_profiles.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringScreen extends StatelessWidget { @@ -73,15 +71,7 @@ class _InheritedListeners extends StatelessWidget { onDidChangeDependencies: (value) { context.read().add(EquipmentProfileChangedEvent(value)); }, - child: MeteringScreenLayoutFeatureListener( - feature: MeteringScreenLayoutFeature.filmPicker, - onDidChangeDependencies: (value) { - if (!value) { - FilmsProvider.of(context).setFilm(const Film.other()); - } - }, - child: child, - ), + child: child, ); } } diff --git a/lib/screens/metering/utils/listsner_equipment_profiles.dart b/lib/screens/metering/utils/listener_equipment_profiles.dart similarity index 91% rename from lib/screens/metering/utils/listsner_equipment_profiles.dart rename to lib/screens/metering/utils/listener_equipment_profiles.dart index 68d03dc..ec604ce 100644 --- a/lib/screens/metering/utils/listsner_equipment_profiles.dart +++ b/lib/screens/metering/utils/listener_equipment_profiles.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class EquipmentProfileListener extends StatefulWidget { diff --git a/lib/screens/metering/utils/listener_metering_layout_feature.dart b/lib/screens/metering/utils/listener_metering_layout_feature.dart deleted file mode 100644 index c245ec3..0000000 --- a/lib/screens/metering/utils/listener_metering_layout_feature.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; -import 'package:lightmeter/providers/user_preferences_provider.dart'; - -/// Listening to multiple dependencies at the same time causes firing an event for all dependencies -/// even though some of them didn't change: -/// ```dart -/// @override -/// void didChangeDependencies() { -/// super.didChangeDependencies(); -/// _bloc.add(EquipmentProfileChangedEvent(EquipmentProfile.of(context))); -/// if (!MeteringScreenLayout.featureStatusOf(context, MeteringScreenLayoutFeature.filmPicker)) { -/// _bloc.add(const FilmChangedEvent(Film.other())); -/// } -/// } -/// ``` -/// To overcome this issue I've decided to create a generic listener, -/// that will listen to each dependency separately. -class MeteringScreenLayoutFeatureListener extends StatefulWidget { - final MeteringScreenLayoutFeature feature; - final ValueChanged onDidChangeDependencies; - final Widget child; - - const MeteringScreenLayoutFeatureListener({ - required this.feature, - required this.onDidChangeDependencies, - required this.child, - super.key, - }); - - @override - State createState() => - _MeteringScreenLayoutFeatureListenerState(); -} - -class _MeteringScreenLayoutFeatureListenerState extends State { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - widget.onDidChangeDependencies( - UserPreferencesProvider.meteringScreenFeatureOf( - context, - widget.feature, - ), - ); - } - - @override - Widget build(BuildContext context) { - return widget.child; - } -} diff --git a/lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart b/lib/screens/metering/utils/notifier_volume_keys.dart similarity index 82% rename from lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart rename to lib/screens/metering/utils/notifier_volume_keys.dart index df64fdf..0c19955 100644 --- a/lib/screens/metering/components/shared/volume_keys_notifier/notifier_volume_keys.dart +++ b/lib/screens/metering/utils/notifier_volume_keys.dart @@ -5,12 +5,12 @@ 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; + final VolumeEventsService _volumeEventsService; late final StreamSubscription _volumeKeysSubscription; VolumeKey _value = VolumeKey.up; - VolumeKeysNotifier(this.volumeEventsService) { - _volumeKeysSubscription = volumeEventsService + VolumeKeysNotifier(this._volumeEventsService) { + _volumeKeysSubscription = _volumeEventsService .volumeButtonsEventStream() .map((event) => event == 24 ? VolumeKey.up : VolumeKey.down) .listen((event) { @@ -19,6 +19,8 @@ class VolumeKeysNotifier extends ChangeNotifier with RouteAware { } VolumeKey get value => _value; + + @protected set value(VolumeKey newValue) { _value = newValue; notifyListeners(); diff --git a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart index 11e8a4f..9854a14 100644 --- a/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart +++ b/lib/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart @@ -1,20 +1,38 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/feature.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart'; -import 'package:lightmeter/screens/settings/components/utils/show_buy_pro_dialog.dart'; +import 'package:lightmeter/providers/remote_config_provider.dart'; +import 'package:lightmeter/providers/services_provider.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/settings/utils/show_buy_pro_dialog.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; class BuyProListTile extends StatelessWidget { const BuyProListTile({super.key}); @override Widget build(BuildContext context) { - return IAPListTile( + final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText); + final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status; + final isPending = status == IAPProductStatus.purchased || status == null; + return ListTile( leading: const Icon(Icons.star), - title: Text(S.of(context).buyLightmeterPro), - onTap: () { - showBuyProDialog(context); - }, - showPendingTrailing: true, + title: Text(unlockFeaturesEnabled ? S.of(context).unlockProFeatures : S.of(context).buyLightmeterPro), + onTap: !isPending + ? () { + showBuyProDialog(context); + ServicesProvider.of(context) + .analytics + .logUnlockProFeatures(unlockFeaturesEnabled ? 'Unlock Pro features' : 'Buy Lightmeter Pro'); + } + : null, + trailing: isPending + ? const SizedBox( + height: Dimens.grid24, + width: Dimens.grid24, + child: CircularProgressIndicator(), + ) + : null, ); } } diff --git a/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart b/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart index c060dc1..7050ae2 100644 --- a/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart +++ b/lib/screens/settings/components/lightmeter_pro/widget_settings_section_lightmeter_pro.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/feature.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/remote_config_provider.dart'; import 'package:lightmeter/screens/settings/components/lightmeter_pro/components/buy_pro/widget_list_tile_buy_pro.dart'; import 'package:lightmeter/screens/settings/components/shared/settings_section/widget_settings_section.dart'; @@ -9,7 +11,9 @@ class LightmeterProSettingsSection extends StatelessWidget { @override Widget build(BuildContext context) { return SettingsSection( - title: S.of(context).lightmeterPro, + title: RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText) + ? S.of(context).proFeatures + : S.of(context).lightmeterPro, children: const [BuyProListTile()], ); } diff --git a/lib/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart b/lib/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart new file mode 100644 index 0000000..1446be3 --- /dev/null +++ b/lib/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/camera_feature.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart'; +import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart'; + +class CameraFeaturesListTile extends StatelessWidget { + const CameraFeaturesListTile({super.key}); + + @override + Widget build(BuildContext context) { + return IAPListTile( + leading: const Icon(Icons.camera_alt), + title: Text(S.of(context).cameraFeatures), + onTap: () { + showDialog( + context: context, + builder: (_) => DialogSwitch( + icon: Icons.layers_outlined, + title: S.of(context).cameraFeatures, + values: UserPreferencesProvider.cameraConfigOf(context), + titleAdapter: (context, feature) { + switch (feature) { + case CameraFeature.spotMetering: + return S.of(context).cameraFeatureSpotMetering; + case CameraFeature.histogram: + return S.of(context).cameraFeatureHistogram; + } + }, + subtitleAdapter: (context, feature) { + switch (feature) { + case CameraFeature.spotMetering: + return S.of(context).cameraFeatureSpotMeteringHint; + case CameraFeature.histogram: + return S.of(context).cameraFeatureHistogramHint; + } + }, + onSave: UserPreferencesProvider.of(context).setCameraFeature, + ), + ); + }, + ); + } +} diff --git a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart index 3c72918..6879a2c 100644 --- a/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart +++ b/lib/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; - +import 'package:lightmeter/providers/equipment_profile_provider.dart'; import 'package:lightmeter/res/dimens.dart'; import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_container/widget_container_equipment_profile.dart'; import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/components/equipment_profile_name_dialog/widget_dialog_equipment_profile_name.dart'; import 'package:lightmeter/screens/shared/icon_placeholder/widget_icon_placeholder.dart'; import 'package:lightmeter/screens/shared/sliver_screen/screen_sliver.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class EquipmentProfilesScreen extends StatefulWidget { @@ -91,7 +90,7 @@ class _EquipmentProfilesScreenState extends State { } void _updateProfileAt(EquipmentProfile data) { - EquipmentProfileProvider.of(context).updateProdile(data); + EquipmentProfileProvider.of(context).updateProfile(data); } void _removeProfileAt(EquipmentProfile data) { diff --git a/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart b/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart index c343e2b..72ff433 100644 --- a/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart +++ b/lib/screens/settings/components/metering/components/films/widget_list_tile_films.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/films_provider.dart'; import 'package:lightmeter/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart'; import 'package:lightmeter/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class FilmsListTile extends StatelessWidget { diff --git a/lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart b/lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart deleted file mode 100644 index 57aaf24..0000000 --- a/lib/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; -import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/user_preferences_provider.dart'; -import 'package:lightmeter/res/dimens.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; - -class MeteringScreenLayoutFeaturesDialog extends StatefulWidget { - const MeteringScreenLayoutFeaturesDialog({super.key}); - - @override - State createState() => - _MeteringScreenLayoutFeaturesDialogState(); -} - -class _MeteringScreenLayoutFeaturesDialogState extends State { - late final _features = - MeteringScreenLayoutConfig.from(UserPreferencesProvider.meteringScreenConfigOf(context)); - - @override - Widget build(BuildContext context) { - return AlertDialog( - icon: const Icon(Icons.layers_outlined), - titlePadding: Dimens.dialogIconTitlePadding, - title: Text(S.of(context).meteringScreenLayout), - contentPadding: EdgeInsets.zero, - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), - child: Text(S.of(context).meteringScreenLayoutHint), - ), - const SizedBox(height: Dimens.grid16), - ListView( - shrinkWrap: true, - children: [ - _featureListTile(MeteringScreenLayoutFeature.equipmentProfiles), - _featureListTile(MeteringScreenLayoutFeature.extremeExposurePairs), - _featureListTile(MeteringScreenLayoutFeature.filmPicker), - _featureListTile(MeteringScreenLayoutFeature.histogram), - ], - ), - ], - ), - ), - actionsPadding: Dimens.dialogActionsPadding, - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text(S.of(context).cancel), - ), - TextButton( - onPressed: () { - if (!_features[MeteringScreenLayoutFeature.equipmentProfiles]!) { - EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first); - } - UserPreferencesProvider.of(context).setMeteringScreenLayout(_features); - Navigator.of(context).pop(); - }, - child: Text(S.of(context).save), - ), - ], - ); - } - - Widget _featureListTile(MeteringScreenLayoutFeature f) { - return SwitchListTile( - contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left), - title: Text(_toStringLocalized(context, f)), - value: _features[f]!, - onChanged: (value) { - setState(() { - _features.update(f, (_) => value); - }); - }, - ); - } - - String _toStringLocalized(BuildContext context, MeteringScreenLayoutFeature feature) { - switch (feature) { - case MeteringScreenLayoutFeature.equipmentProfiles: - return S.of(context).meteringScreenLayoutHintEquipmentProfiles; - case MeteringScreenLayoutFeature.extremeExposurePairs: - return S.of(context).meteringScreenFeatureExtremeExposurePairs; - case MeteringScreenLayoutFeature.filmPicker: - return S.of(context).meteringScreenFeatureFilmPicker; - case MeteringScreenLayoutFeature.histogram: - return S.of(context).meteringScreenFeatureHistogram; - } - } -} diff --git a/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart b/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart index a540926..1f89b4b 100644 --- a/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart +++ b/lib/screens/settings/components/metering/components/metering_screen_layout/widget_list_tile_metering_screen_layout.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/generated/l10n.dart'; - -import 'package:lightmeter/screens/settings/components/metering/components/metering_screen_layout/components/meterins_screen_layout_features_dialog/widget_dialog_metering_screen_layout_features.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:lightmeter/providers/films_provider.dart'; +import 'package:lightmeter/providers/user_preferences_provider.dart'; +import 'package:lightmeter/screens/settings/components/shared/dialog_switch/widget_dialog_switch.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; class MeteringScreenLayoutListTile extends StatelessWidget { const MeteringScreenLayoutListTile({super.key}); @@ -14,9 +18,35 @@ class MeteringScreenLayoutListTile extends StatelessWidget { onTap: () { showDialog( context: context, - builder: (_) => const MeteringScreenLayoutFeaturesDialog(), + builder: (_) => DialogSwitch( + icon: Icons.layers_outlined, + title: S.of(context).meteringScreenLayout, + description: S.of(context).meteringScreenLayoutHint, + values: UserPreferencesProvider.meteringScreenConfigOf(context), + titleAdapter: _toStringLocalized, + onSave: (value) { + if (!value[MeteringScreenLayoutFeature.equipmentProfiles]!) { + EquipmentProfileProvider.of(context).setProfile(EquipmentProfiles.of(context).first); + } + if (!value[MeteringScreenLayoutFeature.filmPicker]!) { + FilmsProvider.of(context).setFilm(const Film.other()); + } + UserPreferencesProvider.of(context).setMeteringScreenLayout(value); + }, + ), ); }, ); } + + String _toStringLocalized(BuildContext context, MeteringScreenLayoutFeature feature) { + switch (feature) { + case MeteringScreenLayoutFeature.equipmentProfiles: + return S.of(context).meteringScreenLayoutHintEquipmentProfiles; + case MeteringScreenLayoutFeature.extremeExposurePairs: + return S.of(context).meteringScreenFeatureExtremeExposurePairs; + case MeteringScreenLayoutFeature.filmPicker: + return S.of(context).meteringScreenFeatureFilmPicker; + } + } } diff --git a/lib/screens/settings/components/metering/widget_settings_section_metering.dart b/lib/screens/settings/components/metering/widget_settings_section_metering.dart index 90de68d..0c6c07d 100644 --- a/lib/screens/settings/components/metering/widget_settings_section_metering.dart +++ b/lib/screens/settings/components/metering/widget_settings_section_metering.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/screens/settings/components/metering/components/calibration/widget_list_tile_calibration.dart'; +import 'package:lightmeter/screens/settings/components/metering/components/camera_features/widget_list_tile_camera_features.dart'; import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/widget_list_tile_equipment_profiles.dart'; import 'package:lightmeter/screens/settings/components/metering/components/films/widget_list_tile_films.dart'; import 'package:lightmeter/screens/settings/components/metering/components/fractional_stops/widget_list_tile_fractional_stops.dart'; @@ -20,6 +21,7 @@ class MeteringSettingsSection extends StatelessWidget { MeteringScreenLayoutListTile(), EquipmentProfilesListTile(), FilmsListTile(), + CameraFeaturesListTile(), ], ); } diff --git a/lib/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart b/lib/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart index 76cd729..eb03e23 100644 --- a/lib/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart +++ b/lib/screens/settings/components/shared/dialog_filter/widget_dialog_filter.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:lightmeter/generated/l10n.dart'; import 'package:lightmeter/res/dimens.dart'; @@ -34,18 +35,20 @@ class _DialogFilterState extends State> { bool get _hasAnySelected => checkboxValues.contains(true); bool get _hasAnyUnselected => checkboxValues.contains(false); - late final ScrollController _scrollController; + final ScrollController _scrollController = ScrollController(); @override void initState() { super.initState(); - int i = 0; - for (; i < checkboxValues.length; i++) { - if (checkboxValues[i]) { - break; + SchedulerBinding.instance.addPostFrameCallback((_) { + int i = 0; + for (; i < checkboxValues.length; i++) { + if (checkboxValues[i]) { + break; + } } - } - _scrollController = ScrollController(initialScrollOffset: Dimens.grid56 * i); + _scrollController.jumpTo((Dimens.grid56 * i).clamp(0, _scrollController.position.maxScrollExtent)); + }); } @override @@ -61,79 +64,80 @@ class _DialogFilterState extends State> { titlePadding: Dimens.dialogIconTitlePadding, title: Text(widget.title), contentPadding: EdgeInsets.zero, - content: Column( - children: [ - Padding( - padding: Dimens.dialogIconTitlePadding, - child: Text(widget.description), - ), - const Divider(), - Expanded( - child: SingleChildScrollView( - controller: _scrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: List.generate( - widget.values.length, - (index) => CheckboxListTile( - value: checkboxValues[index], - controlAffinity: ListTileControlAffinity.leading, - title: Text( - widget.titleAdapter(context, widget.values[index]), - style: Theme.of(context).textTheme.bodyLarge, + content: SizedBox( + width: double.maxFinite, + child: Column( + children: [ + Padding( + padding: Dimens.dialogIconTitlePadding, + child: Text(widget.description), + ), + const Divider(), + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: List.generate( + widget.values.length, + (index) => CheckboxListTile( + value: checkboxValues[index], + controlAffinity: ListTileControlAffinity.leading, + title: Text( + widget.titleAdapter(context, widget.values[index]), + style: Theme.of(context).textTheme.bodyLarge, + ), + onChanged: (value) { + if (value != null) { + setState(() { + checkboxValues[index] = value; + }); + } + }, ), - onChanged: (value) { - if (value != null) { - setState(() { - checkboxValues[index] = value; - }); - } - }, ), ), ), ), - ), - const Divider(), - Padding( - padding: Dimens.dialogActionsPadding, - child: Row( - children: [ - SizedBox( - width: 40, - child: IconButton( - padding: EdgeInsets.zero, - icon: Icon(_hasAnyUnselected ? Icons.select_all : Icons.deselect), - onPressed: _toggleAll, - tooltip: _hasAnyUnselected - ? S.of(context).tooltipSelectAll - : S.of(context).tooltipDesecelectAll, + const Divider(), + Padding( + padding: Dimens.dialogActionsPadding, + child: Row( + children: [ + SizedBox( + width: 40, + child: IconButton( + padding: EdgeInsets.zero, + icon: Icon(_hasAnyUnselected ? Icons.select_all : Icons.deselect), + onPressed: _toggleAll, + tooltip: _hasAnyUnselected ? S.of(context).tooltipSelectAll : S.of(context).tooltipDesecelectAll, + ), ), - ), - const Spacer(), - TextButton( - onPressed: Navigator.of(context).pop, - child: Text(S.of(context).cancel), - ), - TextButton( - onPressed: _hasAnySelected - ? () { - final List selectedValues = []; - for (int i = 0; i < widget.values.length; i++) { - if (checkboxValues[i]) { - selectedValues.add(widget.values[i]); + const Spacer(), + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(S.of(context).cancel), + ), + TextButton( + onPressed: _hasAnySelected + ? () { + final List selectedValues = []; + for (int i = 0; i < widget.values.length; i++) { + if (checkboxValues[i]) { + selectedValues.add(widget.values[i]); + } } + Navigator.of(context).pop(selectedValues); } - Navigator.of(context).pop(selectedValues); - } - : null, - child: Text(S.of(context).save), - ), - ], - ), - ) - ], + : null, + child: Text(S.of(context).save), + ), + ], + ), + ) + ], + ), ), ); } diff --git a/lib/screens/settings/components/shared/dialog_picker/widget_dialog_picker.dart b/lib/screens/settings/components/shared/dialog_picker/widget_dialog_picker.dart index c893027..dc31b5c 100644 --- a/lib/screens/settings/components/shared/dialog_picker/widget_dialog_picker.dart +++ b/lib/screens/settings/components/shared/dialog_picker/widget_dialog_picker.dart @@ -32,24 +32,28 @@ class _DialogPickerState extends State> { titlePadding: Dimens.dialogIconTitlePadding, title: Text(widget.title), contentPadding: EdgeInsets.zero, - content: Column( - mainAxisSize: MainAxisSize.min, - children: widget.values - .map( - (e) => RadioListTile( - value: e, - groupValue: _selected, - title: Text(widget.titleAdapter(context, e)), - onChanged: (T? value) { - if (value != null) { - setState(() { - _selected = value; - }); - } - }, - ), - ) - .toList(), + content: SizedBox( + width: double.maxFinite, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: widget.values + .map( + (e) => RadioListTile( + value: e, + groupValue: _selected, + title: Text(widget.titleAdapter(context, e)), + onChanged: (T? value) { + if (value != null) { + setState(() { + _selected = value; + }); + } + }, + ), + ) + .toList(), + ), ), actionsPadding: Dimens.dialogActionsPadding, actions: [ diff --git a/lib/screens/settings/components/shared/dialog_range_picker/widget_dialog_picker_range.dart b/lib/screens/settings/components/shared/dialog_range_picker/widget_dialog_picker_range.dart index cc3f4ca..56f7967 100644 --- a/lib/screens/settings/components/shared/dialog_range_picker/widget_dialog_picker_range.dart +++ b/lib/screens/settings/components/shared/dialog_range_picker/widget_dialog_picker_range.dart @@ -36,47 +36,50 @@ class _DialogRangePickerState extends State = String Function(BuildContext context, T value); + +class DialogSwitch extends StatefulWidget { + final IconData icon; + final String title; + final String? description; + final Map values; + final StringAdapter titleAdapter; + final StringAdapter? subtitleAdapter; + final ValueChanged> onSave; + + const DialogSwitch({ + required this.icon, + required this.title, + this.description, + required this.values, + required this.titleAdapter, + this.subtitleAdapter, + required this.onSave, + super.key, + }); + + @override + State> createState() => _DialogSwitchState(); +} + +class _DialogSwitchState extends State> { + late final Map _features = Map.from(widget.values); + + @override + Widget build(BuildContext context) { + return AlertDialog( + icon: Icon(widget.icon), + titlePadding: Dimens.dialogIconTitlePadding, + title: Text(widget.title), + contentPadding: EdgeInsets.zero, + content: SizedBox( + width: double.maxFinite, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.description != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), + child: Text(widget.description!), + ), + const SizedBox(height: Dimens.grid16) + ], + ListView( + shrinkWrap: true, + children: _features.entries + .map( + (entry) => SwitchListTile( + contentPadding: EdgeInsets.symmetric(horizontal: Dimens.dialogTitlePadding.left), + title: Text(widget.titleAdapter(context, entry.key)), + subtitle: widget.subtitleAdapter != null + ? Text( + widget.subtitleAdapter!.call(context, entry.key), + style: Theme.of(context).listTileTheme.subtitleTextStyle, + ) + : null, + value: _features[entry.key]!, + onChanged: (value) { + setState(() { + _features.update(entry.key, (_) => value); + }); + }, + ), + ) + .toList(), + ), + ], + ), + ), + actionsPadding: Dimens.dialogActionsPadding, + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(S.of(context).cancel), + ), + TextButton( + onPressed: () { + widget.onSave(_features); + Navigator.of(context).pop(); + }, + child: Text(S.of(context).save), + ), + ], + ); + } +} diff --git a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart index cf65ade..a2b980f 100644 --- a/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart +++ b/lib/screens/settings/components/shared/iap_list_tile/widget_list_tile_iap.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:lightmeter/res/dimens.dart'; -import 'package:lightmeter/screens/settings/components/utils/show_buy_pro_dialog.dart'; import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; /// Depends on the product status and replaces [onTap] with purchase callback @@ -23,24 +22,14 @@ class IAPListTile extends StatelessWidget { @override Widget build(BuildContext context) { - final status = IAPProducts.productOf(context, IAPProductType.paidFeatures)?.status; - final isPending = status == IAPProductStatus.purchased || status == null; - return ListTile( - leading: leading, - title: title, - onTap: switch (status) { - IAPProductStatus.purchasable => () => showBuyProDialog(context), - IAPProductStatus.pending => null, - IAPProductStatus.purchased => onTap, - null => null, - }, - trailing: showPendingTrailing && isPending - ? const SizedBox( - height: Dimens.grid24, - width: Dimens.grid24, - child: CircularProgressIndicator(), - ) - : null, + final isPurchased = IAPProducts.isPurchased(context, IAPProductType.paidFeatures); + return Opacity( + opacity: isPurchased ? Dimens.enabledOpacity : Dimens.disabledOpacity, + child: ListTile( + leading: leading, + title: title, + onTap: isPurchased ? onTap : null, + ), ); } } diff --git a/lib/screens/settings/components/utils/show_buy_pro_dialog.dart b/lib/screens/settings/components/utils/show_buy_pro_dialog.dart deleted file mode 100644 index 0333570..0000000 --- a/lib/screens/settings/components/utils/show_buy_pro_dialog.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/res/dimens.dart'; -import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; - -Future showBuyProDialog(BuildContext context) { - return showDialog( - context: context, - builder: (_) => AlertDialog( - icon: const Icon(Icons.star), - titlePadding: Dimens.dialogIconTitlePadding, - title: Text(S.of(context).lightmeterPro), - contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), - content: SingleChildScrollView(child: Text(S.of(context).lightmeterProDescription)), - actionsPadding: Dimens.dialogActionsPadding, - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text(S.of(context).cancel), - ), - FilledButton( - onPressed: () { - Navigator.of(context).pop(); - IAPProductsProvider.of(context).buy(IAPProductType.paidFeatures); - }, - child: Text(S.of(context).buy), - ), - ], - ), - ); -} diff --git a/lib/screens/settings/utils/show_buy_pro_dialog.dart b/lib/screens/settings/utils/show_buy_pro_dialog.dart new file mode 100644 index 0000000..181259f --- /dev/null +++ b/lib/screens/settings/utils/show_buy_pro_dialog.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:lightmeter/data/models/feature.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/remote_config_provider.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; + +Future showBuyProDialog(BuildContext context) { + final unlockFeaturesEnabled = RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText); + + Widget splitDescription() { + final description = + unlockFeaturesEnabled ? S.of(context).unlockProFeaturesDescription : S.of(context).lightmeterProDescription; + final paragraphs = description.split('\n\n'); + final features = paragraphs.first.split('\n \u2022 ').sublist(1); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(paragraphs.first.split('\n \u2022 ').first), + ...features.map( + (f) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('\u2022 '), + Flexible(child: Text(f)), + ], + ), + ), + Text('\n${paragraphs.last}'), + ], + ); + } + + return showDialog( + context: context, + builder: (_) => AlertDialog( + icon: const Icon(Icons.star), + titlePadding: Dimens.dialogIconTitlePadding, + title: Text(unlockFeaturesEnabled ? S.of(context).proFeatures : S.of(context).lightmeterPro), + contentPadding: const EdgeInsets.symmetric(horizontal: Dimens.paddingL), + content: SingleChildScrollView(child: splitDescription()), + actionsPadding: Dimens.dialogActionsPadding, + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text(S.of(context).cancel), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + IAPProductsProvider.maybeOf(context)?.buy(IAPProductType.paidFeatures); + }, + child: Text(unlockFeaturesEnabled ? S.of(context).unlock : S.of(context).buy), + ), + ], + ), + ); +} diff --git a/lib/utils/map_model.dart b/lib/utils/map_model.dart new file mode 100644 index 0000000..1f5ea09 --- /dev/null +++ b/lib/utils/map_model.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class MapModel extends InheritedModel { + final Map data; + + const MapModel({ + required this.data, + required super.child, + }); + + @override + bool updateShouldNotify(MapModel oldWidget) => oldWidget.data != data; + + @override + bool updateShouldNotifyDependent( + MapModel oldWidget, + Set dependencies, + ) { + for (final dependecy in dependencies) { + if (oldWidget.data[dependecy] != data[dependecy]) { + return true; + } + } + return false; + } +} diff --git a/iap/lib/src/providers/selectable_provider.dart b/lib/utils/selectable_provider.dart similarity index 100% rename from iap/lib/src/providers/selectable_provider.dart rename to lib/utils/selectable_provider.dart diff --git a/lib/utils/text_line_height.dart b/lib/utils/text_line_height.dart deleted file mode 100644 index a531074..0000000 --- a/lib/utils/text_line_height.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter/widgets.dart'; - -extension TextLineHeight on TextStyle { - double get lineHeight => fontSize! * height!; -} diff --git a/lib/utils/to_string_signed.dart b/lib/utils/to_string_signed.dart index 4122893..fcced8d 100644 --- a/lib/utils/to_string_signed.dart +++ b/lib/utils/to_string_signed.dart @@ -1,13 +1,4 @@ -extension SignedString on num { - String toStringSigned() { - if (this > 0) { - return "+${toString()}"; - } else { - return toString(); - } - } -} - +/// Returns value in form -1 or + 1. The only exception - 0. extension SignedStringDouble on double { String toStringSignedAsFixed(int fractionDigits) { if (this > 0) { diff --git a/pubspec.yaml b/pubspec.yaml index bd8e81e..723575b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: lightmeter description: Lightmeter app inspired by Material 3 design system. publish_to: "none" -version: 0.15.1+42 +version: 0.16.0+45 environment: sdk: ">=3.0.0 <4.0.0" @@ -13,8 +13,10 @@ dependencies: clipboard: 0.1.3 dynamic_color: 1.6.6 exif: 3.1.4 - firebase_core: 2.14.0 - firebase_crashlytics: 3.3.3 + firebase_analytics: 10.6.2 + firebase_core: 2.20.0 + firebase_crashlytics: 3.4.2 + firebase_remote_config: 4.3.2 flutter: sdk: flutter flutter_bloc: 8.1.3 @@ -22,11 +24,11 @@ dependencies: sdk: flutter intl: 0.18.0 intl_utils: 2.8.2 - light_sensor: 2.0.2 + light_sensor: 3.0.0 m3_lightmeter_iap: git: url: "https://github.com/vodemn/m3_lightmeter_iap" - ref: v0.4.0 + ref: v0.7.1 m3_lightmeter_resources: git: url: "https://github.com/vodemn/m3_lightmeter_resources" @@ -46,7 +48,7 @@ dev_dependencies: flutter_driver: sdk: flutter flutter_launcher_icons: 0.11.0 - flutter_native_splash: 2.2.16 + flutter_native_splash: 2.3.5 flutter_test: sdk: flutter google_fonts: 3.0.1 @@ -58,7 +60,7 @@ dev_dependencies: flutter: uses-material-design: true - assets: + assets: - assets/camera_stub_image.jpg flutter_intl: diff --git a/screenshots/README.md b/screenshots/README.md new file mode 100644 index 0000000..06df390 --- /dev/null +++ b/screenshots/README.md @@ -0,0 +1,37 @@ +# Screenshots + +The easiest way to create several sets of identical screenshots for Android and iOS is to generate them instead of taking them manually. Generating screenshots will save time and effort while also providing a consistent output. + +## Context + +As a user I want to see the most relevant screenshots in the store, so that I can see the actual state of the app. + +## Screenshot cases + +- Metering screen + + 1. Reflected light metering mode* + 2. Incident light metering mode* ** + 3. Opened ISO picker + +- Settings screen + + 1. Just the screen + 2. Opened metering screen layout features dialog + +- Equipment profiles screen + + 1. Just the screen + 2. Opened equipment profile ISO picker + +> *also in dark mode + +> **Android only + +## Run the generator + +```console +sh screenshots/generate_screenshots.sh +``` + +Screenshots will be stored in the _screenshots/_ folder. diff --git a/screenshots/generate_screenshots.dart b/screenshots/generate_screenshots.dart new file mode 100644 index 0000000..814cfd1 --- /dev/null +++ b/screenshots/generate_screenshots.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lightmeter/data/models/ev_source_type.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +import 'package:lightmeter/data/models/theme_type.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; +import 'package:lightmeter/data/shared_prefs_service.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/theme.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart'; +import 'package:lightmeter/screens/settings/components/metering/components/equipment_profiles/components/equipment_profile_screen/screen_equipment_profile.dart'; +import 'package:lightmeter/screens/settings/screen_settings.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../integration_test/mocks/paid_features_mock.dart'; +import '../integration_test/utils/widget_tester_actions.dart'; + +//https://stackoverflow.com/a/67186625/13167574 + +/// Just a screenshot generator. No expectations here. +void main() { + final binding = IntegrationTestWidgetsFlutterBinding(); + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final Color lightThemeColor = primaryColorsList[5]; + final Color darkThemeColor = primaryColorsList[3]; + + void mockSharedPrefs(ThemeType theme, Color color) { + // ignore: invalid_use_of_visible_for_testing_member + SharedPreferences.setMockInitialValues({ + /// Metering values + UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index, + UserPreferencesService.isoKey: 400, + UserPreferencesService.ndFilterKey: 0, + + /// Metering settings + UserPreferencesService.stopTypeKey: StopType.third.index, + UserPreferencesService.cameraEvCalibrationKey: 0.0, + UserPreferencesService.lightSensorEvCalibrationKey: 0.0, + UserPreferencesService.meteringScreenLayoutKey: json.encode( + { + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.extremeExposurePairs: true, + MeteringScreenLayoutFeature.filmPicker: true, + }.toJson(), + ), + + /// General settings + UserPreferencesService.caffeineKey: true, + UserPreferencesService.hapticsKey: true, + UserPreferencesService.volumeActionKey: VolumeAction.shutter.toString(), + UserPreferencesService.localeKey: 'en', + + /// Theme settings + UserPreferencesService.themeTypeKey: theme.index, + UserPreferencesService.primaryColorKey: color.value, + UserPreferencesService.dynamicColorKey: false, + }); + } + + /// Generates several screenshots with the light theme + testWidgets( + 'Generate light theme screenshots', + (tester) async { + mockSharedPrefs(ThemeType.light, lightThemeColor); + await tester.pumpApplication(); + + await tester.takePhoto(); + await tester.takeScreenshot(binding, '${lightThemeColor.value}_metering_reflected'); + + if (Platform.isAndroid) { + await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor)); + await tester.pumpAndSettle(); + await tester.toggleIncidentMetering(7.3); + await tester.takeScreenshot(binding, '${lightThemeColor.value}_metering_incident'); + } + + await tester.openAnimatedPicker(); + await tester.takeScreenshot(binding, '${lightThemeColor.value}_metering_iso_picker'); + + await tester.tapCancelButton(); + await tester.tap(find.byTooltip(S.current.tooltipOpenSettings)); + await tester.pumpAndSettle(); + await tester.takeScreenshot(binding, '${lightThemeColor.value}_settings'); + + await tester.tapDescendantTextOf(S.current.meteringScreenLayout); + await tester.takeScreenshot(binding, '${lightThemeColor.value}_settings_metering_screen_layout'); + + await tester.tapCancelButton(); + await tester.tapDescendantTextOf(S.current.equipmentProfiles); + await tester.pumpAndSettle(); + await tester.tapDescendantTextOf(mockEquipmentProfiles.first.name); + await tester.pumpAndSettle(); + await tester.takeScreenshot(binding, '${lightThemeColor.value}-equipment_profiles'); + + await tester.tap(find.byIcon(Icons.iso).first); + await tester.pumpAndSettle(); + await tester.takeScreenshot(binding, '${lightThemeColor.value}_equipment_profiles_iso_picker'); + }, + ); + + /// and the additionally the first one with the dark theme + testWidgets( + 'Generate dark theme screenshots', + (tester) async { + mockSharedPrefs(ThemeType.dark, darkThemeColor); + await tester.pumpApplication(); + + await tester.takePhoto(); + await tester.takeScreenshot(binding, '${darkThemeColor.value}_metering_reflected'); + + if (Platform.isAndroid) { + await tester.tap(find.byTooltip(S.current.tooltipUseLightSensor)); + await tester.pumpAndSettle(); + await tester.toggleIncidentMetering(7.3); + await tester.takeScreenshot(binding, '${darkThemeColor.value}_metering_incident'); + } + }, + ); +} + +extension on WidgetTester { + Future takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name) async { + if (Platform.isAndroid) { + await binding.convertFlutterSurfaceToImage(); + await pumpAndSettle(); + } + await binding.takeScreenshot(name); + await pumpAndSettle(); + } +} diff --git a/integration_test/generate_screenshots.sh b/screenshots/generate_screenshots.sh similarity index 83% rename from integration_test/generate_screenshots.sh rename to screenshots/generate_screenshots.sh index 8296e96..c95568e 100644 --- a/integration_test/generate_screenshots.sh +++ b/screenshots/generate_screenshots.sh @@ -2,7 +2,7 @@ flutter drive \ --dart-define="cameraPreviewAspectRatio=240/320" \ --dart-define="cameraStubImage=assets/camera_stub_image.jpg" \ --driver=test_driver/screenshot_driver.dart \ - --target=integration_test/generate_screenshots.dart \ + --target=screenshots/generate_screenshots.dart \ --profile \ --flavor=dev \ --no-dds \ diff --git a/test/application_mock.dart b/test/application_mock.dart new file mode 100644 index 0000000..dbcf260 --- /dev/null +++ b/test/application_mock.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/theme.dart'; + +/// Provides [MaterialApp] with default theme and "en" localization +class WidgetTestApplicationMock extends StatelessWidget { + final Widget child; + + const WidgetTestApplicationMock({required this.child, super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: themeFrom(primaryColorsList[5], Brightness.light), + locale: const Locale('en'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 1.0), + child: child!, + ), + home: Scaffold(body: child), + ); + } +} diff --git a/test/data/light_sensor_service_test.dart b/test/data/light_sensor_service_test.dart index d29b50f..f8be45b 100644 --- a/test/data/light_sensor_service_test.dart +++ b/test/data/light_sensor_service_test.dart @@ -1,9 +1,11 @@ -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:light_sensor/light_sensor.dart'; import 'package:lightmeter/data/light_sensor_service.dart'; import 'package:mocktail/mocktail.dart'; import 'package:platform/platform.dart'; +import '../event_channel_mock.dart'; + class _MockLocalPlatform extends Mock implements LocalPlatform {} void main() { @@ -12,68 +14,44 @@ void main() { late _MockLocalPlatform localPlatform; late LightSensorService service; - const methodChannel = MethodChannel('system_feature'); - // TODO: add event channel mock - //const eventChannel = EventChannel('light.eventChannel'); - setUp(() { localPlatform = _MockLocalPlatform(); service = LightSensorService(localPlatform); }); - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); - }); - group( 'hasSensor()', () { + void setMockSensorAvailability({required bool hasSensor}) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + LightSensor.methodChannel, + (methodCall) async { + switch (methodCall.method) { + case "sensor": + return hasSensor; + default: + return null; + } + }, + ); + } + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + LightSensor.methodChannel, + null, + ); + }); + test('true - Android', () async { when(() => localPlatform.isAndroid).thenReturn(true); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (methodCall) async { - switch (methodCall.method) { - case "sensor": - return true; - default: - return null; - } - }); + setMockSensorAvailability(hasSensor: true); expectLater(service.hasSensor(), completion(true)); }); test('false - Android', () async { when(() => localPlatform.isAndroid).thenReturn(true); - 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 - Android', () async { - when(() => localPlatform.isAndroid).thenReturn(true); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (methodCall) async { - switch (methodCall.method) { - case "sensor": - return null; - default: - return null; - } - }); + setMockSensorAvailability(hasSensor: false); expectLater(service.hasSensor(), completion(false)); }); @@ -85,10 +63,18 @@ void main() { ); group('luxStream', () { - // test('Android', () async { - // when(() => localPlatform.isAndroid).thenReturn(true); - // expect(service.luxStream(), const Stream.empty()); - // }); + test('Android', () async { + when(() => localPlatform.isAndroid).thenReturn(true); + final stream = service.luxStream(); + final List result = []; + final subscription = stream.listen(result.add); + await sendMockVolumeAction(LightSensor.eventChannel.name, 100); + await sendMockVolumeAction(LightSensor.eventChannel.name, 150); + await sendMockVolumeAction(LightSensor.eventChannel.name, 150); + await sendMockVolumeAction(LightSensor.eventChannel.name, 200); + expect(result, [100, 150, 150, 200]); + subscription.cancel(); + }); test('iOS', () async { when(() => localPlatform.isAndroid).thenReturn(false); diff --git a/test/data/models/camera_features_config_test.dart b/test/data/models/camera_features_config_test.dart new file mode 100644 index 0000000..d7261a8 --- /dev/null +++ b/test/data/models/camera_features_config_test.dart @@ -0,0 +1,47 @@ +import 'package:lightmeter/data/models/camera_feature.dart'; +import 'package:test/test.dart'; + +void main() { + group( + 'fromJson()', + () { + test('All keys', () { + expect( + CameraFeaturesConfigJson.fromJson( + { + 'spotMetering': true, + 'histogram': true, + }, + ), + { + CameraFeature.spotMetering: true, + CameraFeature.histogram: true, + }, + ); + }); + + test('Legacy (no spotMetering & histogram)', () { + expect( + CameraFeaturesConfigJson.fromJson({}), + { + CameraFeature.spotMetering: false, + CameraFeature.histogram: false, + }, + ); + }); + }, + ); + + test('toJson()', () { + expect( + { + CameraFeature.spotMetering: true, + CameraFeature.histogram: true, + }.toJson(), + { + 'spotMetering': true, + 'histogram': true, + }, + ); + }); +} diff --git a/test/data/models/exposure_pair_test.dart b/test/data/models/exposure_pair_test.dart new file mode 100644 index 0000000..9143750 --- /dev/null +++ b/test/data/models/exposure_pair_test.dart @@ -0,0 +1,38 @@ +import 'package:lightmeter/data/models/exposure_pair.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:test/test.dart'; + +void main() { + test('toString()', () { + expect( + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first).toString(), + '${ApertureValue.values.first} - ${ShutterSpeedValue.values.first}', + ); + }); + + test('==', () { + expect( + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first) == + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first), + true, + ); + expect( + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first) == + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.last), + false, + ); + }); + + test('hashCode', () { + expect( + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first).hashCode == + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first).hashCode, + true, + ); + expect( + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first).hashCode == + ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.last).hashCode, + false, + ); + }); +} diff --git a/test/data/models/metering_screen_layout_config_test.dart b/test/data/models/metering_screen_layout_config_test.dart index 9e9393e..f3216ba 100644 --- a/test/data/models/metering_screen_layout_config_test.dart +++ b/test/data/models/metering_screen_layout_config_test.dart @@ -18,7 +18,6 @@ void main() { { MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: true, MeteringScreenLayoutFeature.equipmentProfiles: true, }, ); @@ -35,7 +34,6 @@ void main() { { MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: false, - MeteringScreenLayoutFeature.histogram: true, MeteringScreenLayoutFeature.equipmentProfiles: true, }, ); @@ -53,7 +51,6 @@ void main() { { MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: false, - MeteringScreenLayoutFeature.histogram: false, MeteringScreenLayoutFeature.equipmentProfiles: true, }, ); @@ -67,13 +64,11 @@ void main() { MeteringScreenLayoutFeature.equipmentProfiles: true, MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: true, }.toJson(), { - '3': true, - '0': true, - '1': true, - '2': true, + 'equipmentProfiles': true, + 'extremeExposurePairs': true, + 'filmPicker': true, }, ); }); diff --git a/test/data/models/supported_locale_test.dart b/test/data/models/supported_locale_test.dart index 6d92154..83f7489 100644 --- a/test/data/models/supported_locale_test.dart +++ b/test/data/models/supported_locale_test.dart @@ -6,11 +6,13 @@ void main() { expect(SupportedLocale.en.intlName, 'en'); expect(SupportedLocale.fr.intlName, 'fr'); expect(SupportedLocale.ru.intlName, 'ru'); + expect(SupportedLocale.zh.intlName, 'zh'); }); test('localizedName', () { expect(SupportedLocale.en.localizedName, 'English'); expect(SupportedLocale.fr.localizedName, 'Français'); expect(SupportedLocale.ru.localizedName, 'Русский'); + expect(SupportedLocale.zh.localizedName, '简体中文'); }); } diff --git a/test/data/shared_prefs_service_test.dart b/test/data/shared_prefs_service_test.dart index 0896626..96f4e1e 100644 --- a/test/data/shared_prefs_service_test.dart +++ b/test/data/shared_prefs_service_test.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/camera_feature.dart'; import 'package:lightmeter/data/models/ev_source_type.dart'; import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; import 'package:lightmeter/data/models/supported_locale.dart'; import 'package:lightmeter/data/models/theme_type.dart'; +import 'package:lightmeter/data/models/volume_action.dart'; import 'package:lightmeter/data/shared_prefs_service.dart'; import 'package:lightmeter/res/theme.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; @@ -99,8 +101,7 @@ void main() { }); test('set', () { - when(() => sharedPreferences.setInt(UserPreferencesService.isoKey, 200)) - .thenAnswer((_) => Future.value(true)); + 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); }); @@ -118,8 +119,7 @@ void main() { }); test('set', () { - when(() => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, 0)) - .thenAnswer((_) => Future.value(true)); + when(() => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, 0)).thenAnswer((_) => Future.value(true)); service.ndFilter = const NdValue(0); verify(() => sharedPreferences.setInt(UserPreferencesService.ndFilterKey, 0)).called(1); }); @@ -175,8 +175,7 @@ void main() { }); test('set', () { - when(() => sharedPreferences.setInt(UserPreferencesService.stopTypeKey, 0)) - .thenAnswer((_) => Future.value(true)); + when(() => sharedPreferences.setInt(UserPreferencesService.stopTypeKey, 0)).thenAnswer((_) => Future.value(true)); service.stopType = StopType.full; verify(() => sharedPreferences.setInt(UserPreferencesService.stopTypeKey, 0)).called(1); }); @@ -193,12 +192,11 @@ void main() { MeteringScreenLayoutFeature.extremeExposurePairs: true, MeteringScreenLayoutFeature.filmPicker: true, MeteringScreenLayoutFeature.equipmentProfiles: true, - MeteringScreenLayoutFeature.histogram: true, }, ); }); - test('get', () { + test('get (legacy)', () { when( () => sharedPreferences.getString(UserPreferencesService.meteringScreenLayoutKey), ).thenReturn("""{"0":false,"1":true}"""); @@ -208,7 +206,20 @@ void main() { MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: true, MeteringScreenLayoutFeature.equipmentProfiles: true, - MeteringScreenLayoutFeature.histogram: true, + }, + ); + }); + + test('get', () { + when( + () => sharedPreferences.getString(UserPreferencesService.meteringScreenLayoutKey), + ).thenReturn("""{"extremeExposurePairs":false,"filmPicker":true}"""); + expect( + service.meteringScreenLayout, + { + MeteringScreenLayoutFeature.extremeExposurePairs: false, + MeteringScreenLayoutFeature.filmPicker: true, + MeteringScreenLayoutFeature.equipmentProfiles: true, }, ); }); @@ -217,19 +228,62 @@ void main() { when( () => sharedPreferences.setString( UserPreferencesService.meteringScreenLayoutKey, - """{"0":false,"1":true,"2":true,"3":true}""", + """{"extremeExposurePairs":false,"filmPicker":true,"equipmentProfiles":true}""", ), ).thenAnswer((_) => Future.value(true)); service.meteringScreenLayout = { MeteringScreenLayoutFeature.extremeExposurePairs: false, MeteringScreenLayoutFeature.filmPicker: true, - MeteringScreenLayoutFeature.histogram: true, MeteringScreenLayoutFeature.equipmentProfiles: true, }; verify( () => sharedPreferences.setString( UserPreferencesService.meteringScreenLayoutKey, - """{"0":false,"1":true,"2":true,"3":true}""", + """{"extremeExposurePairs":false,"filmPicker":true,"equipmentProfiles":true}""", + ), + ).called(1); + }); + }); + + group('cameraFeatures', () { + test('get default', () { + when(() => sharedPreferences.getString(UserPreferencesService.cameraFeaturesKey)).thenReturn(null); + expect( + service.cameraFeatures, + { + CameraFeature.spotMetering: false, + CameraFeature.histogram: false, + }, + ); + }); + + test('get', () { + when(() => sharedPreferences.getString(UserPreferencesService.cameraFeaturesKey)) + .thenReturn("""{"spotMetering":false,"histogram":true}"""); + expect( + service.cameraFeatures, + { + CameraFeature.spotMetering: false, + CameraFeature.histogram: true, + }, + ); + }); + + test('set', () { + when( + () => sharedPreferences.setString( + UserPreferencesService.cameraFeaturesKey, + """{"spotMetering":false,"histogram":true}""", + ), + ).thenAnswer((_) => Future.value(true)); + service.cameraFeatures = { + CameraFeature.spotMetering: false, + CameraFeature.histogram: true, + }; + verify( + () => sharedPreferences.setString( + UserPreferencesService.cameraFeaturesKey, + """{"spotMetering":false,"histogram":true}""", ), ).called(1); }); @@ -253,6 +307,26 @@ void main() { verify(() => sharedPreferences.setBool(UserPreferencesService.hapticsKey, false)).called(1); }); }); + group('volumeAction', () { + test('get default', () { + when(() => sharedPreferences.getBool(UserPreferencesService.volumeActionKey)).thenReturn(null); + expect(service.volumeAction, VolumeAction.shutter); + }); + + test('get', () { + when(() => sharedPreferences.getString(UserPreferencesService.volumeActionKey)) + .thenReturn(VolumeAction.shutter.toString()); + expect(service.volumeAction, VolumeAction.shutter); + }); + + test('set', () { + when(() => sharedPreferences.setString(UserPreferencesService.volumeActionKey, VolumeAction.shutter.toString())) + .thenAnswer((_) => Future.value(true)); + service.volumeAction = VolumeAction.shutter; + verify(() => sharedPreferences.setString(UserPreferencesService.volumeActionKey, VolumeAction.shutter.toString())) + .called(1); + }); + }); group('locale', () { test('get default', () { @@ -261,8 +335,7 @@ void main() { }); test('get', () { - when(() => sharedPreferences.getString(UserPreferencesService.localeKey)) - .thenReturn('SupportedLocale.ru'); + when(() => sharedPreferences.getString(UserPreferencesService.localeKey)).thenReturn('SupportedLocale.ru'); expect(service.locale, SupportedLocale.ru); }); @@ -279,14 +352,12 @@ void main() { group('cameraEvCalibration', () { test('get default', () { - when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey)) - .thenReturn(null); + when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey)).thenReturn(null); expect(service.cameraEvCalibration, 0.0); }); test('get', () { - when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey)) - .thenReturn(2.0); + when(() => sharedPreferences.getDouble(UserPreferencesService.cameraEvCalibrationKey)).thenReturn(2.0); expect(service.cameraEvCalibration, 2.0); }); @@ -303,14 +374,12 @@ void main() { group('lightSensorEvCalibration', () { test('get default', () { - when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey)) - .thenReturn(null); + when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey)).thenReturn(null); expect(service.lightSensorEvCalibration, 0.0); }); test('get', () { - when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey)) - .thenReturn(2.0); + when(() => sharedPreferences.getDouble(UserPreferencesService.lightSensorEvCalibrationKey)).thenReturn(2.0); expect(service.lightSensorEvCalibration, 2.0); }); @@ -354,8 +423,7 @@ void main() { }); test('get', () { - when(() => sharedPreferences.getInt(UserPreferencesService.primaryColorKey)) - .thenReturn(0xff9c27b0); + when(() => sharedPreferences.getInt(UserPreferencesService.primaryColorKey)).thenReturn(0xff9c27b0); expect(service.primaryColor, primaryColorsList[2]); }); @@ -372,14 +440,12 @@ void main() { group('dynamicColor', () { test('get default', () { - when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey)) - .thenReturn(null); + when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey)).thenReturn(null); expect(service.dynamicColor, false); }); test('get', () { - when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey)) - .thenReturn(true); + when(() => sharedPreferences.getBool(UserPreferencesService.dynamicColorKey)).thenReturn(true); expect(service.dynamicColor, true); }); @@ -387,8 +453,7 @@ void main() { when(() => sharedPreferences.setBool(UserPreferencesService.dynamicColorKey, false)) .thenAnswer((_) => Future.value(true)); service.dynamicColor = false; - verify(() => sharedPreferences.setBool(UserPreferencesService.dynamicColorKey, false)) - .called(1); + verify(() => sharedPreferences.setBool(UserPreferencesService.dynamicColorKey, false)).called(1); }); }); } diff --git a/test/data/volume_events_service_test.dart b/test/data/volume_events_service_test.dart index f9ef3d6..f574e50 100644 --- a/test/data/volume_events_service_test.dart +++ b/test/data/volume_events_service_test.dart @@ -4,6 +4,8 @@ import 'package:lightmeter/data/volume_events_service.dart'; import 'package:mocktail/mocktail.dart'; import 'package:platform/platform.dart'; +import '../event_channel_mock.dart'; + class _MockLocalPlatform extends Mock implements LocalPlatform {} void main() { @@ -60,10 +62,18 @@ void main() { }); group('volumeButtonsEventStream', () { - // test('Android', () async { - // when(() => localPlatform.isAndroid).thenReturn(true); - // expect(service.volumeButtonsEventStream(), const Stream.empty()); - // }); + test('Android', () async { + when(() => localPlatform.isAndroid).thenReturn(true); + final stream = service.volumeButtonsEventStream(); + final List result = []; + final subscription = stream.listen(result.add); + await sendMockVolumeAction(VolumeEventsService.volumeEventsChannel.name, 24); + await sendMockVolumeAction(VolumeEventsService.volumeEventsChannel.name, 25); + await sendMockVolumeAction(VolumeEventsService.volumeEventsChannel.name, 20); + await sendMockVolumeAction(VolumeEventsService.volumeEventsChannel.name, 24); + expect(result, [24, 25, 24]); + subscription.cancel(); + }); test('iOS', () async { when(() => localPlatform.isAndroid).thenReturn(false); diff --git a/test/event_channel_mock.dart b/test/event_channel_mock.dart new file mode 100644 index 0000000..ee8cbc7 --- /dev/null +++ b/test/event_channel_mock.dart @@ -0,0 +1,10 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future sendMockVolumeAction(String channelName, int keyCode) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + channelName, + const StandardMethodCodec().encodeSuccessEnvelope(keyCode), + (ByteData? data) {}, + ); +} diff --git a/test/function_mock.dart b/test/function_mock.dart new file mode 100644 index 0000000..8a4abfe --- /dev/null +++ b/test/function_mock.dart @@ -0,0 +1,7 @@ +import 'package:mocktail/mocktail.dart'; + +class _ValueChanged { + void onChanged(T value) {} +} + +class MockValueChanged extends Mock implements _ValueChanged {} diff --git a/test/interactors/metering_interactor_test.dart b/test/interactors/metering_interactor_test.dart index 1f0b05a..083a116 100644 --- a/test/interactors/metering_interactor_test.dart +++ b/test/interactors/metering_interactor_test.dart @@ -236,7 +236,7 @@ void main() { ); group( - 'Haptics', + 'Light sensor', () { test('hasAmbientLightSensor() - true', () async { when(() => mockLightSensorService.hasSensor()).thenAnswer((_) async => true); diff --git a/test/providers/equipment_profile_provider_test.dart b/test/providers/equipment_profile_provider_test.dart new file mode 100644 index 0000000..fb329cd --- /dev/null +++ b/test/providers/equipment_profile_provider_test.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockIAPStorageService extends Mock implements IAPStorageService {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _MockIAPStorageService storageService; + + setUpAll(() { + storageService = _MockIAPStorageService(); + }); + + tearDown(() { + reset(storageService); + }); + + Future pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async { + await tester.pumpWidget( + IAPProducts( + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + status: productStatus, + ), + ], + child: EquipmentProfileProvider( + storageService: storageService, + child: const _Application(), + ), + ), + ); + } + + void expectEquipmentProfilesCount(int count) { + expect(find.text('Equipment profiles count: $count'), findsOneWidget); + } + + void expectSelectedEquipmentProfileName(String name) { + expect(find.text('Selected equipment profile: $name'), findsOneWidget); + } + + group( + 'EquipmentProfileProvider dependency on IAPProductStatus', + () { + setUp(() { + when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles.first.id); + when(() => storageService.equipmentProfiles).thenReturn(_customProfiles); + }); + + testWidgets( + 'IAPProductStatus.purchased - show all saved profiles', + (tester) async { + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectEquipmentProfilesCount(3); + expectSelectedEquipmentProfileName(_customProfiles.first.name); + }, + ); + + testWidgets( + 'IAPProductStatus.purchasable - show only default', + (tester) async { + await pumpTestWidget(tester, IAPProductStatus.purchasable); + expectEquipmentProfilesCount(1); + expectSelectedEquipmentProfileName(''); + }, + ); + + testWidgets( + 'IAPProductStatus.pending - show only default', + (tester) async { + await pumpTestWidget(tester, IAPProductStatus.pending); + expectEquipmentProfilesCount(1); + expectSelectedEquipmentProfileName(''); + }, + ); + }, + ); + + group('EquipmentProfileProvider CRUD', () { + testWidgets( + 'Add', + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn([]); + when(() => storageService.selectedEquipmentProfileId).thenReturn(''); + + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectEquipmentProfilesCount(1); + expectSelectedEquipmentProfileName(''); + + await tester.tap(find.byKey(_Application.addProfileButtonKey)); + await tester.pump(); + expectEquipmentProfilesCount(2); + expectSelectedEquipmentProfileName(''); + + verifyNever(() => storageService.selectedEquipmentProfileId = ''); + verify(() => storageService.equipmentProfiles = any>()).called(1); + }, + ); + + testWidgets( + 'Add from', + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); + when(() => storageService.selectedEquipmentProfileId).thenReturn(''); + + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectEquipmentProfilesCount(3); + expectSelectedEquipmentProfileName(''); + + await tester.tap(find.byKey(_Application.addFromProfileButtonKey(_customProfiles[0].id))); + await tester.pump(); + expectEquipmentProfilesCount(4); + expectSelectedEquipmentProfileName(''); + + verifyNever(() => storageService.selectedEquipmentProfileId = ''); + verify(() => storageService.equipmentProfiles = any>()).called(1); + }, + ); + + testWidgets( + 'Edit selected', + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); + when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); + + await pumpTestWidget(tester, IAPProductStatus.purchased); + + /// Change the name & limit ISO values of the both added profiles + await tester.tap(find.byKey(_Application.updateProfileButtonKey(_customProfiles[0].id))); + await tester.pumpAndSettle(); + expectEquipmentProfilesCount(3); + expectSelectedEquipmentProfileName("${_customProfiles[0].name} updated"); + + verifyNever(() => storageService.selectedEquipmentProfileId = _customProfiles[0].id); + verify(() => storageService.equipmentProfiles = any>()).called(1); + }, + ); + + testWidgets( + 'Delete selected', + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); + when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); + + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectEquipmentProfilesCount(3); + expectSelectedEquipmentProfileName(_customProfiles[0].name); + + /// Delete the selected profile + await tester.tap(find.byKey(_Application.deleteProfileButtonKey(_customProfiles[0].id))); + await tester.pumpAndSettle(); + expectEquipmentProfilesCount(2); + expectSelectedEquipmentProfileName(''); + + verify(() => storageService.selectedEquipmentProfileId = '').called(1); + verify(() => storageService.equipmentProfiles = any>()).called(1); + }, + ); + + testWidgets( + 'Delete not selected', + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); + when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); + + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectEquipmentProfilesCount(3); + expectSelectedEquipmentProfileName(_customProfiles[0].name); + + /// Delete the not selected profile + await tester.tap(find.byKey(_Application.deleteProfileButtonKey(_customProfiles[1].id))); + await tester.pumpAndSettle(); + expectEquipmentProfilesCount(2); + expectSelectedEquipmentProfileName(_customProfiles[0].name); + + verifyNever(() => storageService.selectedEquipmentProfileId = ''); + verify(() => storageService.equipmentProfiles = any>()).called(1); + }, + ); + + testWidgets( + 'Select', + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); + when(() => storageService.selectedEquipmentProfileId).thenReturn(''); + + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectEquipmentProfilesCount(3); + expectSelectedEquipmentProfileName(''); + + /// Select the 1st custom profile + await tester.tap(find.byKey(_Application.setProfileButtonKey(_customProfiles[0].id))); + await tester.pumpAndSettle(); + expectEquipmentProfilesCount(3); + expectSelectedEquipmentProfileName(_customProfiles[0].name); + + verify(() => storageService.selectedEquipmentProfileId = _customProfiles[0].id).called(1); + verifyNever(() => storageService.equipmentProfiles = any>()); + }, + ); + }); +} + +class _Application extends StatelessWidget { + const _Application(); + + static ValueKey get addProfileButtonKey => const ValueKey('addProfileButtonKey'); + static ValueKey addFromProfileButtonKey(String id) => ValueKey('addFromProfileButtonKey$id'); + static ValueKey setProfileButtonKey(String id) => ValueKey('setProfileButtonKey$id'); + static ValueKey updateProfileButtonKey(String id) => ValueKey('updateProfileButtonKey$id'); + static ValueKey deleteProfileButtonKey(String id) => ValueKey('deleteProfileButtonKey$id'); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('IAPProviders test')), + body: Center( + child: Column( + children: [ + Text("Equipment profiles count: ${EquipmentProfiles.of(context).length}"), + Text("Selected equipment profile: ${EquipmentProfiles.selectedOf(context).name}"), + ElevatedButton( + key: addProfileButtonKey, + onPressed: () { + EquipmentProfileProvider.of(context).addProfile('Test added'); + }, + child: const Text("Add"), + ), + ...EquipmentProfiles.of(context).map((e) => _equipmentProfilesCrudRow(context, e)), + ], + ), + ), + ), + ); + } + + Widget _equipmentProfilesCrudRow(BuildContext context, EquipmentProfile profile) { + return Row( + children: [ + ElevatedButton( + key: setProfileButtonKey(profile.id), + onPressed: () { + EquipmentProfileProvider.of(context).setProfile(profile); + }, + child: const Text("Set"), + ), + ElevatedButton( + key: addFromProfileButtonKey(profile.id), + onPressed: () { + EquipmentProfileProvider.of(context).addProfile('Test from ${profile.name}', profile); + }, + child: const Text("Add from"), + ), + ElevatedButton( + key: updateProfileButtonKey(profile.id), + onPressed: () { + EquipmentProfileProvider.of(context).updateProfile( + profile.copyWith( + name: '${profile.name} updated', + isoValues: _customProfiles.first.isoValues, + ), + ); + }, + child: const Text("Update"), + ), + ElevatedButton( + key: deleteProfileButtonKey(profile.id), + onPressed: () { + EquipmentProfileProvider.of(context).deleteProfile(profile); + }, + child: const Text("Delete"), + ), + ], + ); + } +} + +final List _customProfiles = [ + const EquipmentProfile( + id: '1', + name: 'Test 1', + apertureValues: [ + ApertureValue(4.0, StopType.full), + ApertureValue(4.5, StopType.third), + ApertureValue(4.8, StopType.half), + ApertureValue(5.0, StopType.third), + ApertureValue(5.6, StopType.full), + ApertureValue(6.3, StopType.third), + ApertureValue(6.7, StopType.half), + ApertureValue(7.1, StopType.third), + ApertureValue(8, StopType.full), + ], + ndValues: [ + NdValue(0), + NdValue(2), + NdValue(4), + NdValue(8), + NdValue(16), + NdValue(32), + NdValue(64), + ], + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: [ + IsoValue(100, StopType.full), + IsoValue(125, StopType.third), + IsoValue(160, StopType.third), + IsoValue(200, StopType.full), + IsoValue(250, StopType.third), + IsoValue(320, StopType.third), + IsoValue(400, StopType.full), + ], + ), + const EquipmentProfile( + id: '2', + name: 'Test 2', + apertureValues: [ + ApertureValue(4.0, StopType.full), + ApertureValue(4.5, StopType.third), + ApertureValue(4.8, StopType.half), + ApertureValue(5.0, StopType.third), + ApertureValue(5.6, StopType.full), + ApertureValue(6.3, StopType.third), + ApertureValue(6.7, StopType.half), + ApertureValue(7.1, StopType.third), + ApertureValue(8, StopType.full), + ], + ndValues: [ + NdValue(0), + NdValue(2), + NdValue(4), + NdValue(8), + NdValue(16), + NdValue(32), + NdValue(64), + ], + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: [ + IsoValue(100, StopType.full), + IsoValue(125, StopType.third), + IsoValue(160, StopType.third), + IsoValue(200, StopType.full), + IsoValue(250, StopType.third), + IsoValue(320, StopType.third), + IsoValue(400, StopType.full), + ], + ), +]; diff --git a/test/providers/films_provider_test.dart b/test/providers/films_provider_test.dart new file mode 100644 index 0000000..760ca43 --- /dev/null +++ b/test/providers/films_provider_test.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/providers/films_provider.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockIAPStorageService extends Mock implements IAPStorageService {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _MockIAPStorageService mockIAPStorageService; + + setUpAll(() { + mockIAPStorageService = _MockIAPStorageService(); + }); + + tearDown(() { + reset(mockIAPStorageService); + }); + + Future pumpTestWidget(WidgetTester tester, IAPProductStatus productStatus) async { + await tester.pumpWidget( + IAPProducts( + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + status: productStatus, + ) + ], + child: FilmsProvider( + storageService: mockIAPStorageService, + availableFilms: mockFilms, + child: const _Application(), + ), + ), + ); + } + + void expectFilmsCount(int count) { + expect(find.text('Films count: $count'), findsOneWidget); + } + + void expectFilmsInUseCount(int count) { + expect(find.text('Films in use count: $count'), findsOneWidget); + } + + void expectSelectedFilmName(String name) { + expect(find.text('Selected film: $name'), findsOneWidget); + } + + group( + 'FilmsProvider dependency on IAPProductStatus', + () { + setUp(() { + when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first); + when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); + }); + + testWidgets( + 'IAPProductStatus.purchased - show all saved films', + (tester) async { + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount(mockFilms.length + 1); + expectSelectedFilmName(mockFilms.first.name); + }, + ); + + testWidgets( + 'IAPProductStatus.purchasable - show only default', + (tester) async { + await pumpTestWidget(tester, IAPProductStatus.purchasable); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount(1); + expectSelectedFilmName(''); + }, + ); + + testWidgets( + 'IAPProductStatus.pending - show only default', + (tester) async { + await pumpTestWidget(tester, IAPProductStatus.pending); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount(1); + expectSelectedFilmName(''); + }, + ); + }, + ); + + group( + 'FilmsProvider CRUD', + () { + testWidgets( + 'Select films in use', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); + when(() => mockIAPStorageService.filmsInUse).thenReturn([]); + + /// Init + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount(1); + expectSelectedFilmName(''); + + /// Select all filmsInUse + await tester.tap(find.byKey(_Application.saveFilmsButtonKey(0))); + await tester.pumpAndSettle(); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount(mockFilms.length + 1); + expectSelectedFilmName(''); + + verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(0).toList()).called(1); + verifyNever(() => mockIAPStorageService.selectedFilm = const Film.other()); + }, + ); + + testWidgets( + 'Select film', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); + when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); + + /// Init + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount(mockFilms.length + 1); + expectSelectedFilmName(''); + + /// Select all filmsInUse + await tester.tap(find.byKey(_Application.setFilmButtonKey(0))); + await tester.pumpAndSettle(); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount(mockFilms.length + 1); + expectSelectedFilmName(mockFilms.first.name); + + verifyNever(() => mockIAPStorageService.filmsInUse = any>()); + verify(() => mockIAPStorageService.selectedFilm = mockFilms.first).called(1); + }, + ); + + group( + 'Coming from free app', + () { + testWidgets( + 'Has selected film', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms[2]); + when(() => mockIAPStorageService.filmsInUse).thenReturn([]); + + /// Init + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectFilmsInUseCount(1); + expectSelectedFilmName(''); + + verifyNever(() => mockIAPStorageService.filmsInUse = any>()); + verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1); + }, + ); + + testWidgets( + 'None film selected', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); + when(() => mockIAPStorageService.filmsInUse).thenReturn([]); + + /// Init + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectFilmsInUseCount(1); + expectSelectedFilmName(''); + + verifyNever(() => mockIAPStorageService.filmsInUse = any>()); + verifyNever(() => mockIAPStorageService.selectedFilm = const Film.other()); + }, + ); + }, + ); + + testWidgets( + 'Discard selected (by filmsInUse list update)', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(mockFilms.first); + when(() => mockIAPStorageService.filmsInUse).thenReturn(mockFilms); + + /// Init + await pumpTestWidget(tester, IAPProductStatus.purchased); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount(mockFilms.length + 1); + expectSelectedFilmName(mockFilms.first.name); + + /// Select all filmsInUse except the first one + await tester.tap(find.byKey(_Application.saveFilmsButtonKey(1))); + await tester.pumpAndSettle(); + expectFilmsCount(mockFilms.length + 1); + expectFilmsInUseCount((mockFilms.length - 1) + 1); + expectSelectedFilmName(''); + + verify(() => mockIAPStorageService.filmsInUse = mockFilms.skip(1).toList()).called(1); + verify(() => mockIAPStorageService.selectedFilm = const Film.other()).called(1); + }, + ); + }, + ); +} + +class _Application extends StatelessWidget { + const _Application(); + + static ValueKey saveFilmsButtonKey(int index) => ValueKey('saveFilmsButtonKey$index'); + static ValueKey setFilmButtonKey(int index) => ValueKey('setFilmButtonKey$index'); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Column( + children: [ + Text("Films count: ${Films.of(context).length}"), + Text("Films in use count: ${Films.inUseOf(context).length}"), + Text("Selected film: ${Films.selectedOf(context).name}"), + _filmRow(context, 0), + _filmRow(context, 1), + ], + ), + ), + ), + ); + } + + Widget _filmRow(BuildContext context, int index) { + return Row( + children: [ + ElevatedButton( + key: saveFilmsButtonKey(index), + onPressed: () { + FilmsProvider.of(context).saveFilms(mockFilms.skip(index).toList()); + }, + child: const Text("Save filmsInUse"), + ), + ElevatedButton( + key: setFilmButtonKey(index), + onPressed: () { + FilmsProvider.of(context).setFilm(mockFilms[index]); + }, + child: const Text("Set film"), + ), + ], + ); + } +} + +const mockFilms = [_MockFilm2x(), _MockFilm3x(), _MockFilm4x()]; + +class _MockFilm2x extends Film { + const _MockFilm2x() : super('Mock film 2x', 400); + + @override + double reciprocityFormula(double t) => t * 2; +} + +class _MockFilm3x extends Film { + const _MockFilm3x() : super('Mock film 3x', 800); + + @override + double reciprocityFormula(double t) => t * 3; +} + +class _MockFilm4x extends Film { + const _MockFilm4x() : super('Mock film 4x', 1600); + + @override + double reciprocityFormula(double t) => t * 4; +} diff --git a/test/providers/remote_config_provider_test.dart b/test/providers/remote_config_provider_test.dart new file mode 100644 index 0000000..a215cbe --- /dev/null +++ b/test/providers/remote_config_provider_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/feature.dart'; +import 'package:lightmeter/data/remote_config_service.dart'; +import 'package:lightmeter/providers/remote_config_provider.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockRemoteConfigService extends Mock implements RemoteConfigService {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _MockRemoteConfigService mockRemoteConfigService; + + setUpAll(() { + mockRemoteConfigService = _MockRemoteConfigService(); + }); + + setUp(() { + when(() => mockRemoteConfigService.fetchConfig()).thenAnswer((_) async {}); + when(() => mockRemoteConfigService.getValue(Feature.unlockProFeaturesText)).thenReturn(false); + when(() => mockRemoteConfigService.getAll()).thenReturn({Feature.unlockProFeaturesText: false}); + }); + + tearDown(() { + reset(mockRemoteConfigService); + }); + + Future pumpTestWidget(WidgetTester tester) async { + await tester.pumpWidget( + RemoteConfigProvider( + remoteConfigService: mockRemoteConfigService, + child: const _Application(), + ), + ); + } + + testWidgets( + 'RemoteConfigProvider init', + (tester) async { + when(() => mockRemoteConfigService.onConfigUpdated()).thenAnswer((_) => const Stream.empty()); + + await pumpTestWidget(tester); + expect(find.text('unlockProFeaturesText: false'), findsOneWidget); + }, + ); + + testWidgets( + 'RemoteConfigProvider updates stream', + (tester) async { + final StreamController> remoteConfigUpdateController = StreamController>(); + when(() => mockRemoteConfigService.onConfigUpdated()).thenAnswer((_) => remoteConfigUpdateController.stream); + + await pumpTestWidget(tester); + expect(find.text('unlockProFeaturesText: false'), findsOneWidget); + + when(() => mockRemoteConfigService.getValue(Feature.unlockProFeaturesText)).thenReturn(true); + remoteConfigUpdateController.add({Feature.unlockProFeaturesText}); + await tester.pumpAndSettle(); + expect(find.text('unlockProFeaturesText: true'), findsOneWidget); + + await remoteConfigUpdateController.close(); + }, + ); + + test('RemoteConfig.updateShouldNotifyDependent', () { + const config = RemoteConfig(config: {Feature.unlockProFeaturesText: false}, child: SizedBox()); + expect( + config.updateShouldNotifyDependent(config, {}), + false, + ); + expect( + config.updateShouldNotifyDependent( + const RemoteConfig(config: {Feature.unlockProFeaturesText: false}, child: SizedBox()), + {Feature.unlockProFeaturesText}, + ), + false, + ); + expect( + config.updateShouldNotifyDependent( + const RemoteConfig(config: {Feature.unlockProFeaturesText: true}, child: SizedBox()), + {Feature.unlockProFeaturesText}, + ), + true, + ); + }); +} + +class _Application extends StatelessWidget { + const _Application(); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Text( + "${Feature.unlockProFeaturesText.name}: ${RemoteConfig.isEnabled(context, Feature.unlockProFeaturesText)}", + ), + ), + ), + ); + } +} diff --git a/test/providers/user_preferences_provider_test.dart b/test/providers/user_preferences_provider_test.dart new file mode 100644 index 0000000..b6dc2ae --- /dev/null +++ b/test/providers/user_preferences_provider_test.dart @@ -0,0 +1,423 @@ +import 'package:dynamic_color/test_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/camera_feature.dart'; +import 'package:lightmeter/data/models/dynamic_colors_state.dart'; +import 'package:lightmeter/data/models/ev_source_type.dart'; +import 'package:lightmeter/data/models/metering_screen_layout_config.dart'; +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/user_preferences_provider.dart'; +import 'package:lightmeter/res/theme.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:material_color_utilities/material_color_utilities.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockUserPreferencesService extends Mock implements UserPreferencesService {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _MockUserPreferencesService mockUserPreferencesService; + + setUpAll(() { + mockUserPreferencesService = _MockUserPreferencesService(); + }); + + setUp(() { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third); + when(() => mockUserPreferencesService.meteringScreenLayout).thenReturn({ + MeteringScreenLayoutFeature.extremeExposurePairs: true, + MeteringScreenLayoutFeature.filmPicker: true, + MeteringScreenLayoutFeature.equipmentProfiles: true, + }); + when(() => mockUserPreferencesService.cameraFeatures).thenReturn({ + CameraFeature.spotMetering: true, + CameraFeature.histogram: true, + }); + when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en); + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); + when(() => mockUserPreferencesService.primaryColor).thenReturn(primaryColorsList[5]); + when(() => mockUserPreferencesService.dynamicColor).thenReturn(false); + }); + + tearDown(() { + reset(mockUserPreferencesService); + }); + + Future pumpTestWidget( + WidgetTester tester, { + bool hasLightSensor = true, + required WidgetBuilder builder, + }) async { + await tester.pumpWidget( + UserPreferencesProvider( + hasLightSensor: hasLightSensor, + userPreferencesService: mockUserPreferencesService, + child: _Application(builder: builder), + ), + ); + } + + group('[evSourceType]', () { + Future pumpEvTestApplication(WidgetTester tester, {required bool hasLightSensor}) async { + await pumpTestWidget( + tester, + hasLightSensor: hasLightSensor, + builder: (context) => Column( + children: [ + Text('EV source type: ${UserPreferencesProvider.evSourceTypeOf(context)}'), + ElevatedButton( + onPressed: UserPreferencesProvider.of(context).toggleEvSourceType, + child: const Text('toggleEvSourceType'), + ), + ], + ), + ); + } + + void expectEvSource(EvSourceType evSourceType) { + expect(find.text("EV source type: $evSourceType"), findsOneWidget); + } + + testWidgets( + 'Init evSourceType when has sensor & stored sensor', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.sensor); + await pumpEvTestApplication(tester, hasLightSensor: true); + expectEvSource(EvSourceType.sensor); + }, + ); + + testWidgets( + 'Init evSourceType when has no sensor & stored camera', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + await pumpEvTestApplication(tester, hasLightSensor: false); + expectEvSource(EvSourceType.camera); + }, + ); + + testWidgets( + 'Init evSourceType when has no sensor & stored sensor -> Reset to camera', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.sensor); + await pumpEvTestApplication(tester, hasLightSensor: false); + expectEvSource(EvSourceType.camera); + }, + ); + + testWidgets( + 'Try toggleEvSourceType() when has no sensor', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + await pumpEvTestApplication(tester, hasLightSensor: false); + await tester.tap(find.text('toggleEvSourceType')); + await tester.pumpAndSettle(); + verifyNever(() => mockUserPreferencesService.evSourceType = EvSourceType.sensor); + }, + ); + + testWidgets( + 'Try toggleEvSourceType() when has sensor', + (tester) async { + when(() => mockUserPreferencesService.evSourceType).thenReturn(EvSourceType.camera); + await pumpEvTestApplication(tester, hasLightSensor: true); + + await tester.tap(find.text('toggleEvSourceType')); + await tester.pumpAndSettle(); + expectEvSource(EvSourceType.sensor); + verify(() => mockUserPreferencesService.evSourceType = EvSourceType.sensor).called(1); + + await tester.tap(find.text('toggleEvSourceType')); + await tester.pumpAndSettle(); + expectEvSource(EvSourceType.camera); + verify(() => mockUserPreferencesService.evSourceType = EvSourceType.camera).called(1); + }, + ); + }); + + testWidgets( + 'Set different stop type', + (tester) async { + when(() => mockUserPreferencesService.stopType).thenReturn(StopType.third); + await pumpTestWidget( + tester, + builder: (context) => Column( + children: [ + Text('Stop type: ${UserPreferencesProvider.stopTypeOf(context)}'), + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setStopType(StopType.full), + child: const Text('setStopType'), + ), + ], + ), + ); + expect(find.text("Stop type: ${StopType.third}"), findsOneWidget); + + await tester.tap(find.text('setStopType')); + await tester.pumpAndSettle(); + expect(find.text("Stop type: ${StopType.full}"), findsOneWidget); + verify(() => mockUserPreferencesService.stopType = StopType.full).called(1); + }, + ); + + testWidgets( + 'Set metering screen layout config', + (tester) async { + await pumpTestWidget( + tester, + builder: (context) { + final config = UserPreferencesProvider.meteringScreenConfigOf(context); + return Column( + children: [ + ...List.generate( + config.length, + (index) => Text('${config.keys.toList()[index]}: ${config.values.toList()[index]}'), + ), + ...List.generate( + MeteringScreenLayoutFeature.values.length, + (index) => Text( + '${MeteringScreenLayoutFeature.values[index]}: ${UserPreferencesProvider.meteringScreenFeatureOf(context, MeteringScreenLayoutFeature.values[index])}', + ), + ), + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setMeteringScreenLayout({ + MeteringScreenLayoutFeature.equipmentProfiles: true, + MeteringScreenLayoutFeature.extremeExposurePairs: false, + MeteringScreenLayoutFeature.filmPicker: false, + }), + child: const Text(''), + ), + ], + ); + }, + ); + // Match `findsNWidgets(2)` to verify that `meteringScreenFeatureOf` specific results are the same as the whole config + expect(find.text("${MeteringScreenLayoutFeature.equipmentProfiles}: true"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.extremeExposurePairs}: true"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.filmPicker}: true"), findsNWidgets(2)); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text("${MeteringScreenLayoutFeature.equipmentProfiles}: true"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.extremeExposurePairs}: false"), findsNWidgets(2)); + expect(find.text("${MeteringScreenLayoutFeature.filmPicker}: false"), findsNWidgets(2)); + verify( + () => mockUserPreferencesService.meteringScreenLayout = { + MeteringScreenLayoutFeature.extremeExposurePairs: false, + MeteringScreenLayoutFeature.filmPicker: false, + MeteringScreenLayoutFeature.equipmentProfiles: true, + }, + ).called(1); + }, + ); + + testWidgets( + 'Set camera features config', + (tester) async { + await pumpTestWidget( + tester, + builder: (context) { + final config = UserPreferencesProvider.cameraConfigOf(context); + return Column( + children: [ + ...List.generate( + config.length, + (index) => Text('${config.keys.toList()[index]}: ${config.values.toList()[index]}'), + ), + ...List.generate( + CameraFeature.values.length, + (index) => Text( + '${CameraFeature.values[index]}: ${UserPreferencesProvider.cameraFeatureOf(context, CameraFeature.values[index])}', + ), + ), + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setCameraFeature({ + CameraFeature.spotMetering: true, + CameraFeature.histogram: false, + }), + child: const Text(''), + ), + ], + ); + }, + ); + // Match `findsNWidgets(2)` to verify that `cameraFeatureOf` specific results are the same as the whole config + expect(find.text("${CameraFeature.spotMetering}: true"), findsNWidgets(2)); + expect(find.text("${CameraFeature.histogram}: true"), findsNWidgets(2)); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.text("${CameraFeature.spotMetering}: true"), findsNWidgets(2)); + expect(find.text("${CameraFeature.histogram}: false"), findsNWidgets(2)); + verify( + () => mockUserPreferencesService.cameraFeatures = { + CameraFeature.spotMetering: true, + CameraFeature.histogram: false, + }, + ).called(1); + }, + ); + + testWidgets( + 'Set different locale', + (tester) async { + when(() => mockUserPreferencesService.locale).thenReturn(SupportedLocale.en); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setLocale(SupportedLocale.fr), + child: Text('${UserPreferencesProvider.localeOf(context)}'), + ), + ); + expect(find.text("${SupportedLocale.en}"), findsOneWidget); + + await tester.tap(find.text("${SupportedLocale.en}")); + await tester.pumpAndSettle(); + expect(find.text("${SupportedLocale.fr}"), findsOneWidget); + verify(() => mockUserPreferencesService.locale = SupportedLocale.fr).called(1); + }, + ); + + group('[theme]', () { + testWidgets( + 'Set dark theme type', + (tester) async { + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); + await pumpTestWidget( + tester, + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setThemeType(ThemeType.dark), + child: Text('${UserPreferencesProvider.themeTypeOf(context)}'), + ), + Text('${Theme.of(context).colorScheme.brightness}') + ], + ), + ); + expect(find.text("${ThemeType.light}"), findsOneWidget); + expect(find.text("${Brightness.light}"), findsOneWidget); + + await tester.tap(find.text("${ThemeType.light}")); + await tester.pumpAndSettle(); + expect(find.text("${ThemeType.dark}"), findsOneWidget); + expect(find.text("${Brightness.dark}"), findsOneWidget); + verify(() => mockUserPreferencesService.themeType = ThemeType.dark).called(1); + }, + ); + + testWidgets( + 'Set systemDefault theme type and toggle platform brightness', + (tester) async { + when(() => mockUserPreferencesService.themeType).thenReturn(ThemeType.light); + await pumpTestWidget( + tester, + builder: (context) => Column( + children: [ + ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setThemeType(ThemeType.systemDefault), + child: Text('${UserPreferencesProvider.themeTypeOf(context)}'), + ), + Text('${Theme.of(context).colorScheme.brightness}') + ], + ), + ); + TestWidgetsFlutterBinding.instance.platformDispatcher.platformBrightnessTestValue = Brightness.dark; + expect(find.text("${ThemeType.light}"), findsOneWidget); + expect(find.text("${Brightness.light}"), findsOneWidget); + + await tester.tap(find.text("${ThemeType.light}")); + await tester.pumpAndSettle(); + expect(find.text("${ThemeType.systemDefault}"), findsOneWidget); + expect(find.text("${Brightness.dark}"), findsOneWidget); + verify(() => mockUserPreferencesService.themeType = ThemeType.systemDefault).called(1); + + TestWidgetsFlutterBinding.instance.platformDispatcher.platformBrightnessTestValue = Brightness.light; + await tester.pumpAndSettle(); + expect(find.text("${ThemeType.systemDefault}"), findsOneWidget); + expect(find.text("${Brightness.light}"), findsOneWidget); + }, + ); + + testWidgets( + 'Set primary color', + (tester) async { + when(() => mockUserPreferencesService.primaryColor).thenReturn(primaryColorsList[5]); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).setPrimaryColor(primaryColorsList[7]), + child: Text('${UserPreferencesProvider.themeOf(context).primaryColor}'), + ), + ); + expect(find.text("${primaryColorsList[5]}"), findsOneWidget); + + await tester.tap(find.text("${primaryColorsList[5]}")); + await tester.pumpAndSettle(); + expect(find.text("${primaryColorsList[7]}"), findsOneWidget); + verify(() => mockUserPreferencesService.primaryColor = primaryColorsList[7]).called(1); + }, + ); + + testWidgets( + 'Dynamic colors not available', + (tester) async { + when(() => mockUserPreferencesService.dynamicColor).thenReturn(true); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).enableDynamicColor(false), + child: Text('${UserPreferencesProvider.dynamicColorStateOf(context)}'), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text("${DynamicColorState.unavailable}"), + findsOneWidget, + reason: + "Even though dynamic colors usage is enabled, the core palette can be unavailable. Therefore `DynamicColorState` is also unavailable.", + ); + }, + ); + + testWidgets( + 'Toggle dynamic color state', + (tester) async { + DynamicColorTestingUtils.setMockDynamicColors(corePalette: CorePalette.of(0xffffffff)); + when(() => mockUserPreferencesService.dynamicColor).thenReturn(true); + await pumpTestWidget( + tester, + builder: (context) => ElevatedButton( + onPressed: () => UserPreferencesProvider.of(context).enableDynamicColor(false), + child: Text('${UserPreferencesProvider.dynamicColorStateOf(context)}'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text("${DynamicColorState.enabled}"), findsOneWidget); + + await tester.tap(find.text("${DynamicColorState.enabled}")); + await tester.pumpAndSettle(); + expect(find.text("${DynamicColorState.disabled}"), findsOneWidget); + verify(() => mockUserPreferencesService.dynamicColor = false).called(1); + }, + ); + }); +} + +class _Application extends StatelessWidget { + final WidgetBuilder builder; + + const _Application({required this.builder}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: UserPreferencesProvider.themeOf(context), + home: Scaffold(body: Center(child: Builder(builder: builder))), + ); + } +} diff --git a/test/screens/metering/bloc_metering_test.dart b/test/screens/metering/bloc_metering_test.dart index fbffbbc..f07d35c 100644 --- a/test/screens/metering/bloc_metering_test.dart +++ b/test/screens/metering/bloc_metering_test.dart @@ -7,9 +7,9 @@ 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:lightmeter/screens/metering/utils/notifier_volume_keys.dart'; import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; diff --git a/test/screens/metering/components/camera/bloc_container_camera_test.dart b/test/screens/metering/components/camera/bloc_container_camera_test.dart index d54c0a5..52c57c8 100644 --- a/test/screens/metering/components/camera/bloc_container_camera_test.dart +++ b/test/screens/metering/components/camera/bloc_container_camera_test.dart @@ -4,10 +4,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lightmeter/interactors/metering_interactor.dart'; import 'package:lightmeter/screens/metering/communication/bloc_communication_metering.dart'; -import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' - as communication_events; -import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' - as communication_states; +import 'package:lightmeter/screens/metering/communication/event_communication_metering.dart' as communication_events; +import 'package:lightmeter/screens/metering/communication/state_communication_metering.dart' as communication_states; import 'package:lightmeter/screens/metering/components/camera_container/bloc_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; import 'package:lightmeter/screens/metering/components/camera_container/models/camera_error_type.dart'; @@ -16,9 +14,9 @@ import 'package:mocktail/mocktail.dart'; class _MockMeteringInteractor extends Mock implements MeteringInteractor {} -class _MockMeteringCommunicationBloc extends MockBloc< - communication_events.MeteringCommunicationEvent, - communication_states.MeteringCommunicationState> implements MeteringCommunicationBloc {} +class _MockMeteringCommunicationBloc + extends MockBloc + implements MeteringCommunicationBloc {} void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -147,8 +145,7 @@ void main() { verify(() => meteringInteractor.requestCameraPermission()).called(1); }, expect: () => [ - isA() - .having((state) => state.error, "error", CameraErrorType.permissionNotGranted), + isA().having((state) => state.error, "error", CameraErrorType.permissionNotGranted), ], ); @@ -166,8 +163,7 @@ void main() { }, expect: () => [ isA(), - isA() - .having((state) => state.error, "error", CameraErrorType.permissionNotGranted), + isA().having((state) => state.error, "error", CameraErrorType.permissionNotGranted), ], ); @@ -215,8 +211,7 @@ void main() { 'No cameras detected error', setUp: () { when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( cameraMethodChannel, (methodCall) async => cameraMethodCallSuccessHandler(methodCall, cameras: const []), ); @@ -232,8 +227,7 @@ void main() { }, expect: () => [ isA(), - isA() - .having((state) => state.error, "error", CameraErrorType.noCamerasDetected), + isA().having((state) => state.error, "error", CameraErrorType.noCamerasDetected), ], ); @@ -241,8 +235,7 @@ void main() { 'No back facing cameras available', setUp: () { when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( cameraMethodChannel, (methodCall) async => cameraMethodCallSuccessHandler(methodCall, cameras: frontCameras), ); @@ -263,8 +256,7 @@ void main() { 'Catch other initialization errors', setUp: () { when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( cameraMethodChannel, (methodCall) async { switch (methodCall.method) { @@ -300,10 +292,8 @@ void main() { act: (bloc) async { bloc.add(const InitializeEvent()); await Future.delayed(Duration.zero); - TestWidgetsFlutterBinding.instance - .handleAppLifecycleStateChanged(AppLifecycleState.detached); - TestWidgetsFlutterBinding.instance - .handleAppLifecycleStateChanged(AppLifecycleState.resumed); + TestWidgetsFlutterBinding.instance.handleAppLifecycleStateChanged(AppLifecycleState.detached); + TestWidgetsFlutterBinding.instance.handleAppLifecycleStateChanged(AppLifecycleState.resumed); }, verify: (_) { verify(() => meteringInteractor.checkCameraPermission()).called(2); @@ -500,6 +490,29 @@ void main() { ); }, ); + + group( + '`ExposureSpotChangedEvent`', + () { + blocTest( + 'Set exposure spot multiple times', + setUp: () { + when(() => meteringInteractor.checkCameraPermission()).thenAnswer((_) async => true); + }, + build: () => bloc, + act: (bloc) async { + bloc.add(const InitializeEvent()); + await Future.delayed(Duration.zero); + bloc.add(const ExposureSpotChangedEvent(Offset(0.1, 0.1))); + bloc.add(const ExposureSpotChangedEvent(Offset(1.0, 0.5))); + }, + verify: (_) { + verify(() => meteringInteractor.checkCameraPermission()).called(1); + }, + expect: () => [...initializedStateSequence], + ); + }, + ); } extension _MethodChannelMock on MethodChannel { diff --git a/test/screens/metering/components/camera/event_container_camera_test.dart b/test/screens/metering/components/camera/event_container_camera_test.dart index f136b5f..3751f10 100644 --- a/test/screens/metering/components/camera/event_container_camera_test.dart +++ b/test/screens/metering/components/camera/event_container_camera_test.dart @@ -1,5 +1,7 @@ // ignore_for_file: prefer_const_constructors +import 'dart:ui'; + import 'package:lightmeter/screens/metering/components/camera_container/event_container_camera.dart'; import 'package:test/test.dart'; @@ -41,4 +43,23 @@ void main() { }); }, ); + + group( + '`ExposureSpotChangedEvent`', + () { + final a = ExposureSpotChangedEvent(Offset(0.0, 0.0)); + final b = ExposureSpotChangedEvent(Offset(0.0, 0.0)); + final c = ExposureSpotChangedEvent(Offset(2.0, 2.0)); + test('==', () { + expect(a == b && b == a, true); + expect(a != c && c != a, true); + expect(b != c && c != b, true); + }); + test('hashCode', () { + expect(a.hashCode == b.hashCode, true); + expect(a.hashCode != c.hashCode, true); + expect(b.hashCode != c.hashCode, true); + }); + }, + ); } diff --git a/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart b/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart new file mode 100644 index 0000000..6853168 --- /dev/null +++ b/test/screens/metering/components/shared/readings_container/equipment_profile_picker_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/equipment_profile_picker/widget_picker_equipment_profiles.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../../../application_mock.dart'; +import 'utils.dart'; + +class _MockIAPStorageService extends Mock implements IAPStorageService {} + +void main() { + late final _MockIAPStorageService mockIAPStorageService; + + setUpAll(() { + mockIAPStorageService = _MockIAPStorageService(); + when(() => mockIAPStorageService.equipmentProfiles).thenReturn(_mockEquipmentProfiles); + when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(''); + }); + + Future pumpApplication(WidgetTester tester) async { + await tester.pumpWidget( + IAPProducts( + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + status: IAPProductStatus.purchased, + ) + ], + child: EquipmentProfileProvider( + storageService: mockIAPStorageService, + child: const WidgetTestApplicationMock( + child: Row(children: [Expanded(child: EquipmentProfilePicker())]), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets( + 'Check dialog icon and title consistency', + (tester) async { + await pumpApplication(tester); + expectReadingValueContainerText(S.current.equipmentProfile); + await tester.openAnimatedPicker(); + expect(find.byIcon(Icons.camera), findsOneWidget); + expectDialogPickerText(S.current.equipmentProfile); + }, + ); + + group( + 'Display selected value', + () { + testWidgets( + 'None', + (tester) async { + when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(''); + await pumpApplication(tester); + expectReadingValueContainerText(S.current.none); + await tester.openAnimatedPicker(); + expectRadioListTile(S.current.none, isSelected: true); + }, + ); + + testWidgets( + 'Praktica + Zenitar', + (tester) async { + when(() => mockIAPStorageService.selectedEquipmentProfileId).thenReturn(_mockEquipmentProfiles.first.id); + await pumpApplication(tester); + expectReadingValueContainerText(_mockEquipmentProfiles.first.name); + await tester.openAnimatedPicker(); + expectRadioListTile(_mockEquipmentProfiles.first.name, isSelected: true); + }, + ); + }, + ); +} + +final _mockEquipmentProfiles = [ + EquipmentProfile( + id: '1', + name: 'Praktica + Zenitar', + apertureValues: ApertureValue.values.sublist( + ApertureValue.values.indexOf(const ApertureValue(1.7, StopType.half)), + ApertureValue.values.indexOf(const ApertureValue(16, StopType.full)) + 1, + ), + ndValues: NdValue.values.sublist(0, 3), + shutterSpeedValues: ShutterSpeedValue.values.sublist( + ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)), + ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1, + ), + isoValues: const [ + IsoValue(50, StopType.full), + IsoValue(100, StopType.full), + IsoValue(200, StopType.full), + IsoValue(250, StopType.third), + IsoValue(400, StopType.full), + IsoValue(500, StopType.third), + IsoValue(800, StopType.full), + IsoValue(1600, StopType.full), + IsoValue(3200, StopType.full), + ], + ), + const EquipmentProfile( + id: '2', + name: 'Praktica + Jupiter', + apertureValues: ApertureValue.values, + ndValues: NdValue.values, + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values, + ), +]; diff --git a/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart b/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart new file mode 100644 index 0000000..819a9eb --- /dev/null +++ b/test/screens/metering/components/shared/readings_container/extreme_exposure_pairs_container_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/exposure_pair.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/films_provider.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/extreme_exposure_pairs_container/widget_container_extreme_exposure_pairs.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +import '../../../../../application_mock.dart'; + +void main() { + testWidgets( + 'No exposure pairs', + (tester) async { + await tester.pumpApplication( + fastest: null, + slowest: null, + ); + + final pickerFinder = find.byType(ExtremeExposurePairsContainer); + expect(pickerFinder, findsOneWidget); + expect(find.descendant(of: pickerFinder, matching: find.text(S.current.fastestExposurePair)), findsOneWidget); + expect(find.descendant(of: pickerFinder, matching: find.text(S.current.slowestExposurePair)), findsOneWidget); + expect(find.descendant(of: pickerFinder, matching: find.text('-')), findsNWidgets(2)); + }, + ); + + testWidgets( + 'Has pairs', + (tester) async { + await tester.pumpApplication( + fastest: ExposurePair(ApertureValue.values.first, ShutterSpeedValue.values.first), + slowest: ExposurePair(ApertureValue.values.last, ShutterSpeedValue.values.last), + ); + + final pickerFinder = find.byType(ExtremeExposurePairsContainer); + expect(pickerFinder, findsOneWidget); + expect(find.descendant(of: pickerFinder, matching: find.text(S.current.fastestExposurePair)), findsOneWidget); + expect(find.descendant(of: pickerFinder, matching: find.text(S.current.slowestExposurePair)), findsOneWidget); + expect(find.descendant(of: pickerFinder, matching: find.text('f/1.0 - 1/2000')), findsOneWidget); + expect(find.descendant(of: pickerFinder, matching: find.text('f/45 - 16"')), findsOneWidget); + }, + ); +} + +extension WidgetTesterActions on WidgetTester { + Future pumpApplication({ + required ExposurePair? fastest, + required ExposurePair? slowest, + }) async { + await pumpWidget( + Films( + values: const [Film.other()], + filmsInUse: const [Film.other()], + selected: const Film.other(), + child: WidgetTestApplicationMock( + child: Row( + children: [ + Expanded( + child: ExtremeExposurePairsContainer( + fastest: fastest, + slowest: slowest, + ), + ), + ], + ), + ), + ), + ); + await pumpAndSettle(); + } +} diff --git a/test/screens/metering/components/shared/readings_container/film_picker_test.dart b/test/screens/metering/components/shared/readings_container/film_picker_test.dart new file mode 100644 index 0000000..19f4840 --- /dev/null +++ b/test/screens/metering/components/shared/readings_container/film_picker_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/films_provider.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/film_picker/widget_picker_film.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../../../application_mock.dart'; +import 'utils.dart'; + +class _MockIAPStorageService extends Mock implements IAPStorageService {} + +void main() { + late final _MockIAPStorageService mockIAPStorageService; + + setUpAll(() { + mockIAPStorageService = _MockIAPStorageService(); + when(() => mockIAPStorageService.filmsInUse).thenReturn(_films); + }); + + Future pumpApplication(WidgetTester tester) async { + await tester.pumpWidget( + IAPProducts( + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + status: IAPProductStatus.purchased, + ) + ], + child: FilmsProvider( + storageService: mockIAPStorageService, + child: const WidgetTestApplicationMock( + child: Row( + children: [ + Expanded( + child: FilmPicker(selectedIso: IsoValue(400, StopType.full)), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + group('Film push/pull label', () { + testWidgets( + 'Film.other()', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(const Film.other()); + await pumpApplication(tester); + expectReadingValueContainerText(S.current.film); + expectReadingValueContainerText(S.current.none); + }, + ); + + testWidgets( + 'Film with the same ISO', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[1]); + await pumpApplication(tester); + expectReadingValueContainerText(S.current.film); + expectReadingValueContainerText(_films[1].name); + }, + ); + + testWidgets( + 'Film with greater ISO', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[2]); + await pumpApplication(tester); + expectReadingValueContainerText(S.current.filmPull); + expectReadingValueContainerText(_films[2].name); + }, + ); + + testWidgets( + 'Film with lower ISO', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[0]); + await pumpApplication(tester); + expectReadingValueContainerText(S.current.filmPush); + expectReadingValueContainerText(_films[0].name); + }, + ); + }); + + testWidgets( + 'Film picker shows only films in use', + (tester) async { + when(() => mockIAPStorageService.selectedFilm).thenReturn(_films[0]); + await pumpApplication(tester); + await tester.openAnimatedPicker(); + expectRadioListTile(S.current.none, isSelected: true); + expectRadioListTile(_films[1].name); + expectRadioListTile(_films[2].name); + expectRadioListTile(_films[3].name); + }, + ); +} + +const _films = [ + Film('ISO 100 Film', 100), + Film('ISO 400 Film', 400), + Film('ISO 800 Film', 800), + Film('ISO 1600 Film', 1600), +]; diff --git a/test/screens/metering/components/shared/readings_container/iso_picker_test.dart b/test/screens/metering/components/shared/readings_container/iso_picker_test.dart new file mode 100644 index 0000000..9068df4 --- /dev/null +++ b/test/screens/metering/components/shared/readings_container/iso_picker_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +import '../../../../../application_mock.dart'; +import 'utils.dart'; + +void main() { + Future pumpApplication( + WidgetTester tester, { + List values = IsoValue.values, + IsoValue selectedValue = const IsoValue(100, StopType.full), + }) async { + assert(values.contains(selectedValue)); + await tester.pumpWidget( + WidgetTestApplicationMock( + child: Row( + children: [ + Expanded( + child: IsoValuePicker( + selectedValue: selectedValue, + values: values, + onChanged: (_) {}, + ), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets( + 'Check dialog icon and title consistency', + (tester) async { + await pumpApplication(tester); + expectReadingValueContainerText(S.current.iso); + await tester.openAnimatedPicker(); + expect(find.byIcon(Icons.iso), findsOneWidget); + expectDialogPickerText(S.current.iso); + expectDialogPickerText(S.current.filmSpeed); + }, + ); + + group( + 'Display selected value', + () { + testWidgets( + 'Any', + (tester) async { + await pumpApplication(tester); + expectReadingValueContainerText('100'); + await tester.openAnimatedPicker(); + expectRadioListTile('100', isSelected: true); + }, + ); + }, + ); +} diff --git a/test/screens/metering/components/shared/readings_container/nd_picker_test.dart b/test/screens/metering/components/shared/readings_container/nd_picker_test.dart new file mode 100644 index 0000000..3deb824 --- /dev/null +++ b/test/screens/metering/components/shared/readings_container/nd_picker_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/nd_picker/widget_picker_nd.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; + +import '../../../../../application_mock.dart'; +import 'utils.dart'; + +void main() { + Future pumpApplication( + WidgetTester tester, { + List values = NdValue.values, + NdValue selectedValue = const NdValue(0), + }) async { + assert(values.contains(selectedValue)); + await tester.pumpWidget( + WidgetTestApplicationMock( + child: Row( + children: [ + Expanded( + child: NdValuePicker( + selectedValue: selectedValue, + values: values, + onChanged: (_) {}, + ), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets( + 'Check dialog icon and title consistency', + (tester) async { + await pumpApplication(tester); + expectReadingValueContainerText(S.current.nd); + await tester.openAnimatedPicker(); + expect(find.byIcon(Icons.filter_b_and_w), findsOneWidget); + expectDialogPickerText(S.current.nd); + expectDialogPickerText(S.current.ndFilterFactor); + }, + ); + + group( + 'Display selected value', + () { + testWidgets( + 'None', + (tester) async { + await pumpApplication(tester); + expectReadingValueContainerText(S.current.none); + await tester.openAnimatedPicker(); + expectRadioListTile(S.current.none, isSelected: true); + }, + ); + + testWidgets( + 'ND2', + (tester) async { + await pumpApplication(tester, selectedValue: const NdValue(2)); + expectReadingValueContainerText('2'); + await tester.openAnimatedPicker(); + expectRadioListTile('2', isSelected: true); + }, + ); + }, + ); +} diff --git a/test/screens/metering/components/shared/readings_container/shared/animated_dialog_test.dart b/test/screens/metering/components/shared/readings_container/shared/animated_dialog_test.dart new file mode 100644 index 0000000..d4b83c6 --- /dev/null +++ b/test/screens/metering/components/shared/readings_container/shared/animated_dialog_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; + +import '../../../../../../application_mock.dart'; +import '../utils.dart'; + +void main() { + group( + 'Open & close tests', + () { + testWidgets( + 'Open & close with select', + (tester) async { + await tester.pumpApplication(); + await tester.openAnimatedPicker>(); + expect(find.byType(DialogPicker), findsOneWidget); + await tester.tapSelectButton(); + expect(find.byType(DialogPicker), findsNothing); + }, + ); + + testWidgets( + 'Open & close with cancel', + (tester) async { + await tester.pumpApplication(); + await tester.openAnimatedPicker>(); + expect(find.byType(DialogPicker), findsOneWidget); + await tester.tapCancelButton(); + expect(find.byType(DialogPicker), findsNothing); + }, + ); + + testWidgets( + 'Open & close with tap outside', + (tester) async { + await tester.pumpApplication(); + await tester.openAnimatedPicker>(); + expect(find.byType(DialogPicker), findsOneWidget); + + /// tester taps the center of the found widget, + /// which results in tap on the dialog instead of the underlying barrier + /// therefore just tap at offset outside the dialog + await tester.longPressAt(const Offset(16, 16)); + await tester.pumpAndSettle(Dimens.durationML); + expect(find.byType(DialogPicker), findsNothing); + }, + ); + + testWidgets( + 'Open & close with back gesture', + (tester) async { + await tester.pumpApplication(); + await tester.openAnimatedPicker>(); + expect(find.byType(DialogPicker), findsOneWidget); + + //// https://github.com/flutter/flutter/blob/master/packages/flutter/test/widgets/router_test.dart#L970-L971 + //// final ByteData message = const JSONMethodCodec().encodeMethodCall(const MethodCall('popRoute')); + //// await tester.binding.defaultBinaryMessenger.handlePlatformMessage('flutter/navigation', message, (_) {}); + /// https://github.com/flutter/packages/blob/main/packages/animations/test/open_container_test.dart#L234 + (tester.state(find.byType(Navigator)) as NavigatorState).pop(); + await tester.pumpAndSettle(Dimens.durationML); + expect(find.byType(DialogPicker), findsNothing); + }, + ); + }, + ); +} + +extension WidgetTesterActions on WidgetTester { + Future pumpApplication() async { + await pumpWidget( + WidgetTestApplicationMock( + child: Row( + children: [ + Expanded( + child: AnimatedDialogPicker( + icon: Icons.iso, + title: '', + subtitle: '', + selectedValue: 0, + values: List.generate(10, (index) => index), + itemTitleBuilder: (_, value) => Text(value.toString()), + itemTrailingBuilder: (selected, value) => null, + onChanged: (_) {}, + closedChild: ReadingValueContainer.singleValue( + value: ReadingValue( + label: '', + value: 0.toString(), + ), + ), + ), + ), + ], + ), + ), + ); + await pumpAndSettle(); + } + + Future tapSelectButton() async { + final cancelButton = find.byWidgetPredicate( + (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == S.current.select, + ); + expect(cancelButton, findsOneWidget); + await tap(cancelButton); + await pumpAndSettle(Dimens.durationML); + } + + Future tapCancelButton() async { + final cancelButton = find.byWidgetPredicate( + (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == S.current.cancel, + ); + expect(cancelButton, findsOneWidget); + await tap(cancelButton); + await pumpAndSettle(Dimens.durationML); + } +} diff --git a/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart b/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart new file mode 100644 index 0000000..bd037af --- /dev/null +++ b/test/screens/metering/components/shared/readings_container/shared/dialog_picker_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/widget_picker_dialog_animated.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../../../../application_mock.dart'; +import '../../../../../../function_mock.dart'; +import '../utils.dart'; + +void main() { + final functions = MockValueChanged(); + + group( + 'onChanged', + () { + testWidgets( + 'other', + (tester) async { + await tester.pumpApplication(functions.onChanged); + await tester.openAnimatedPicker>(); + expect(find.byType(DialogPicker), findsOneWidget); + await tester.tapListTile(1); + await tester.tapSelectButton(); + verify(() => functions.onChanged(1)).called(1); + }, + ); + + testWidgets( + 'same', + (tester) async { + await tester.pumpApplication(functions.onChanged); + await tester.openAnimatedPicker>(); + expect(find.byType(DialogPicker), findsOneWidget); + await tester.tapListTile(0); + await tester.tapSelectButton(); + verify(() => functions.onChanged(0)).called(1); + }, + ); + }, + ); +} + +extension WidgetTesterActions on WidgetTester { + Future pumpApplication(ValueChanged onChanged) async { + await pumpWidget( + WidgetTestApplicationMock( + child: Row( + children: [ + Expanded( + child: AnimatedDialogPicker( + icon: Icons.iso, + title: '', + subtitle: '', + selectedValue: 0, + values: List.generate(10, (index) => index), + itemTitleBuilder: (_, value) => Text(value.toString()), + itemTrailingBuilder: (selected, value) => null, + onChanged: onChanged, + closedChild: ReadingValueContainer.singleValue( + value: ReadingValue( + label: '', + value: 0.toString(), + ), + ), + ), + ), + ], + ), + ), + ); + await pumpAndSettle(); + } + + Future tapListTile(int iso) async { + expect(find.descendant(of: find.byType(RadioListTile), matching: find.text('$iso')), findsOneWidget); + await tap(find.descendant(of: find.byType(RadioListTile), matching: find.text('$iso'))); + } + + Future tapSelectButton() async { + final cancelButton = find.byWidgetPredicate( + (widget) => widget is TextButton && widget.child is Text && (widget.child as Text?)?.data == S.current.select, + ); + expect(cancelButton, findsOneWidget); + await tap(cancelButton); + await pumpAndSettle(); + } +} diff --git a/test/screens/metering/components/shared/readings_container/utils.dart b/test/screens/metering/components/shared/readings_container/utils.dart new file mode 100644 index 0000000..0ffba27 --- /dev/null +++ b/test/screens/metering/components/shared/readings_container/utils.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/res/dimens.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/animated_dialog_picker/components/dialog_picker/widget_picker_dialog.dart'; +import 'package:lightmeter/screens/metering/components/shared/readings_container/components/shared/reading_value_container/widget_container_reading_value.dart'; + +extension WidgetTesterActions on WidgetTester { + Future openAnimatedPicker() async { + await tap(find.byType(T)); + await pumpAndSettle(Dimens.durationL); + } +} + +void expectReadingValueContainerText(String text) => _expectTextDescendantOf(text); + +void expectDialogPickerText(String text) => _expectTextDescendantOf>(text); + +void _expectTextDescendantOf(String text) { + expect(find.descendant(of: find.byType(T), matching: find.text(text)), findsOneWidget); +} + +void expectRadioListTile(String text, {bool isSelected = false}) { + expect( + find.descendant(of: find.byType(RadioListTile), matching: find.text(text)), + findsOneWidget, + ); +} diff --git a/test/screens/metering/utils/listener_equipment_profiles_test.dart b/test/screens/metering/utils/listener_equipment_profiles_test.dart new file mode 100644 index 0000000..ae6cb87 --- /dev/null +++ b/test/screens/metering/utils/listener_equipment_profiles_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/providers/equipment_profile_provider.dart'; +import 'package:lightmeter/screens/metering/utils/listener_equipment_profiles.dart'; +import 'package:m3_lightmeter_iap/m3_lightmeter_iap.dart'; +import 'package:m3_lightmeter_resources/m3_lightmeter_resources.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../function_mock.dart'; + +class _MockIAPStorageService extends Mock implements IAPStorageService {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final storageService = _MockIAPStorageService(); + final equipmentProfileProviderKey = GlobalKey(); + final onDidChangeDependencies = MockValueChanged(); + + tearDown(() { + reset(onDidChangeDependencies); + reset(storageService); + }); + + Future pumpTestWidget(WidgetTester tester) async { + await tester.pumpWidget( + IAPProducts( + products: [ + IAPProduct( + storeId: IAPProductType.paidFeatures.storeId, + status: IAPProductStatus.purchased, + ), + ], + child: EquipmentProfileProvider( + key: equipmentProfileProviderKey, + storageService: storageService, + child: MaterialApp( + home: EquipmentProfileListener( + onDidChangeDependencies: onDidChangeDependencies.onChanged, + child: Builder(builder: (context) => Text(EquipmentProfiles.selectedOf(context).name)), + ), + ), + ), + ), + ); + } + + testWidgets( + 'Trigger `onDidChangeDependencies` by selecting a new profile', + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); + when(() => storageService.selectedEquipmentProfileId).thenReturn(''); + await pumpTestWidget(tester); + + equipmentProfileProviderKey.currentState!.setProfile(_customProfiles[0]); + await tester.pump(); + verify(() => onDidChangeDependencies.onChanged(_customProfiles[0])).called(1); + }, + ); + + testWidgets( + 'Trigger `onDidChangeDependencies` by updating the selected profile', + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); + when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); + await pumpTestWidget(tester); + + final updatedProfile1 = _customProfiles[0].copyWith(name: 'Test 1 updated'); + equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile1); + await tester.pump(); + verify(() => onDidChangeDependencies.onChanged(updatedProfile1)).called(1); + + /// Verify that updating the not selected profile doesn't trigger the callback + final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated'); + equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile2); + await tester.pump(); + verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2)); + }, + ); + + testWidgets( + "Don't trigger `onDidChangeDependencies` by updating the unselected profile", + (tester) async { + when(() => storageService.equipmentProfiles).thenReturn(List.from(_customProfiles)); + when(() => storageService.selectedEquipmentProfileId).thenReturn(_customProfiles[0].id); + await pumpTestWidget(tester); + + final updatedProfile2 = _customProfiles[1].copyWith(name: 'Test 2 updated'); + equipmentProfileProviderKey.currentState!.updateProfile(updatedProfile2); + await tester.pump(); + verifyNever(() => onDidChangeDependencies.onChanged(updatedProfile2)); + }, + ); +} + +final List _customProfiles = [ + const EquipmentProfile( + id: '1', + name: 'Test 1', + apertureValues: [ + ApertureValue(4.0, StopType.full), + ApertureValue(4.5, StopType.third), + ApertureValue(4.8, StopType.half), + ApertureValue(5.0, StopType.third), + ApertureValue(5.6, StopType.full), + ApertureValue(6.3, StopType.third), + ApertureValue(6.7, StopType.half), + ApertureValue(7.1, StopType.third), + ApertureValue(8, StopType.full), + ], + ndValues: [ + NdValue(0), + NdValue(2), + NdValue(4), + NdValue(8), + NdValue(16), + NdValue(32), + NdValue(64), + ], + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: [ + IsoValue(100, StopType.full), + IsoValue(125, StopType.third), + IsoValue(160, StopType.third), + IsoValue(200, StopType.full), + IsoValue(250, StopType.third), + IsoValue(320, StopType.third), + IsoValue(400, StopType.full), + ], + ), + const EquipmentProfile( + id: '2', + name: 'Test 2', + apertureValues: ApertureValue.values, + ndValues: NdValue.values, + shutterSpeedValues: ShutterSpeedValue.values, + isoValues: IsoValue.values, + ), +]; diff --git a/test/screens/metering/utils/notifier_volume_keys_test.dart b/test/screens/metering/utils/notifier_volume_keys_test.dart new file mode 100644 index 0000000..7bb6458 --- /dev/null +++ b/test/screens/metering/utils/notifier_volume_keys_test.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:lightmeter/data/models/volume_action.dart'; +import 'package:lightmeter/data/volume_events_service.dart'; +import 'package:lightmeter/screens/metering/utils/notifier_volume_keys.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import '../../../function_mock.dart'; + +class _MockVolumeEventsService extends Mock implements VolumeEventsService {} + +void main() { + late _MockVolumeEventsService mockVolumeEventsService; + + setUp(() { + mockVolumeEventsService = _MockVolumeEventsService(); + }); + + test( + 'Listen to `volumeButtonsEventStream()`', + () async { + final StreamController volumeButtonsEvents = StreamController(); + when(() => mockVolumeEventsService.volumeButtonsEventStream()).thenAnswer((_) => volumeButtonsEvents.stream); + + final volumeKeysNotifier = VolumeKeysNotifier(mockVolumeEventsService); + final functions = MockValueChanged(); + volumeKeysNotifier.addListener(() => functions.onChanged(volumeKeysNotifier.value)); + expect(volumeKeysNotifier.value, VolumeKey.up); + + volumeButtonsEvents.add(25); + volumeButtonsEvents.add(25); + volumeButtonsEvents.add(25); + volumeButtonsEvents.add(24); + volumeButtonsEvents.add(24); + volumeButtonsEvents.add(25); + await Future.delayed(Duration.zero); + verify(() => functions.onChanged(VolumeKey.up)).called(2); + verify(() => functions.onChanged(VolumeKey.down)).called(4); + + volumeKeysNotifier.removeListener(() => functions.onChanged(volumeKeysNotifier.value)); + await volumeKeysNotifier.dispose(); + await volumeButtonsEvents.close(); + }, + ); +} diff --git a/test/screens/settings/utils/show_buy_pro_dialog_test.dart b/test/screens/settings/utils/show_buy_pro_dialog_test.dart new file mode 100644 index 0000000..16a8e19 --- /dev/null +++ b/test/screens/settings/utils/show_buy_pro_dialog_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/data/models/feature.dart'; +import 'package:lightmeter/generated/l10n.dart'; +import 'package:lightmeter/providers/remote_config_provider.dart'; +import 'package:lightmeter/screens/settings/utils/show_buy_pro_dialog.dart'; + +import '../../../application_mock.dart'; + +void main() { + Future pumpApplication(WidgetTester tester) async { + await tester.pumpWidget( + RemoteConfig( + config: const {Feature.unlockProFeaturesText: false}, + child: WidgetTestApplicationMock( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showBuyProDialog(context), + child: const SizedBox(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets( + '`showBuyProDialog` and buy', + (tester) async { + await pumpApplication(tester); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(S.current.lightmeterPro), findsOneWidget); + expect(find.text(S.current.cancel), findsOneWidget); + expect(find.text(S.current.buy), findsOneWidget); + + await tester.tap(find.text(S.current.buy)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + }, + ); + + testWidgets( + '`showBuyProDialog` and cancel', + (tester) async { + await pumpApplication(tester); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text(S.current.lightmeterPro), findsOneWidget); + expect(find.text(S.current.cancel), findsOneWidget); + expect(find.text(S.current.buy), findsOneWidget); + + await tester.tap(find.text(S.current.cancel)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + }, + ); +} diff --git a/test/utils/selectable_provider_test.dart b/test/utils/selectable_provider_test.dart new file mode 100644 index 0000000..4ef2b96 --- /dev/null +++ b/test/utils/selectable_provider_test.dart @@ -0,0 +1,76 @@ + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/utils/selectable_provider.dart'; + +void main() { + group('SelectableInheritedModel.updateShouldNotifyDependent', () { + final model = SelectableInheritedModel( + values: List.generate(25, (index) => index), + selected: 1, + child: const SizedBox(), + ); + + test( + '`{}`', + () { + expect( + model.updateShouldNotifyDependent( + SelectableInheritedModel( + values: List.generate(25, (index) => index), + selected: 1, + child: const SizedBox(), + ), + {}, + ), + false, + ); + }, + ); + + test( + '`{SelectableAspect.list}`', + () { + expect( + model.updateShouldNotifyDependent( + SelectableInheritedModel( + values: List.generate(25, (index) => index), + selected: 1, + child: const SizedBox(), + ), + {SelectableAspect.list}, + ), + true, + ); + }, + ); + + test( + '`{SelectableAspect.selected}`', + () { + expect( + model.updateShouldNotifyDependent( + SelectableInheritedModel( + values: List.generate(25, (index) => index), + selected: 1, + child: const SizedBox(), + ), + {SelectableAspect.selected}, + ), + false, + ); + expect( + model.updateShouldNotifyDependent( + SelectableInheritedModel( + values: List.generate(25, (index) => index), + selected: 2, + child: const SizedBox(), + ), + {SelectableAspect.selected}, + ), + true, + ); + }, + ); + }); +} diff --git a/test/utils/to_string_signed_test.dart b/test/utils/to_string_signed_test.dart new file mode 100644 index 0000000..8611204 --- /dev/null +++ b/test/utils/to_string_signed_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:lightmeter/utils/to_string_signed.dart'; + +void main() { + test('toStringSignedAsFixed(0)', () { + expect(1.5.toStringSignedAsFixed(0), '+2'); + expect((-1.5).toStringSignedAsFixed(0), '-2'); + expect(0.0.toStringSignedAsFixed(0), '0'); + }); + + test('toStringSignedAsFixed(1)', () { + expect(1.5.toStringSignedAsFixed(1), '+1.5'); + expect((-1.5).toStringSignedAsFixed(1), '-1.5'); + expect(0.0.toStringSignedAsFixed(1), '0.0'); + }); +} diff --git a/test_coverage.sh b/test_coverage.sh index c88e397..12014e8 100644 --- a/test_coverage.sh +++ b/test_coverage.sh @@ -1,4 +1,12 @@ flutter test --coverage +flutter test integration_test --flavor=dev --coverage + +file=test/coverage_helper_test.dart +echo "// Helper file to make coverage work for all dart files\n" > $file +echo "// ignore_for_file: unused_import, directives_ordering" >> $file +find lib '!' -path '*generated*/*' '!' -name '*.g.dart' '!' -name '*.part.dart' -name '*.dart' | cut -c4- | awk -v package=$1 '{printf "import '\''package:lightmeter%s%s'\'';\n", package, $1}' >> $file +echo "void main() {}" >> $file + lcov --remove coverage/lcov.info 'lib/generated/*' 'lib/l10n/*' -o coverage/new_lcov.info genhtml coverage/new_lcov.info -o coverage/html open coverage/html/index.html \ No newline at end of file diff --git a/test_driver/integration_driver.dart b/test_driver/integration_driver.dart new file mode 100644 index 0000000..3d79aac --- /dev/null +++ b/test_driver/integration_driver.dart @@ -0,0 +1,8 @@ +import 'package:integration_test/integration_test_driver_extended.dart'; + +import 'utils/grant_camera_permission.dart'; + +Future main() async { + await grantCameraPermission(); + await integrationDriver(); +} diff --git a/test_driver/screenshot_driver.dart b/test_driver/screenshot_driver.dart index 2a7525d..4348142 100644 --- a/test_driver/screenshot_driver.dart +++ b/test_driver/screenshot_driver.dart @@ -1,20 +1,15 @@ -import 'dart:developer'; import 'dart:io'; import 'package:integration_test/integration_test_driver_extended.dart'; -import 'utils/android_camera_permission.dart'; +import 'utils/grant_camera_permission.dart'; Future main() async { - try { - await grandCameraPermission(); - await integrationDriver( - onScreenshot: (name, bytes, [args]) async { - final File image = await File('screenshots/$name.png').create(recursive: true); - image.writeAsBytesSync(bytes); - return true; - }, - ); - } catch (e) { - log('Error occured: $e'); - } + await grantCameraPermission(); + await integrationDriver( + onScreenshot: (name, bytes, [args]) async { + final File image = await File('screenshots/$name.png').create(recursive: true); + image.writeAsBytesSync(bytes); + return true; + }, + ); } diff --git a/test_driver/utils/grant_camera_permission.dart b/test_driver/utils/grant_camera_permission.dart new file mode 100644 index 0000000..c6f56d7 --- /dev/null +++ b/test_driver/utils/grant_camera_permission.dart @@ -0,0 +1,34 @@ +import 'dart:developer'; +import 'dart:io'; + +Future grantCameraPermission() async { + try { + final bool adbExists = Process.runSync('which', ['adb']).exitCode == 0; + if (!adbExists) { + log(r'This test needs ADB to exist on the $PATH. Skipping...'); + exit(0); + } + final deviceId = await Process.run('adb', ["-s", 'shell', 'devices']).then((value) { + if (value.stdout is String) { + return RegExp(r"(?:List of devices attached\n)([A-Z0-9]*)(?:\sdevice\n)") + .firstMatch(value.stdout as String)! + .group(1); + } + }); + if (deviceId == null) { + log('This test needs at least one device connected'); + exit(0); + } + await Process.run('adb', [ + "-s", + deviceId, // https://github.com/flutter/flutter/issues/86295#issuecomment-1192766368 + 'shell', + 'pm', + 'grant', + 'com.vodemn.lightmeter.dev', + 'android.permission.CAMERA' + ]); + } catch (e) { + log('Error occured: $e'); + } +}