From 134af8ad2841cec930233c344142e1ca10bfad04 Mon Sep 17 00:00:00 2001 From: Vadim <44135514+vodemn@users.noreply.github.com> Date: Wed, 21 Feb 2024 12:33:25 +0100 Subject: [PATCH] ML-141 Prepare iOS release (#144) * implemented `MockCameraContainerBloc` to stub camera on simulator * [iOS] fixed camera preview aspect ratio * place screenshots in platform-specific folders * [iOS] updated buildable name * [iOS] fixed stub image cover fit * [iOS] implemented screenshots generator for all target devices * store screenshots in _generated_ folder * Update .gitignore * Created "Build Prod .ipa" workflow * added. certs to .ipa workflow * test ipa building * fixed provision cert path * set provision profile in XCode * set automatic signing for dev builds * set ios version in Podfile * renamed provision file * renamed provision profile * fixed cert folder... * changed provision path * typo * typo * try automatic signing * use manual profile installation * added export options * typo * increased timeout * increased ipa timeout * Update README.md * typo * [iOS] separated camera handling logic * [iOS] fixed vibration * migrated to http server iap * [iOS] fixed histogram * replaced distribution profile with development profile * removed constants from env to the separate file * removed duplicate launch schema * fixed PR check workflow * [iOS] set `ITSAppUsesNonExemptEncryption` to NO * [iOS] removed java reference from "Build .ipa" workflow --- .github/scripts/restore_from_base64.sh | 14 +++ .github/workflows/build_apk.yml | 14 ++- .github/workflows/build_ipa.yml | 97 +++++++++++++++++++ .github/workflows/create_release.yml | 12 +-- .github/workflows/pr_check.yml | 3 + .gitignore | 3 +- .vscode/launch.json | 44 +-------- .vscode/settings.json | 2 +- .vscode/tasks.json | 6 -- README.md | 26 ++--- .../src/providers/iap_products_provider.dart | 3 +- .../mocks/paid_features_mock.dart | 11 ++- .../utils/widget_tester_actions.dart | 1 + ios/Podfile | 2 +- ios/Runner.xcodeproj/project.pbxproj | 21 ++-- .../xcshareddata/xcschemes/dev.xcscheme | 6 +- .../xcshareddata/xcschemes/prod.xcscheme | 6 +- ios/Runner/Info.plist | 6 ++ lib/data/haptics_service.dart | 17 +++- lib/environment.dart | 16 --- lib/platform_config.dart | 7 +- lib/runner.dart | 6 +- .../bloc_container_camera.dart | 26 ++++- .../histogram/widget_histogram.dart | 68 +++++++++---- .../camera_preview/widget_camera_preview.dart | 11 ++- .../widget_list_tile_report_issue.dart | 4 +- .../widget_list_tile_source_code.dart | 4 +- .../widget_list_tile_write_email.dart | 7 +- pubspec.yaml | 2 +- screenshots/README.md | 39 ++++++-- screenshots/generate_screenshots.dart | 24 ++--- .../scripts/generate_ios_screenshots.sh | 13 +++ .../{ => scripts}/generate_screenshots.sh | 9 +- test_driver/screenshot_driver.dart | 2 +- 34 files changed, 363 insertions(+), 169 deletions(-) create mode 100644 .github/scripts/restore_from_base64.sh create mode 100644 .github/workflows/build_ipa.yml create mode 100644 screenshots/scripts/generate_ios_screenshots.sh rename screenshots/{ => scripts}/generate_screenshots.sh (68%) diff --git a/.github/scripts/restore_from_base64.sh b/.github/scripts/restore_from_base64.sh new file mode 100644 index 0000000..bd3daa5 --- /dev/null +++ b/.github/scripts/restore_from_base64.sh @@ -0,0 +1,14 @@ +content="$1" +filename="$2" + +if [[ ! -n "$content" ]]; then + echo "Provide file content" + exit 1 +fi + +if [[ ! -n "$filename" ]]; then + echo "Provide a path to an output file" + exit 1 +fi + +echo -n "$content" | base64 --decode --output "$filename" diff --git a/.github/workflows/build_apk.yml b/.github/workflows/build_apk.yml index d39bd31..54ba110 100644 --- a/.github/workflows/build_apk.yml +++ b/.github/workflows/build_apk.yml @@ -67,12 +67,10 @@ jobs: cp $GOOGLE_SERVICES_JSON_ANDROID_PATH ./android/app - name: Restore firebase_options.dart - env: - FIREBASE_OPTIONS: ${{ secrets.FIREBASE_OPTIONS }} - run: | - FIREBASE_OPTIONS_PATH=$RUNNER_TEMP/firebase_options.dart - echo -n "$FIREBASE_OPTIONS" | base64 --decode --output $FIREBASE_OPTIONS_PATH - cp $FIREBASE_OPTIONS_PATH ./lib + run: bash .github/scripts/restore_from_base64.sh "${{ secrets.FIREBASE_OPTIONS }}" "lib/firebase_options.dart" + + - name: Restore constants.dart + run: bash .github/scripts/restore_from_base64.sh "${{ secrets.CONSTANTS }}" "lib/constants.dart" - name: Install Flutter uses: subosito/flutter-action@v2 @@ -89,10 +87,10 @@ jobs: - name: Build .apk env: FLAVOR: ${{ github.event.inputs.flavor }} - run: flutter build apk --release --flavor $FLAVOR --dart-define cameraPreviewAspectRatio=240/320 -t lib/main_$FLAVOR.dart + run: flutter build apk --release --flavor $FLAVOR -t lib/main_$FLAVOR.dart - name: Upload artifact uses: actions/upload-artifact@v3 with: - name: m3_lightmeter_${{ github.event.inputs.flavor }} + name: m3_lightmeter_${{ github.event.inputs.flavor }}_apk path: build/app/outputs/flutter-apk/app-${{ github.event.inputs.flavor }}-release.apk diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml new file mode 100644 index 0000000..91c4247 --- /dev/null +++ b/.github/workflows/build_ipa.yml @@ -0,0 +1,97 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Build Prod .ipa + +on: + workflow_dispatch: + +env: + FLAVOR: "prod" + +jobs: + build: + name: Build .ipa + runs-on: macos-11 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Connect private iap package + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.M3_LIGHTMETER_IAP_KEY }} + + - name: Install the Apple certificate and provisioning profile + env: + APP_STORE_P12: ${{ secrets.APP_STORE_P12 }} + APP_STORE_P12_PASSWORD: ${{ secrets.APP_STORE_P12_PASSWORD }} + APP_STORE_PROVISION_PROD: ${{ secrets.APP_STORE_PROVISION_PROD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + # create variables + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + PROVISION_PATH=$RUNNER_TEMP/build_provision.mobileprovision + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + + # import certificate and provisioning profile from secrets + echo -n "$APP_STORE_P12" | base64 --decode -o $CERTIFICATE_PATH + echo -n "$APP_STORE_PROVISION_PROD" | base64 --decode -o $PROVISION_PATH + + # create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # import certificate to keychain + security import $CERTIFICATE_PATH -P "$APP_STORE_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # apply provisioning profile + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + cp $PROVISION_PATH ~/Library/MobileDevice/Provisioning\ Profiles + + - name: Restore ios/Runner/ExportOptions.plist + run: bash .github/scripts/restore_from_base64.sh "${{ secrets.APP_STORE_EXPORT_OPTIONS }}" "ios/Runner/ExportOptions.plist" + + - name: Restore firebase_options.dart + run: bash .github/scripts/restore_from_base64.sh "${{ secrets.FIREBASE_OPTIONS }}" "lib/firebase_options.dart" + + - name: Restore constants.dart + run: bash .github/scripts/restore_from_base64.sh "${{ secrets.CONSTANTS }}" "lib/constants.dart" + + - name: Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: "stable" + flutter-version: "3.10.0" + + - name: Prepare flutter project + run: | + flutter --version + flutter pub get + flutter pub run intl_utils:generate + + - name: Build .ipa + run: | + flutter build ipa \ + --release \ + --flavor $FLAVOR \ + --target lib/main_$FLAVOR.dart \ + --export-options-plist=ios/Runner/ExportOptions.plist + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: m3_lightmeter_$FLAVOR_ipa + path: build/ios/ipa/lightmeter.ipa + + - name: Clean up keychain and provisioning profile + if: ${{ always() }} + run: | + security delete-keychain $RUNNER_TEMP/app-signing.keychain-db + rm ~/Library/MobileDevice/Provisioning\ Profiles/build_provision.mobileprovision diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml index 69a672b..0d444c6 100644 --- a/.github/workflows/create_release.yml +++ b/.github/workflows/create_release.yml @@ -37,7 +37,7 @@ on: default: true env: - BUILD_ARGS: --release --flavor prod --dart-define cameraPreviewAspectRatio=240/320 -t lib/main_prod.dart + BUILD_ARGS: --release --flavor prod -t lib/main_prod.dart jobs: build: @@ -86,12 +86,10 @@ jobs: cp $GOOGLE_SERVICES_JSON_ANDROID_PATH ./android/app - name: Restore firebase_options.dart - env: - FIREBASE_OPTIONS: ${{ secrets.FIREBASE_OPTIONS }} - run: | - FIREBASE_OPTIONS_PATH=$RUNNER_TEMP/firebase_options.dart - echo -n "$FIREBASE_OPTIONS" | base64 --decode --output $FIREBASE_OPTIONS_PATH - cp $FIREBASE_OPTIONS_PATH ./lib + run: bash .github/scripts/restore_from_base64.sh "${{ secrets.FIREBASE_OPTIONS }}" "lib/firebase_options.dart" + + - name: Restore constants.dart + run: bash .github/scripts/restore_from_base64.sh "${{ secrets.CONSTANTS }}" "lib/constants.dart" # This step makes sense when Github release is enabled because this release increments the build number. # Therefore here we have to increment it as well to build an apk with the same build number. diff --git a/.github/workflows/pr_check.yml b/.github/workflows/pr_check.yml index 113bba0..b2153eb 100644 --- a/.github/workflows/pr_check.yml +++ b/.github/workflows/pr_check.yml @@ -36,6 +36,9 @@ jobs: if: steps.fetch-iap.conclusion != 'success' run: bash ./.github/scripts/stub_iap.sh + - name: Restore constants.dart + run: bash .github/scripts/restore_from_base64.sh "${{ secrets.CONSTANTS }}" "lib/constants.dart" + - uses: subosito/flutter-action@v2 with: channel: "stable" diff --git a/.gitignore b/.gitignore index 4904e66..72e16ec 100644 --- a/.gitignore +++ b/.gitignore @@ -58,7 +58,8 @@ android/app/google-services.json ios/firebase_app_id_file.json ios/Runner/GoogleService-Info.plist /lib/firebase_options.dart +/lib/constants.dart coverage/ test/coverage_helper_test.dart -screenshots/*.png \ No newline at end of file +screenshots/generated/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 76ad313..ff372ab 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,83 +5,49 @@ "version": "0.2.0", "configurations": [ { - "name": "dev-debug (android)", + "name": "dev-debug", "request": "launch", "type": "dart", "flutterMode": "debug", "args": [ "--flavor", "dev", - "--dart-define", - "cameraPreviewAspectRatio=240/320", ], "program": "${workspaceFolder}/lib/main_dev.dart", }, { - "name": "dev-profile (android)", + "name": "dev-profile", "request": "launch", "type": "dart", "flutterMode": "profile", "args": [ "--flavor", "dev", - "--dart-define", - "cameraPreviewAspectRatio=240/320", ], "program": "${workspaceFolder}/lib/main_dev.dart", }, { - "name": "prod-debug (android)", + "name": "prod-debug", "request": "launch", "type": "dart", "flutterMode": "debug", "args": [ "--flavor", "prod", - "--dart-define", - "cameraPreviewAspectRatio=240/320", ], "program": "${workspaceFolder}/lib/main_prod.dart", }, { - "name": "prod-profile (android)", + "name": "prod-profile", "request": "launch", "type": "dart", "flutterMode": "profile", "args": [ "--flavor", "prod", - "--dart-define", - "cameraPreviewAspectRatio=240/320", ], "program": "${workspaceFolder}/lib/main_prod.dart", }, - { - "name": "dev-debug (ios)", - "request": "launch", - "type": "dart", - "flutterMode": "debug", - "args": [ - "--flavor", - "dev", - "--dart-define", - "cameraPreviewAspectRatio=3/4", - ], - "program": "${workspaceFolder}/lib/main_dev.dart", - }, - { - "name": "dev-profile (ios)", - "request": "launch", - "flutterMode": "profile", - "type": "dart", - "args": [ - "--flavor", - "dev", - "--dart-define", - "cameraPreviewAspectRatio=3/4", - ], - "program": "${workspaceFolder}/lib/main_dev.dart", - }, { "name": "dev-simulator", "request": "launch", @@ -91,8 +57,6 @@ "--flavor", "dev", "--dart-define", - "cameraPreviewAspectRatio=240/320", - "--dart-define", "cameraStubImage=assets/camera_stub_image.jpg" ], "program": "${workspaceFolder}/lib/main_dev.dart", diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ddf5c0..e929cb4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,7 +16,7 @@ "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": false + "editor.wordBasedSuggestions": "off" }, "dart.doNotFormat": [ "**/generated/**", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index adaac0f..a59d2c7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,8 +11,6 @@ "--flavor", "dev", "--release", - "--dart-define", - "cameraPreviewAspectRatio=240/320", "-t", "lib/main_dev.dart", ], @@ -27,8 +25,6 @@ "--flavor", "prod", "--release", - "--dart-define", - "cameraPreviewAspectRatio=240/320", "-t", "lib/main_prod.dart", ], @@ -43,8 +39,6 @@ "--flavor", "prod", "--release", - "--dart-define", - "cameraPreviewAspectRatio=240/320", "-t", "lib/main_prod.dart", ], diff --git a/README.md b/README.md index b5ef345..1162d4c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,19 @@ To build this app you need to install Flutter 3.10.0 stable. [How to install](ht ### 2. Project setup +#### Restore _constants.dart_ file + +Create a file _lib/constants.dart_ and paste the following content: + +```dart +const String contactEmail = ''; +const String iapServerUrl = ''; +const String issuesReportUrl = ''; +const String sourceCodeUrl = ''; +``` + +#### Stub IAP package + As part of the app's functionallity is in the private repo, you have to replace these lines in _pubspec.yaml_: ```yaml @@ -75,17 +88,8 @@ Out of the box Firebase Crashlytics won't work. If you want to add Crashlytics t ### 4. Build -#### Android - -You can build an apk by running the following command from the root of the repository: - -```console -flutter build apk --release --flavor dev --dart-define cameraPreviewAspectRatio=240/320 -t lib/main_dev.dart -``` - -### iOS - -TBD +- Checkout [Build .apk](.github/workflows/build_apk.yml) workflow for Android +- Checkout [Build .ipa](.github/workflows/build_ipa.yml) workflow for iOS # Support diff --git a/iap/lib/src/providers/iap_products_provider.dart b/iap/lib/src/providers/iap_products_provider.dart index 9d381ae..799e8a6 100644 --- a/iap/lib/src/providers/iap_products_provider.dart +++ b/iap/lib/src/providers/iap_products_provider.dart @@ -2,9 +2,10 @@ import 'package:flutter/material.dart'; import 'package:m3_lightmeter_iap/src/data/models/iap_product.dart'; class IAPProductsProvider extends StatefulWidget { + final String apiUrl; final Widget child; - const IAPProductsProvider({required this.child, super.key}); + const IAPProductsProvider({required this.apiUrl, required this.child, super.key}); static IAPProductsProviderState of(BuildContext context) => IAPProductsProvider.maybeOf(context)!; diff --git a/integration_test/mocks/paid_features_mock.dart b/integration_test/mocks/paid_features_mock.dart index 5d38c52..1038694 100644 --- a/integration_test/mocks/paid_features_mock.dart +++ b/integration_test/mocks/paid_features_mock.dart @@ -8,12 +8,16 @@ import 'package:mocktail/mocktail.dart'; class _MockIAPStorageService extends Mock implements IAPStorageService {} class MockIAPProviders extends StatefulWidget { + final List equipmentProfiles; final String selectedEquipmentProfileId; + final List films; final Film selectedFilm; final Widget child; const MockIAPProviders({ + this.equipmentProfiles = const [], this.selectedEquipmentProfileId = '', + this.films = mockFilms, this.selectedFilm = const Film.other(), required this.child, super.key, @@ -66,7 +70,12 @@ final mockEquipmentProfiles = [ ApertureValue.values.indexOf(const ApertureValue(1.7, StopType.half)), ApertureValue.values.indexOf(const ApertureValue(16, StopType.full)) + 1, ), - ndValues: NdValue.values.sublist(0, 3), + ndValues: const [ + NdValue(0), + NdValue(2), + NdValue(4), + NdValue(8), + ], shutterSpeedValues: ShutterSpeedValue.values.sublist( ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(1000, true, StopType.full)), ShutterSpeedValue.values.indexOf(const ShutterSpeedValue(16, false, StopType.full)) + 1, diff --git a/integration_test/utils/widget_tester_actions.dart b/integration_test/utils/widget_tester_actions.dart index 8411634..72bc328 100644 --- a/integration_test/utils/widget_tester_actions.dart +++ b/integration_test/utils/widget_tester_actions.dart @@ -63,6 +63,7 @@ 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))); + await pumpAndSettle(); } } diff --git a/ios/Podfile b/ios/Podfile index 462df98..e7974fe 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '11.0' +platform :ios, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0b4c62e..c8d24d4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -399,9 +399,11 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 489Z6UQMGN; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; @@ -412,6 +414,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter; PRODUCT_NAME = Lightmeter; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Lightmeter Development"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -533,9 +536,11 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 489Z6UQMGN; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; @@ -546,6 +551,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter; PRODUCT_NAME = Lightmeter; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Lightmeter Development"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -561,9 +567,11 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 489Z6UQMGN; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 489Z6UQMGN; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Lightmeter; @@ -574,6 +582,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.vodemn.lightmeter; PRODUCT_NAME = Lightmeter; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Lightmeter Development"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme index 1225bc3..9367f67 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme @@ -15,7 +15,7 @@ @@ -45,7 +45,7 @@ @@ -62,7 +62,7 @@ diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme index 8847513..5eaa0a7 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/prod.xcscheme @@ -15,7 +15,7 @@ @@ -45,7 +45,7 @@ @@ -62,7 +62,7 @@ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f2e1bd9..290af5b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,12 @@ + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + + Enc + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/lib/data/haptics_service.dart b/lib/data/haptics_service.dart index e27a6aa..f1bdc6b 100644 --- a/lib/data/haptics_service.dart +++ b/lib/data/haptics_service.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:vibration/vibration.dart'; class HapticsService { @@ -11,10 +13,17 @@ class HapticsService { Future _tryVibrate({required int duration, required int amplitude}) async { if (await _canVibrate()) { - await Vibration.vibrate( - duration: duration, - amplitude: amplitude, - ); + if (Platform.isAndroid) { + await Vibration.vibrate( + duration: duration, + amplitude: amplitude, + ); + } else { + await Vibration.vibrate( + pattern: [duration], + intensities: [amplitude], + ); + } } } diff --git a/lib/environment.dart b/lib/environment.dart index 69ba143..34dc037 100644 --- a/lib/environment.dart +++ b/lib/environment.dart @@ -2,39 +2,23 @@ enum BuildType { dev, prod } class Environment { final BuildType buildType; - final String sourceCodeUrl; - final String issuesReportUrl; - final String contactEmail; - final bool hasLightSensor; const Environment({ required this.buildType, - required this.sourceCodeUrl, - required this.issuesReportUrl, - required this.contactEmail, this.hasLightSensor = false, }); const Environment.dev() : buildType = BuildType.dev, - sourceCodeUrl = 'https://github.com/vodemn/m3_lightmeter', - issuesReportUrl = 'https://github.com/vodemn/m3_lightmeter/issues/new/choose', - contactEmail = 'contact.vodemn@gmail.com', hasLightSensor = false; const Environment.prod() : buildType = BuildType.prod, - sourceCodeUrl = 'https://github.com/vodemn/m3_lightmeter', - issuesReportUrl = 'https://github.com/vodemn/m3_lightmeter/issues/new/choose', - contactEmail = 'contact.vodemn@gmail.com', hasLightSensor = false; Environment copyWith({bool? hasLightSensor}) => Environment( buildType: buildType, - sourceCodeUrl: sourceCodeUrl, - issuesReportUrl: issuesReportUrl, - contactEmail: contactEmail, hasLightSensor: hasLightSensor ?? this.hasLightSensor, ); } diff --git a/lib/platform_config.dart b/lib/platform_config.dart index d06223e..0c86deb 100644 --- a/lib/platform_config.dart +++ b/lib/platform_config.dart @@ -1,10 +1,9 @@ +import 'dart:io'; + class PlatformConfig { const PlatformConfig._(); - static double get cameraPreviewAspectRatio { - final rational = const String.fromEnvironment('cameraPreviewAspectRatio').split('/'); - return int.parse(rational[0]) / int.parse(rational[1]); - } + static double get cameraPreviewAspectRatio => Platform.isAndroid ? 240 / 320 : 288 / 352; static String get cameraStubImage => const String.fromEnvironment('cameraStubImage'); diff --git a/lib/runner.dart b/lib/runner.dart index 04d4321..8eb31b2 100644 --- a/lib/runner.dart +++ b/lib/runner.dart @@ -4,6 +4,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/widgets.dart'; import 'package:lightmeter/application.dart'; import 'package:lightmeter/application_wrapper.dart'; +import 'package:lightmeter/constants.dart'; import 'package:lightmeter/data/analytics/analytics.dart'; import 'package:lightmeter/data/analytics/api/analytics_firebase.dart'; import 'package:lightmeter/environment.dart'; @@ -27,7 +28,10 @@ Future runLightmeterApp(Environment env) async { products: [IAPProduct(storeId: IAPProductType.paidFeatures.storeId)], child: application, ) - : IAPProductsProvider(child: application), + : IAPProductsProvider( + apiUrl: iapServerUrl, + child: application, + ), ); }, _errorsLogger.logCrash, 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 cd91f59..8e8e9ff 100644 --- a/lib/screens/metering/components/camera_container/bloc_container_camera.dart +++ b/lib/screens/metering/components/camera_container/bloc_container_camera.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -253,12 +254,29 @@ class _WidgetsBindingObserver with WidgetsBindingObserver { _WidgetsBindingObserver(this.onLifecycleStateChanged); + /// Revoking camera permissions results in app being killed both on Android and iOS @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (_prevState == AppLifecycleState.inactive && state == AppLifecycleState.resumed) { - return; + switch (defaultTargetPlatform) { + /// On Android opening a dialog results in [AppLifecycleState.inactive] + case TargetPlatform.android: + if (_prevState == AppLifecycleState.inactive && state == AppLifecycleState.resumed) { + return; + } + _prevState = state; + onLifecycleStateChanged(state); + + /// When coming from the app's settings iOS fires paused -> inactive -> resumed state which falls into this condition. + /// So the inactive state is skipped. + case TargetPlatform.iOS: + if (state == AppLifecycleState.inactive) { + return; + } + if (_prevState != state) { + _prevState = state; + onLifecycleStateChanged(state); + } + default: } - _prevState = state; - onLifecycleStateChanged(state); } } diff --git a/lib/screens/metering/components/camera_container/components/camera_preview/components/histogram/widget_histogram.dart b/lib/screens/metering/components/camera_container/components/camera_preview/components/histogram/widget_histogram.dart index 05dfa9d..5b2b60d 100644 --- a/lib/screens/metering/components/camera_container/components/camera_preview/components/histogram/widget_histogram.dart +++ b/lib/screens/metering/components/camera_container/components/camera_preview/components/histogram/widget_histogram.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:lightmeter/res/dimens.dart'; @@ -64,31 +65,58 @@ class _CameraHistogramState extends State { histogramG = List.filled(256, 0); histogramB = List.filled(256, 0); - final int uvRowStride = image.planes[1].bytesPerRow; - final int uvPixelStride = image.planes[1].bytesPerPixel!; - - for (int x = 0; x < image.width; x++) { - for (int y = 0; y < image.height; y++) { - final int uvIndex = uvPixelStride * (x / 2).floor() + uvRowStride * (y / 2).floor(); - final int index = y * image.width + x; - - final yp = image.planes[0].bytes[index]; - final up = image.planes[1].bytes[uvIndex]; - final vp = image.planes[2].bytes[uvIndex]; - - final r = yp + vp * 1436 / 1024 - 179; - final g = yp - up * 46549 / 131072 + 44 - vp * 93604 / 131072 + 91; - final b = yp + up * 1814 / 1024 - 227; - - histogramR[r.round().clamp(0, 255)]++; - histogramG[g.round().clamp(0, 255)]++; - histogramB[b.round().clamp(0, 255)]++; - } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + _yuv420toRgb(image); + case TargetPlatform.iOS: + _bgra8888toRgb(image); + default: } if (mounted) setState(() {}); }); } + + void _yuv420toRgb(CameraImage image) { + final int uvRowStride = image.planes[1].bytesPerRow; + final int uvPixelStride = image.planes[1].bytesPerPixel!; + + for (int x = 0; x < image.width; x++) { + for (int y = 0; y < image.height; y++) { + final int uvIndex = uvPixelStride * (x / 2).floor() + uvRowStride * (y / 2).floor(); + final int index = y * image.width + x; + + final yp = image.planes[0].bytes[index]; + final up = image.planes[1].bytes[uvIndex]; + final vp = image.planes[2].bytes[uvIndex]; + + final r = yp + vp * 1436 / 1024 - 179; + final g = yp - up * 46549 / 131072 + 44 - vp * 93604 / 131072 + 91; + final b = yp + up * 1814 / 1024 - 227; + + histogramR[r.round().clamp(0, 255)]++; + histogramG[g.round().clamp(0, 255)]++; + histogramB[b.round().clamp(0, 255)]++; + } + } + } + + void _bgra8888toRgb(CameraImage image) { + for (int i = 0; i < image.planes.first.bytes.length; i++) { + final int channel = i % 4; + switch (channel) { + case 3: + break; + case 2: + histogramR[image.planes.first.bytes[i]]++; + case 1: + histogramG[image.planes.first.bytes[i]]++; + case 0: + histogramB[image.planes.first.bytes[i]]++; + default: + } + } + } } class HistogramChannel extends StatelessWidget { 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 464228f..0164a35 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 @@ -84,7 +84,16 @@ class _CameraPreviewBuilderState extends State<_CameraPreviewBuilder> { @override Widget build(BuildContext context) { if (PlatformConfig.cameraStubImage.isNotEmpty) { - return Image.asset(PlatformConfig.cameraStubImage); + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + PlatformConfig.cameraStubImage, + fit: BoxFit.cover, + ), + ), + ], + ); } return ValueListenableBuilder( valueListenable: _initializedNotifier, diff --git a/lib/screens/settings/components/about/components/report_issue/widget_list_tile_report_issue.dart b/lib/screens/settings/components/about/components/report_issue/widget_list_tile_report_issue.dart index 72bc1b5..b737791 100644 --- a/lib/screens/settings/components/about/components/report_issue/widget_list_tile_report_issue.dart +++ b/lib/screens/settings/components/about/components/report_issue/widget_list_tile_report_issue.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/constants.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/services_provider.dart'; import 'package:url_launcher/url_launcher.dart'; class ReportIssueListTile extends StatelessWidget { @@ -13,7 +13,7 @@ class ReportIssueListTile extends StatelessWidget { title: Text(S.of(context).reportIssue), onTap: () { launchUrl( - Uri.parse(ServicesProvider.of(context).environment.issuesReportUrl), + Uri.parse(issuesReportUrl), mode: LaunchMode.externalApplication, ); }, diff --git a/lib/screens/settings/components/about/components/source_code/widget_list_tile_source_code.dart b/lib/screens/settings/components/about/components/source_code/widget_list_tile_source_code.dart index 4327332..ad40ce5 100644 --- a/lib/screens/settings/components/about/components/source_code/widget_list_tile_source_code.dart +++ b/lib/screens/settings/components/about/components/source_code/widget_list_tile_source_code.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:lightmeter/constants.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/services_provider.dart'; import 'package:url_launcher/url_launcher.dart'; class SourceCodeListTile extends StatelessWidget { @@ -13,7 +13,7 @@ class SourceCodeListTile extends StatelessWidget { title: Text(S.of(context).sourceCode), onTap: () { launchUrl( - Uri.parse(ServicesProvider.of(context).environment.sourceCodeUrl), + Uri.parse(sourceCodeUrl), mode: LaunchMode.externalApplication, ); }, diff --git a/lib/screens/settings/components/about/components/write_email/widget_list_tile_write_email.dart b/lib/screens/settings/components/about/components/write_email/widget_list_tile_write_email.dart index b0a4391..4d2ee30 100644 --- a/lib/screens/settings/components/about/components/write_email/widget_list_tile_write_email.dart +++ b/lib/screens/settings/components/about/components/write_email/widget_list_tile_write_email.dart @@ -1,7 +1,7 @@ import 'package:clipboard/clipboard.dart'; import 'package:flutter/material.dart'; +import 'package:lightmeter/constants.dart'; import 'package:lightmeter/generated/l10n.dart'; -import 'package:lightmeter/providers/services_provider.dart'; import 'package:url_launcher/url_launcher.dart'; class WriteEmailListTile extends StatelessWidget { @@ -13,8 +13,7 @@ class WriteEmailListTile extends StatelessWidget { leading: const Icon(Icons.email), title: Text(S.of(context).writeEmail), onTap: () { - final email = ServicesProvider.of(context).environment.contactEmail; - final mailToUrl = Uri.parse('mailto:$email?subject=M3 Lightmeter'); + final mailToUrl = Uri.parse('mailto:$contactEmail?subject=M3 Lightmeter'); canLaunchUrl(mailToUrl).then((canLaunch) { if (canLaunch) { launchUrl( @@ -29,7 +28,7 @@ class WriteEmailListTile extends StatelessWidget { action: SnackBarAction( label: S.of(context).copyEmail, onPressed: () { - FlutterClipboard.copy(email).then((_) { + FlutterClipboard.copy(contactEmail).then((_) { ScaffoldMessenger.of(context).clearSnackBars(); }); }, diff --git a/pubspec.yaml b/pubspec.yaml index 0302700..5792aed 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: m3_lightmeter_iap: git: url: "https://github.com/vodemn/m3_lightmeter_iap" - ref: v0.7.2 + ref: v0.8.1 m3_lightmeter_resources: git: url: "https://github.com/vodemn/m3_lightmeter_resources" diff --git a/screenshots/README.md b/screenshots/README.md index 06df390..ab8c20a 100644 --- a/screenshots/README.md +++ b/screenshots/README.md @@ -10,8 +10,8 @@ As a user I want to see the most relevant screenshots in the store, so that I ca - Metering screen - 1. Reflected light metering mode* - 2. Incident light metering mode* ** + 1. Reflected light metering mode\* + 2. Incident light metering mode\* \*\* 3. Opened ISO picker - Settings screen @@ -24,14 +24,41 @@ As a user I want to see the most relevant screenshots in the store, so that I ca 1. Just the screen 2. Opened equipment profile ISO picker -> *also in dark mode +> \*also in dark mode -> **Android only +> \*\*Android only ## Run the generator +Screenshots will be stored in the _screenshots/generated/\/_ folder. + +### Android + ```console -sh screenshots/generate_screenshots.sh +sh screenshots/generate_screenshots.sh ``` -Screenshots will be stored in the _screenshots/_ folder. +### iOS + +Apple requires screenshots a specific list of devices, so we can implement a custom generator to cover all those devices. + +Can be run on Simulator. + +```console +sh screenshots/generate_ios_screenshots.sh +``` + +## List of devices + +### Android + +- Pixel 6 + +### iOS + +- iPhone 8 Plus +- iPhone 13 Pro +- iPhone 13 Pro Max +- iPhone 15 Pro +- iPhone 15 Pro Max +- iPad Pro (12.9-inch) (6th generation) diff --git a/screenshots/generate_screenshots.dart b/screenshots/generate_screenshots.dart index 814cfd1..f767245 100644 --- a/screenshots/generate_screenshots.dart +++ b/screenshots/generate_screenshots.dart @@ -70,37 +70,37 @@ void main() { await tester.pumpApplication(); await tester.takePhoto(); - await tester.takeScreenshot(binding, '${lightThemeColor.value}_metering_reflected'); + await tester.takeScreenshot(binding, 'light-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.takeScreenshot(binding, 'light-metering_incident'); } await tester.openAnimatedPicker(); - await tester.takeScreenshot(binding, '${lightThemeColor.value}_metering_iso_picker'); + await tester.takeScreenshot(binding, 'light-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.takeScreenshot(binding, 'light-settings'); await tester.tapDescendantTextOf(S.current.meteringScreenLayout); - await tester.takeScreenshot(binding, '${lightThemeColor.value}_settings_metering_screen_layout'); + await tester.takeScreenshot(binding, 'light-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.takeScreenshot(binding, 'light-equipment_profiles'); await tester.tap(find.byIcon(Icons.iso).first); await tester.pumpAndSettle(); - await tester.takeScreenshot(binding, '${lightThemeColor.value}_equipment_profiles_iso_picker'); - }, + await tester.takeScreenshot(binding, 'light-equipment_profiles_iso_picker'); + } ); /// and the additionally the first one with the dark theme @@ -111,25 +111,27 @@ void main() { await tester.pumpApplication(); await tester.takePhoto(); - await tester.takeScreenshot(binding, '${darkThemeColor.value}_metering_reflected'); + await tester.takeScreenshot(binding, 'dark-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'); + await tester.takeScreenshot(binding, 'dark-metering_incident'); } }, ); } +final String _platformFolder = Platform.isAndroid ? 'android' : 'ios'; + extension on WidgetTester { Future takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name) async { if (Platform.isAndroid) { await binding.convertFlutterSurfaceToImage(); await pumpAndSettle(); } - await binding.takeScreenshot(name); + await binding.takeScreenshot("$_platformFolder/${const String.fromEnvironment('deviceName')}/$name"); await pumpAndSettle(); } } diff --git a/screenshots/scripts/generate_ios_screenshots.sh b/screenshots/scripts/generate_ios_screenshots.sh new file mode 100644 index 0000000..5befaae --- /dev/null +++ b/screenshots/scripts/generate_ios_screenshots.sh @@ -0,0 +1,13 @@ +devices_array=("iPhone 8 Plus" "iPhone 13 Pro" "iPhone 13 Pro Max" "iPhone 15 Pro" "iPhone 15 Pro Max" "iPad Pro (12.9-inch) (6th generation)") + +open -a Simulator + +for i in "${devices_array[@]}"; do # https://www.baeldung.com/linux/shell-script-iterate-over-string-list#2-understanding--and--special-indices + echo "$i" + xcrun simctl boot "$i" + #uid=$(echo "$(fvm flutter devices)" | sed -n -r "s/$i \(mobile\) • (.*) • .* • .*\(simulator\)/\1/p") + #echo $uid + sh screenshots/scripts/generate_screenshots.sh "$i" +done + +killall 'Simulator' diff --git a/screenshots/generate_screenshots.sh b/screenshots/scripts/generate_screenshots.sh similarity index 68% rename from screenshots/generate_screenshots.sh rename to screenshots/scripts/generate_screenshots.sh index c95568e..fafbf76 100644 --- a/screenshots/generate_screenshots.sh +++ b/screenshots/scripts/generate_screenshots.sh @@ -1,9 +1,12 @@ -flutter drive \ - --dart-define="cameraPreviewAspectRatio=240/320" \ +deviceName="$1" + +fvm flutter drive \ + -d "$deviceName" \ --dart-define="cameraStubImage=assets/camera_stub_image.jpg" \ + --dart-define="deviceName=$deviceName" \ --driver=test_driver/screenshot_driver.dart \ --target=screenshots/generate_screenshots.dart \ - --profile \ + --debug \ --flavor=dev \ --no-dds \ --endless-trace-buffer \ diff --git a/test_driver/screenshot_driver.dart b/test_driver/screenshot_driver.dart index 4348142..fe49e36 100644 --- a/test_driver/screenshot_driver.dart +++ b/test_driver/screenshot_driver.dart @@ -7,7 +7,7 @@ Future main() async { await grantCameraPermission(); await integrationDriver( onScreenshot: (name, bytes, [args]) async { - final File image = await File('screenshots/$name.png').create(recursive: true); + final File image = await File('screenshots/generated/$name.png').create(recursive: true); image.writeAsBytesSync(bytes); return true; },