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
This commit is contained in:
Vadim 2024-02-21 12:33:25 +01:00 committed by GitHub
parent a1ce17d675
commit 134af8ad28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 363 additions and 169 deletions

14
.github/scripts/restore_from_base64.sh vendored Normal file
View file

@ -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"

View file

@ -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

97
.github/workflows/build_ipa.yml vendored Normal file
View file

@ -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

View file

@ -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.

View file

@ -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"

3
.gitignore vendored
View file

@ -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
screenshots/generated/

44
.vscode/launch.json vendored
View file

@ -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",

View file

@ -16,7 +16,7 @@
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.suggestSelection": "first",
"editor.tabCompletion": "onlySnippets",
"editor.wordBasedSuggestions": false
"editor.wordBasedSuggestions": "off"
},
"dart.doNotFormat": [
"**/generated/**",

6
.vscode/tasks.json vendored
View file

@ -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",
],

View file

@ -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

View file

@ -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)!;

View file

@ -8,12 +8,16 @@ import 'package:mocktail/mocktail.dart';
class _MockIAPStorageService extends Mock implements IAPStorageService {}
class MockIAPProviders extends StatefulWidget {
final List<EquipmentProfile> equipmentProfiles;
final String selectedEquipmentProfileId;
final List<Film> 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,

View file

@ -63,6 +63,7 @@ extension WidgetTesterListTileActions on WidgetTester {
/// Useful for tapping a specific [ListTile] inside a specific screen or dialog
Future<void> tapDescendantTextOf<T>(String text) async {
await tap(find.descendant(of: find.byType(T), matching: find.text(text)));
await pumpAndSettle();
}
}

View file

@ -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'

View file

@ -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";

View file

@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Lightmeter.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@ -45,7 +45,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Lightmeter.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@ -62,7 +62,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Lightmeter.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>

View file

@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Lightmeter.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@ -45,7 +45,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Lightmeter.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@ -62,7 +62,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Lightmeter.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>

View file

@ -2,6 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>
<string></string>
<key>Enc</key>
<string></string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:vibration/vibration.dart';
class HapticsService {
@ -11,10 +13,17 @@ class HapticsService {
Future<void> _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],
);
}
}
}

View file

@ -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,
);
}

View file

@ -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');

View file

@ -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<void> runLightmeterApp(Environment env) async {
products: [IAPProduct(storeId: IAPProductType.paidFeatures.storeId)],
child: application,
)
: IAPProductsProvider(child: application),
: IAPProductsProvider(
apiUrl: iapServerUrl,
child: application,
),
);
},
_errorsLogger.logCrash,

View file

@ -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);
}
}

View file

@ -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<CameraHistogram> {
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 {

View file

@ -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<bool>(
valueListenable: _initializedNotifier,

View file

@ -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,
);
},

View file

@ -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,
);
},

View file

@ -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();
});
},

View file

@ -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"

View file

@ -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/\<platform\>/_ folder.
### Android
```console
sh screenshots/generate_screenshots.sh
sh screenshots/generate_screenshots.sh <deviceName>
```
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)

View file

@ -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<IsoValuePicker>();
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<SettingsScreen>(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<SettingsScreen>(S.current.equipmentProfiles);
await tester.pumpAndSettle();
await tester.tapDescendantTextOf<EquipmentProfilesScreen>(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<void> 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();
}
}

View file

@ -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'

View file

@ -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 \

View file

@ -7,7 +7,7 @@ Future<void> 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;
},