Automated release screenshots generation (#177)

* added system overlays for iPhone 8 Plus & iPhone 13 Pro

* add device frame (wip)

* scale device frame (wip)

* add text to screenshots (wip)

* added screenshots config json

* reorganized screenshot models

* cleanup

* added fonts for dark screenshots

* typo

* store raw screenshots

* added standalone script to update screenshots

* wip

* refined screenshots naming

* skip metering layout dialog screenshot

* parse ipad name

* added assets for Pixel 6

* typo

* added text for incident light metering

* reorganized store script

* typo

* wip

* synced outlined icons

* added timer screen to screenshot generator

* constrained timer screen timeline for tablets

* added timer screenshot title

* typo

* revised scripts

* track release screenshots

* Update README.md

* iphone 6.5" -> iphone 6.7"

* Update google_play_resources.md

* softened screenshot font colors

* cleanup
This commit is contained in:
Vadim 2024-05-21 19:13:33 +02:00 committed by GitHub
parent 8c016e548b
commit f62f658be8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 595 additions and 87 deletions

2
.gitignore vendored
View file

@ -63,4 +63,4 @@ ios/Runner/GoogleService-Info.plist
coverage/
test/coverage_helper_test.dart
**/failures/*.png
screenshots/generated/
screenshots/generated/raw/

View file

@ -23,11 +23,11 @@ Without further delay behold my new Lightmeter app inspired by Material You (a.k
# Screenshots
<p float="center">
<img src="https://lh3.googleusercontent.com/8Sd-pmNcQ0xAr5opuTeJKWr2OXeQvCoFSdVDSoKQSHHKeNmqF71hqeAdm3yjunY12zY" width="18.8%" />
<img src="https://lh3.googleusercontent.com/rqBv8pT0AdcBy0xEgQY2unV-YEQ5KfUkandAxJ62yYCiSF72HClA_tkb4JT_3UPaIfFP" width="18.8%" />
<img src="https://lh3.googleusercontent.com/-SnYbYSugVfdwYi6m_rd9CzpCZMCIfudhnq0zRIlzEtLSXhrwziWVd2hotygfqiSofI" width="18.8%" />
<img src="https://lh3.googleusercontent.com/UXxptL_dAIJDtrmpEZuSz39Iq4HuPb3ZPeuANfE9XH0De0uZQT83LNdu1AObBPobpg" width="18.8%" />
<img src="https://lh3.googleusercontent.com/15g_SPV8knDLFbz1_-wGNJFsJeyVWZ_y--TGHpk75MaaIdMDyTXY2_TL-Aw8bpOhpw" width="18.8%" />
<img src="screenshots/generated/android/android/light_metering-reflected.png" width="18.8%" />
<img src="screenshots/generated/android/android/light_settings.png" width="18.8%" />
<img src="screenshots/generated/android/android/light_timer.png" width="18.8%" />
<img src="screenshots/generated/android/android/light_equipment-profiles.png" width="18.8%" />
<img src="screenshots/generated/android/android/dark_metering-reflected.png" width="18.8%" />
</p>
# Development

View file

@ -6,26 +6,19 @@ Lightmeter
## Short description
Simple and powerful metering app inspired by Google's Material Design 3.
A simple and powerful metering app that can be used for any type of camera from film SLR to pinhole to cinematographic.
## Long description
<b>Material Design</b>
The user interface matches every single detail of the material design guidelines to ensure Lightmeter is an eye candy for you.
A simple and easy to use metering app that can be used for any type of camera from film SLR to pinhole to cinematographic. The app contains the following features:
<b>Easy to Use</b>
No complicated or overblown menus but a familiar and clean interface.
- A reflected light meter with spot metering (using the device's camera)
- An incident light meter (using the device's light sensor)
- An in-built timer for shooting long exposures
- A wide range of ISO values sutable even for solarphotograpy
- Reciprocity calculations for a variety of films
<b>Customizability</b>
There is an inbuilt theme engine with many different colors to choose from.
<b>Features</b>
• Incident light metering (uses lightsensor)
• Reflected light metering (needs camera)
• ISO range from 3 to 6400
• Pre-built reciprocity for some films
• Calibration & ND filters
and many more
and many more!
<b>NOTE</b>
The accuracy of the measurements depends on your decice's hardware.

View file

@ -49,7 +49,7 @@ void testE2E(String description) {
/// Create Praktica + Zenitar profile from scratch
await tester.openSettings();
await tester.tapDescendantTextOf<SettingsScreen>(S.current.equipmentProfiles);
await tester.tap(find.byIcon(Icons.add).first);
await tester.tap(find.byIcon(Icons.add_outlined).first);
await tester.pumpAndSettle();
await tester.setProfileName(mockEquipmentProfiles[0].name);
await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[0].name);
@ -63,7 +63,7 @@ void testE2E(String description) {
expect(find.text('1/1000 - B'), findsOneWidget);
/// Create Praktica + Jupiter profile from Zenitar profile
await tester.tap(find.byIcon(Icons.copy).first);
await tester.tap(find.byIcon(Icons.copy_outlined).first);
await tester.pumpAndSettle();
await tester.setProfileName(mockEquipmentProfiles[1].name);
await tester.expandEquipmentProfileContainer(mockEquipmentProfiles[1].name);
@ -193,7 +193,7 @@ extension on WidgetTester {
bool deselectAll = true,
}) async {
if (deselectAll) {
await tap(find.byIcon(Icons.deselect));
await tap(find.byIcon(Icons.deselect_outlined));
await pump();
}
for (final value in valuesToSelect) {

View file

@ -68,4 +68,7 @@ class Dimens {
paddingL,
);
static const EdgeInsets dialogMargin = EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0);
// TODO(@vodemn) constrain dialogs with this value
static const double tabletMaxWidth = 600;
}

View file

@ -79,15 +79,18 @@ class TimerScreenState extends State<TimerScreen> with TickerProviderStateMixin
const Spacer(),
Padding(
padding: const EdgeInsets.all(Dimens.paddingL),
child: SizedBox.fromSize(
size: Size.square(MediaQuery.sizeOf(context).width - Dimens.paddingL * 4),
child: ValueListenableBuilder(
valueListenable: timelineAnimation,
builder: (_, value, child) => TimerTimeline(
progress: value,
child: TimerText(
timeLeft: Duration(milliseconds: (widget.duration.inMilliseconds * value).toInt()),
duration: widget.duration,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: Dimens.tabletMaxWidth, maxWidth: Dimens.tabletMaxWidth),
child: SizedBox.fromSize(
size: Size.square(MediaQuery.sizeOf(context).width - Dimens.paddingL * 4),
child: ValueListenableBuilder(
valueListenable: timelineAnimation,
builder: (_, value, child) => TimerTimeline(
progress: value,
child: TimerText(
timeLeft: Duration(milliseconds: (widget.duration.inMilliseconds * value).toInt()),
duration: widget.duration,
),
),
),
),

View file

@ -44,6 +44,7 @@ dependencies:
vibration: 1.8.1
dev_dependencies:
args: 2.5.0
bloc_test: 9.1.3
build_runner: 2.4.6
flutter_native_splash: 2.3.5
@ -51,9 +52,11 @@ dev_dependencies:
sdk: flutter
golden_toolkit: 0.15.0
google_fonts: 3.0.1
image: 4.1.7
integration_test:
sdk: flutter
lint: 2.1.2
logging: 1.2.0
meta: 1.9.1
mocktail: 0.3.0
test: 1.24.3

View file

@ -17,28 +17,35 @@ As a user I want to see the most relevant screenshots in the store, so that I ca
- 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
- Timer screen
1. Just the screen
> \*also in dark mode
> \*\*Android only
## Run the generator
Screenshots will be stored in the _screenshots/generated/\<platform\>/_ folder.
Release screenshots will be stored in the _screenshots/generated/\<platform\>/_ folder.
### Android
Raw screenshots will be stored in the _screenshots/generated/raw/\<platform\>/_ folder.
### Generate raw screenshots
#### Android
```console
sh screenshots/generate_screenshots.sh <deviceName>
sh screenshots/generate_android_screenshots.sh
```
### iOS
#### iOS
Apple requires screenshots a specific list of devices, so we can implement a custom generator to cover all those devices.
@ -48,6 +55,12 @@ Can be run on Simulator.
sh screenshots/generate_ios_screenshots.sh
```
### Apply store constraints and text data
```console
sh screenshots/scripts/convert_to_store_screenshots.sh
```
## List of devices
### Android
@ -56,9 +69,5 @@ sh screenshots/generate_ios_screenshots.sh
### 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

@ -0,0 +1,44 @@
{
"screenshots": [
{
"screenshotName": "light_metering-reflected",
"title": "Quick & easy to use",
"subtitle": "with all the necessary controls\nunder your thumb"
},
{
"screenshotName": "light_metering-incident",
"title": "Incident light metering",
"subtitle": "using the light sensor\nof your device"
},
{
"screenshotName": "light_metering-iso-picker",
"title": "Lots of ISO values",
"subtitle": "from 3 and up to 6400"
},
{
"screenshotName": "light_timer",
"title": "In-built timer",
"subtitle": "for the ease of shooting\nlong exposures"
},
{
"screenshotName": "light_settings",
"title": "Useful settings",
"subtitle": "to get the most accurate\nmetering results"
},
{
"screenshotName": "light_equipment-profiles",
"title": "Create multiple profiles",
"subtitle": "to match your\ncamera & lens setups"
},
{
"screenshotName": "light_equipment-profiles-iso-picker",
"title": "Fine-tune results",
"subtitle": "by selecting the values\nthat you use the most"
},
{
"screenshotName": "dark_metering-reflected",
"title": "Match your style",
"subtitle": "with various theme types and colors"
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,189 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:args/args.dart';
import 'package:image/image.dart';
import 'package:logging/logging.dart';
import 'models/screenshot_args.dart';
import 'models/screenshot_device.dart';
import 'models/screenshot_layout.dart';
import 'utils/parse_configs.dart';
final _configs = parseScreenshotConfigs();
Future<int> main(List<String> args) async {
final parser = ArgParser()
..addFlag('verbose', abbr: 'v', help: 'Verbose output')
..addOption('platform', abbr: 'p', help: 'Device platform', mandatory: true)
..addOption('device', abbr: 'd', help: 'device_snake_name', mandatory: true)
..addOption('layout', abbr: 'l', help: 'Device layout', mandatory: true);
final ArgResults argResults = parser.parse(args);
if (argResults['verbose'] as bool) {
Logger.root.level = Level.ALL;
} else {
Logger.root.level = Level.INFO;
}
final platform = argResults["platform"] as String;
final device = argResults["device"] as String;
final layout = ScreenshotLayout.values.firstWhere((e) => e.name == argResults["layout"] as String);
Directory('screenshots/generated/raw/$platform/$device').listSync().forEach((filePath) async {
final screenshotName = filePath.path.split('/').last.replaceAll('.png', '');
final screenshotBytes = File(filePath.path).readAsBytesSync();
final screenshot = decodePng(Uint8List.fromList(screenshotBytes))!;
final screenshotArgs = ScreenshotArgs.fromRawName(
name: screenshotName,
deviceName: device,
platformFolder: platform,
);
final file = await File(screenshotArgs.toPath(layout.name)).create(recursive: true);
file.writeAsBytesSync(
encodePng(
screenshot.convertToStoreScreenshot(
args: screenshotArgs,
layout: layout,
),
),
);
});
return 0;
}
extension ScreenshotImage on Image {
Image convertToStoreScreenshot({
required ScreenshotArgs args,
required ScreenshotLayout layout,
}) {
if (_configs[args.nameWithTheme] == null) {
return this;
}
return _addSystemOverlay(
screenshotDevices[args.deviceName]!,
isDark: args.isDark,
)
._addDeviceFrame(
screenshotDevices[args.deviceName]!,
args.backgroundColor,
)
._applyLayout(
layout,
_configs[args.nameWithTheme]!.title,
_configs[args.nameWithTheme]!.subtitle,
isDark: args.isDark,
);
}
Image _addSystemOverlay(ScreenshotDevice device, {required bool isDark}) {
final path = isDark ? device.systemOverlayPathDark : device.systemOverlayPathLight;
final statusBar = copyResize(
decodePng(File(path).readAsBytesSync())!,
width: width,
);
return compositeImage(this, statusBar);
}
Image _addDeviceFrame(ScreenshotDevice device, String color) {
final backgroundColor = ColorRgba8(
int.parse(color.substring(2, 4), radix: 16),
int.parse(color.substring(4, 6), radix: 16),
int.parse(color.substring(6, 8), radix: 16),
int.parse(color.substring(0, 2), radix: 16),
);
final screenshotRounded = copyCrop(
this,
x: 0,
y: 0,
width: width,
height: height,
);
final frame = decodePng(File(device.deviceFramePath).readAsBytesSync())!;
final expandedScreenshot = copyExpandCanvas(
copyExpandCanvas(
screenshotRounded,
newWidth: screenshotRounded.width + device.screenshotFrameOffset.dx,
newHeight: screenshotRounded.height + device.screenshotFrameOffset.dy,
position: ExpandCanvasPosition.bottomRight,
backgroundColor: backgroundColor,
),
newWidth: frame.width,
newHeight: frame.height,
position: ExpandCanvasPosition.topLeft,
backgroundColor: backgroundColor,
);
return compositeImage(expandedScreenshot, frame);
}
Image _applyLayout(ScreenshotLayout layout, String title, String subtitle, {required bool isDark}) {
final textImage = _drawTitles(layout, title, subtitle, isDark: isDark);
final maxFrameHeight =
layout.size.height - (layout.contentPadding.top + textImage.height + 84 + layout.contentPadding.bottom);
int maxFrameWidth = layout.size.width - (layout.contentPadding.left + layout.contentPadding.right);
if (maxFrameWidth * height / width > maxFrameHeight) {
maxFrameWidth = maxFrameHeight * width ~/ height;
}
final scaledScreenshot = copyResize(this, width: maxFrameWidth);
final draft = copyExpandCanvas(
copyExpandCanvas(
scaledScreenshot,
newWidth: scaledScreenshot.width + (layout.size.width - scaledScreenshot.width) ~/ 2,
newHeight: scaledScreenshot.height + layout.contentPadding.bottom,
position: ExpandCanvasPosition.topLeft,
backgroundColor: getPixel(0, 0),
),
newWidth: layout.size.width,
newHeight: layout.size.height,
position: ExpandCanvasPosition.bottomRight,
backgroundColor: getPixel(0, 0),
);
return compositeImage(
draft,
textImage,
dstX: layout.contentPadding.left,
dstY: layout.contentPadding.top,
);
}
Image _drawTitles(ScreenshotLayout layout, String title, String subtitle, {required bool isDark}) {
final titleFont =
BitmapFont.fromZip(File(isDark ? layout.titleFontDarkPath : layout.titleFontPath).readAsBytesSync());
final subtitleFont =
BitmapFont.fromZip(File(isDark ? layout.subtitleFontDarkPath : layout.subtitleFontPath).readAsBytesSync());
final textImage = fill(
Image(
height: titleFont.lineHeight + 36 + subtitleFont.lineHeight * 2,
width: layout.size.width - (layout.contentPadding.left + layout.contentPadding.right),
),
color: getPixel(0, 0),
);
drawString(
textImage,
title,
font: titleFont,
y: 0,
);
int subtitleDy = titleFont.lineHeight + 36;
subtitle.split('\n').forEach((line) {
drawString(
textImage,
line,
font: subtitleFont,
y: subtitleDy,
);
subtitleDy += subtitleFont.lineHeight;
});
return textImage;
}
}

View file

@ -1,3 +1,5 @@
// ignore_for_file: invalid_use_of_visible_for_testing_member
import 'dart:convert';
import 'dart:io';
@ -5,44 +7,58 @@ 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/exposure_pair.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/dimens.dart';
import 'package:lightmeter/res/theme.dart';
import 'package:lightmeter/screens/metering/components/shared/exposure_pairs_list/widget_list_exposure_pairs.dart';
import 'package:lightmeter/screens/metering/components/shared/readings_container/components/iso_picker/widget_picker_iso.dart';
import 'package:lightmeter/screens/metering/screen_metering.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:lightmeter/screens/shared/animated_circular_button/widget_button_circular_animated.dart';
import 'package:lightmeter/screens/timer/screen_timer.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';
import 'models/screenshot_args.dart';
//https://stackoverflow.com/a/67186625/13167574
const _mockFilm = Film('Ilford HP5+', 400);
final Color _lightThemeColor = primaryColorsList[5];
final Color _darkThemeColor = primaryColorsList[3];
final ThemeData _themeLight = themeFrom(_lightThemeColor, Brightness.light);
final ThemeData _themeDark = themeFrom(_darkThemeColor, Brightness.dark);
/// 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
void mockSharedPrefs({
int iso = 400,
int nd = 0,
double calibration = 0.0,
required ThemeType theme,
required Color color,
}) {
SharedPreferences.setMockInitialValues({
/// Metering values
UserPreferencesService.evSourceTypeKey: EvSourceType.camera.index,
UserPreferencesService.isoKey: 400,
UserPreferencesService.ndFilterKey: 0,
UserPreferencesService.isoKey: iso,
UserPreferencesService.ndFilterKey: nd,
/// Metering settings
UserPreferencesService.stopTypeKey: StopType.third.index,
UserPreferencesService.cameraEvCalibrationKey: 0.0,
UserPreferencesService.lightSensorEvCalibrationKey: 0.0,
UserPreferencesService.cameraEvCalibrationKey: calibration,
UserPreferencesService.lightSensorEvCalibrationKey: calibration,
UserPreferencesService.meteringScreenLayoutKey: json.encode(
{
MeteringScreenLayoutFeature.equipmentProfiles: true,
@ -52,6 +68,7 @@ void main() {
),
/// General settings
UserPreferencesService.autostartTimerKey: false,
UserPreferencesService.caffeineKey: true,
UserPreferencesService.hapticsKey: true,
UserPreferencesService.volumeActionKey: VolumeAction.shutter.toString(),
@ -70,7 +87,7 @@ void main() {
/// Generates several screenshots with the light theme
testWidgets('Generate light theme screenshots', (tester) async {
mockSharedPrefs(ThemeType.light, lightThemeColor);
mockSharedPrefs(theme: ThemeType.light, color: _lightThemeColor);
await tester.pumpApplication(
availableFilms: [_mockFilm],
filmsInUse: [_mockFilm],
@ -78,43 +95,39 @@ void main() {
);
await tester.takePhoto();
await tester.takeScreenshot(binding, 'light-metering_reflected');
await tester.takeScreenshotLight(binding, '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, 'light-metering_incident');
await tester.takeScreenshotLight(binding, 'metering-incident');
}
await tester.openAnimatedPicker<IsoValuePicker>();
await tester.takeScreenshot(binding, 'light-metering_iso_picker');
await tester.takeScreenshotLight(binding, 'metering-iso-picker');
await tester.tapCancelButton();
await tester.tap(find.byTooltip(S.current.tooltipOpenSettings));
await tester.pumpAndSettle();
await tester.takeScreenshot(binding, 'light-settings');
await tester.takeScreenshotLight(binding, 'settings');
await tester.tapDescendantTextOf<SettingsScreen>(S.current.meteringScreenLayout);
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, 'light-equipment_profiles');
await tester.takeScreenshotLight(binding, 'equipment-profiles');
await tester.tap(find.byIcon(Icons.iso).first);
await tester.tap(find.byIcon(Icons.iso_outlined).first);
await tester.pumpAndSettle();
await tester.takeScreenshot(binding, 'light-equipment_profiles_iso_picker');
await tester.takeScreenshotLight(binding, '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);
mockSharedPrefs(theme: ThemeType.dark, color: _darkThemeColor);
await tester.pumpApplication(
availableFilms: [_mockFilm],
filmsInUse: [_mockFilm],
@ -122,14 +135,39 @@ void main() {
);
await tester.takePhoto();
await tester.takeScreenshot(binding, 'dark-metering_reflected');
await tester.takeScreenshotDark(binding, '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, 'dark-metering_incident');
}
testWidgets(
'Generate timer screenshot',
(tester) async {
const timerExposurePair = ExposurePair(
ApertureValue(16, StopType.full),
ShutterSpeedValue(8, false, StopType.full),
);
mockSharedPrefs(
iso: 100,
nd: 8,
calibration: -0.3,
theme: ThemeType.light,
color: _lightThemeColor,
);
await tester.pumpApplication(
availableFilms: [_mockFilm],
filmsInUse: [_mockFilm],
selectedFilm: _mockFilm,
);
await tester.takePhoto();
await tester.scrollToExposurePair(
ev: 5,
exposurePair: timerExposurePair,
);
await tester.tap(find.text(timerExposurePair.shutterSpeed.toString()));
await tester.pumpAndSettle();
await tester.mockTimerResumedState(timerExposurePair.shutterSpeed);
await tester.takeScreenshotLight(binding, 'timer');
},
);
}
@ -137,8 +175,56 @@ void main() {
final String _platformFolder = Platform.isAndroid ? 'android' : 'ios';
extension on WidgetTester {
Future<void> takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name) async {
await binding.takeScreenshot("$_platformFolder/${const String.fromEnvironment('deviceName')}/$name");
Future<void> takeScreenshotLight(IntegrationTestWidgetsFlutterBinding binding, String name) =>
_takeScreenshot(binding, name, _themeLight);
Future<void> takeScreenshotDark(IntegrationTestWidgetsFlutterBinding binding, String name) =>
_takeScreenshot(binding, name, _themeDark);
Future<void> _takeScreenshot(IntegrationTestWidgetsFlutterBinding binding, String name, ThemeData theme) async {
final Color backgroundColor = theme.colorScheme.surface;
await binding.takeScreenshot(
ScreenshotArgs(
name: name,
deviceName: const String.fromEnvironment('deviceName'),
platformFolder: _platformFolder,
backgroundColor: backgroundColor.value.toRadixString(16),
isDark: theme.brightness == Brightness.dark,
).toString(),
);
await pumpAndSettle();
}
}
extension on WidgetTester {
Future<void> scrollToExposurePair({
double ev = mockPhotoEv100,
EquipmentProfile equipmentProfile = defaultEquipmentProfile,
required ExposurePair exposurePair,
}) async {
final exposurePairs = MeteringContainerBuidler.buildExposureValues(
ev,
StopType.third,
equipmentProfile,
);
await scrollUntilVisible(
find.byWidgetPredicate((widget) => widget is Row && widget.key == ValueKey(exposurePairs.indexOf(exposurePair))),
56,
scrollable: find.descendant(of: find.byType(ExposurePairsList), matching: find.byType(Scrollable)),
);
}
Future<void> mockTimerResumedState(ShutterSpeedValue shutterSpeedValue) async {
await tap(find.byType(AnimatedCircluarButton));
await pump(Dimens.durationS);
late final skipTimerDuration =
Duration(milliseconds: (shutterSpeedValue.value * 0.35 * Duration.millisecondsPerSecond).toInt());
await pump(skipTimerDuration);
final TimerScreenState state = this.state(find.byType(TimerScreen));
state.startStopIconController.stop();
state.timelineController.stop();
await pump();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View file

@ -0,0 +1,60 @@
import 'dart:convert';
class ScreenshotArgs {
final String name;
final String deviceName;
final String platformFolder;
final String backgroundColor;
final bool isDark;
static const _pathArgsDelimited = '_';
ScreenshotArgs({
required this.name,
required String deviceName,
required this.platformFolder,
required this.backgroundColor,
required this.isDark,
}) : deviceName = deviceName.replaceAll(' ', _pathArgsDelimited).replaceAll(RegExp('[(|)]'), '').toLowerCase();
ScreenshotArgs.fromRawName({
required String name,
required String deviceName,
required this.platformFolder,
}) : name = name.split(_pathArgsDelimited)[1],
deviceName = deviceName.replaceAll(' ', _pathArgsDelimited).replaceAll(RegExp('[(|)]'), '').toLowerCase(),
backgroundColor = name.split(_pathArgsDelimited)[2],
isDark = name.contains('dark');
static const _folderPrefix = 'screenshots/generated';
String get nameWithTheme => '${isDark ? 'dark' : 'light'}$_pathArgsDelimited$name';
String toPathRaw() =>
'$_folderPrefix/raw/$platformFolder/$deviceName/$nameWithTheme$_pathArgsDelimited$backgroundColor.png';
String toPath(String layoutName) => '$_folderPrefix/$platformFolder/$layoutName/$nameWithTheme.png';
@override
String toString() => jsonEncode(_toJson());
factory ScreenshotArgs.fromString(String data) => ScreenshotArgs._fromJson(jsonDecode(data) as Map<String, dynamic>);
factory ScreenshotArgs._fromJson(Map<String, dynamic> data) {
return ScreenshotArgs(
name: data['name'] as String,
deviceName: data['deviceName'] as String,
platformFolder: data['platformFolder'] as String,
backgroundColor: data['backgroundColor'] as String,
isDark: data['isDark'] as bool,
);
}
Map<String, dynamic> _toJson() {
return {
"name": name,
"deviceName": deviceName,
"platformFolder": platformFolder,
"backgroundColor": backgroundColor,
"isDark": isDark,
};
}
}

View file

@ -0,0 +1,19 @@
class ScreenshotConfig {
final String title;
final String subtitle;
final String screenshotName;
const ScreenshotConfig({
required this.title,
required this.subtitle,
required this.screenshotName,
});
factory ScreenshotConfig.fromJson(Map<String, dynamic> data) {
return ScreenshotConfig(
title: data['title'] as String,
subtitle: data['subtitle'] as String,
screenshotName: data['screenshotName'] as String,
);
}
}

View file

@ -0,0 +1,49 @@
enum ScreenshotDevicePlatform { android, ios }
class ScreenshotDevice {
final String name;
final ScreenshotDevicePlatform platform;
final ({int dx, int dy}) screenshotFrameOffset;
const ScreenshotDevice({
required this.name,
required this.platform,
this.screenshotFrameOffset = (dx: 0, dy: 0),
});
ScreenshotDevice.fromDisplayName({
required String displayName,
required this.platform,
this.screenshotFrameOffset = (dx: 0, dy: 0),
}) : name = displayName.replaceAll(' ', '_').toLowerCase();
String get systemOverlayPathLight =>
'screenshots/assets/system_overlays/${platform.name}/${name}_system_overlay_light.png';
String get systemOverlayPathDark =>
'screenshots/assets/system_overlays/${platform.name}/${name}_system_overlay_dark.png';
String get deviceFramePath => 'screenshots/assets/frames/${platform.name}/${name}_frame.png';
}
final screenshotDevices = <String, ScreenshotDevice>{
for (final d in _screenshotDevicesAndroid + _screenshotDevicesIos) d.name: d,
};
final List<ScreenshotDevice> _screenshotDevicesAndroid = [
ScreenshotDevice.fromDisplayName(
displayName: 'Pixel 6',
platform: ScreenshotDevicePlatform.android,
screenshotFrameOffset: (dx: 67, dy: 66),
),
];
final List<ScreenshotDevice> _screenshotDevicesIos = [
ScreenshotDevice.fromDisplayName(
displayName: 'iPhone 8 Plus',
platform: ScreenshotDevicePlatform.ios,
),
ScreenshotDevice.fromDisplayName(
displayName: 'iPhone 13 Pro',
platform: ScreenshotDevicePlatform.ios,
screenshotFrameOffset: (dx: 72, dy: 60),
),
];

View file

@ -0,0 +1,35 @@
enum ScreenshotLayout {
android(
size: (width: 1440, height: 2560),
contentPadding: (left: 144, top: 132, right: 144, bottom: 132),
titleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Bold.zip',
subtitleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Regular.zip',
),
iphone65inch(
size: (width: 1290, height: 2796),
contentPadding: (left: 144, top: 184, right: 144, bottom: 184),
titleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Bold.zip',
subtitleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Regular.zip',
),
iphone55inch(
size: (width: 1242, height: 2208),
contentPadding: (left: 144, top: 144, right: 144, bottom: 144),
titleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Bold.zip',
subtitleFontPath: 'screenshots/assets/fonts/SF-Pro-Display-Regular.zip',
);
final ({int height, int width}) size;
final ({int left, int top, int right, int bottom}) contentPadding;
final String titleFontPath;
final String subtitleFontPath;
String get titleFontDarkPath => '${titleFontPath.split('.').first}-dark.zip';
String get subtitleFontDarkPath => '${subtitleFontPath.split('.').first}-dark.zip';
const ScreenshotLayout({
required this.size,
required this.contentPadding,
required this.titleFontPath,
required this.subtitleFontPath,
});
}

View file

@ -0,0 +1,4 @@
dart run screenshots/convert_to_store_screenshots.dart -p android -d pixel_6 -l android
dart run screenshots/convert_to_store_screenshots.dart -p ios -d iphone_13_pro -l iphone55inch
dart run screenshots/convert_to_store_screenshots.dart -p ios -d iphone_13_pro -l iphone65inch
#dart run screenshots/convert_to_store_screenshots.dart -p ios -d ipad_pro_12.9-inch_6th_generation -l ipad13inch

View file

@ -0,0 +1 @@
sh screenshots/scripts/generate_screenshots.sh "Pixel 6"

View file

@ -1,13 +1,8 @@
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)")
simulators_array=("iPhone 13 Pro" "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
for i in "${simulators_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

@ -0,0 +1,14 @@
import 'dart:convert';
import 'dart:io';
import '../models/screenshot_config.dart';
Map<String, ScreenshotConfig> parseScreenshotConfigs([String locale = 'en']) {
final configPath = 'screenshots/assets/content/screenshot_titles_$locale.json';
final data = jsonDecode(File(configPath).readAsStringSync()) as Map<String, dynamic>;
final entries = (data['screenshots'] as List).map((value) {
final config = ScreenshotConfig.fromJson(value as Map<String, dynamic>);
return MapEntry(config.screenshotName, config);
});
return Map.fromEntries(entries);
}

View file

@ -79,7 +79,7 @@ extension WidgetTesterActions on WidgetTester {
children: [
Expanded(
child: AnimatedDialogPicker<int>(
icon: Icons.iso,
icon: Icons.iso_outlined,
title: '',
subtitle: '',
selectedValue: 0,

View file

@ -51,7 +51,7 @@ extension WidgetTesterActions on WidgetTester {
children: [
Expanded(
child: AnimatedDialogPicker<int>(
icon: Icons.iso,
icon: Icons.iso_outlined,
title: '',
subtitle: '',
selectedValue: 0,

View file

@ -1,14 +1,15 @@
import 'dart:io';
import 'package:integration_test/integration_test_driver_extended.dart';
import 'utils/grant_camera_permission.dart';
import '../screenshots/models/screenshot_args.dart';
Future<void> main() async {
await grantCameraPermission();
await integrationDriver(
onScreenshot: (name, bytes, [args]) async {
final File image = await File('screenshots/generated/$name.png').create(recursive: true);
image.writeAsBytesSync(bytes);
onScreenshot: (name, bytes, [_]) async {
final screenshotArgs = ScreenshotArgs.fromString(name);
final file = await File(screenshotArgs.toPathRaw()).create(recursive: true);
file.writeAsBytesSync(bytes);
return true;
},
);