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
2
.gitignore
vendored
|
@ -63,4 +63,4 @@ ios/Runner/GoogleService-Info.plist
|
|||
coverage/
|
||||
test/coverage_helper_test.dart
|
||||
**/failures/*.png
|
||||
screenshots/generated/
|
||||
screenshots/generated/raw/
|
10
README.md
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
44
screenshots/assets/content/screenshot_titles_en.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
BIN
screenshots/assets/fonts/SF-Pro-Display-Bold-dark.zip
Normal file
BIN
screenshots/assets/fonts/SF-Pro-Display-Bold.zip
Normal file
BIN
screenshots/assets/fonts/SF-Pro-Display-Regular-dark.zip
Normal file
BIN
screenshots/assets/fonts/SF-Pro-Display-Regular.zip
Normal file
BIN
screenshots/assets/frames/android/pixel_6_frame.png
Normal file
After Width: | Height: | Size: 198 KiB |
BIN
screenshots/assets/frames/ios/iphone_13_pro_frame.png
Normal file
After Width: | Height: | Size: 219 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 16 KiB |
189
screenshots/convert_to_store_screenshots.dart
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 492 KiB |
After Width: | Height: | Size: 221 KiB |
After Width: | Height: | Size: 221 KiB |
After Width: | Height: | Size: 235 KiB |
After Width: | Height: | Size: 220 KiB |
After Width: | Height: | Size: 500 KiB |
BIN
screenshots/generated/android/android/light_settings.png
Normal file
After Width: | Height: | Size: 235 KiB |
BIN
screenshots/generated/android/android/light_timer.png
Normal file
After Width: | Height: | Size: 219 KiB |
After Width: | Height: | Size: 346 KiB |
After Width: | Height: | Size: 159 KiB |
After Width: | Height: | Size: 163 KiB |
After Width: | Height: | Size: 177 KiB |
After Width: | Height: | Size: 345 KiB |
BIN
screenshots/generated/ios/iphone55inch/light_settings.png
Normal file
After Width: | Height: | Size: 167 KiB |
BIN
screenshots/generated/ios/iphone55inch/light_timer.png
Normal file
After Width: | Height: | Size: 148 KiB |
After Width: | Height: | Size: 507 KiB |
After Width: | Height: | Size: 223 KiB |
After Width: | Height: | Size: 227 KiB |
After Width: | Height: | Size: 248 KiB |
After Width: | Height: | Size: 501 KiB |
BIN
screenshots/generated/ios/iphone65inch/light_settings.png
Normal file
After Width: | Height: | Size: 235 KiB |
BIN
screenshots/generated/ios/iphone65inch/light_timer.png
Normal file
After Width: | Height: | Size: 206 KiB |
60
screenshots/models/screenshot_args.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
19
screenshots/models/screenshot_config.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
49
screenshots/models/screenshot_device.dart
Normal 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),
|
||||
),
|
||||
];
|
35
screenshots/models/screenshot_layout.dart
Normal 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,
|
||||
});
|
||||
}
|
4
screenshots/scripts/convert_to_store_screenshots.sh
Normal 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
|
1
screenshots/scripts/generate_android_screenshots.sh
Normal file
|
@ -0,0 +1 @@
|
|||
sh screenshots/scripts/generate_screenshots.sh "Pixel 6"
|
|
@ -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'
|
||||
|
|
14
screenshots/utils/parse_configs.dart
Normal 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);
|
||||
}
|
|
@ -79,7 +79,7 @@ extension WidgetTesterActions on WidgetTester {
|
|||
children: [
|
||||
Expanded(
|
||||
child: AnimatedDialogPicker<int>(
|
||||
icon: Icons.iso,
|
||||
icon: Icons.iso_outlined,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
selectedValue: 0,
|
||||
|
|
|
@ -51,7 +51,7 @@ extension WidgetTesterActions on WidgetTester {
|
|||
children: [
|
||||
Expanded(
|
||||
child: AnimatedDialogPicker<int>(
|
||||
icon: Icons.iso,
|
||||
icon: Icons.iso_outlined,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
selectedValue: 0,
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|