Created
June 19, 2024 08:53
-
-
Save andrewpmoore/64f9abd40a2497a04803fcba962372d0 to your computer and use it in GitHub Desktop.
App store and Play store flutter image creation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'package:flutter/services.dart'; | |
import 'package:flutter_riverpod/flutter_riverpod.dart'; | |
import 'package:google_fonts/google_fonts.dart'; | |
import 'package:gusty/src/extensions/extensions.dart'; | |
import 'package:path_provider/path_provider.dart'; | |
import 'package:screenshot/screenshot.dart'; | |
import 'package:window_manager/window_manager.dart'; | |
import 'package:auto_size_text/auto_size_text.dart'; | |
import 'package:image/image.dart' as img_lib; | |
import 'dart:typed_data'; | |
import 'dart:io'; | |
import '../../../generated/l10n.dart'; | |
import '../../../main.dart'; | |
/// Simple widget to wrap around the scaffold of a page that allows you to use keyboard shortcuts | |
/// and create a collection of framed images suitable for the play store and app store | |
/// Usage | |
/// Wrap StoreImages around a page's scaffold | |
/// Launch the app on desktop, go to the page press CTRL+1 (script handles CTRL+1 to CTRL+8) | |
/// Each is defined as a screen frame with a set of colours and promo text to show. | |
/// Then press CTRL+S | |
/// This will then go through all screen sizes defined in _sizes and all languages defined in _languages | |
/// and create a screenshot for each, both with and without the frame, change frameList, if you do/won't | |
/// want the frame | |
/// Files will be created in a format suitable for uploading with fastlane | |
/// Pressing CTRL+H will remove the frame from your view | |
/// Tweak as necessary | |
class StoreImages extends ConsumerStatefulWidget { | |
final Widget child; | |
const StoreImages({super.key, required this.child}); | |
@override | |
ConsumerState<StoreImages> createState() => _StoreImagesState(); | |
} | |
class _StoreImagesState extends ConsumerState<StoreImages> { | |
final ScreenshotController _screenshotController = ScreenshotController(); | |
bool showScreenshot = false; | |
int imageNumber = 0; | |
final double heightFrame = 166; | |
final double variableWidthFrame = 120; | |
final List<(String, Size, double, String, String, bool)> _sizes = [ | |
const ('APP_IPHONE_55_5', Size(1242, 2208), 3, 'ios','', false), | |
const ('APP_IPHONE_67_0', Size(1290, 2796), 3, 'ios','', false), | |
const ('APP_IPAD_PRO_3GEN_129_0', Size(2048, 2732), 2, 'ios','', true ), | |
const ('APP_IPAD_PRO_129_0', Size(2048, 2732), 2, 'ios','', true ), | |
const ('android_phone', Size(1080, 1920), 2, 'android','phoneScreenshots', false ), | |
const ('android_tablet_7', Size(1200, 1920), 2, 'android','sevenInchScreenshots', true ), | |
const ('android_tablet_10', Size(1600, 2560), 2, 'android','tenInchScreenshots', true ), | |
]; | |
List<(String, String)> _storeStrings = []; | |
final _colorList = [ | |
[const Color(0xffff8990), const Color(0xff174888), const Color(0xff0d8ab7)], | |
[const Color(0xffFAD7A1), const Color(0xff063f80), const Color(0xff021326)], | |
[const Color(0xff6DD5FA), const Color(0xff4286f4), const Color(0xff373B44)], | |
[const Color(0xffFFFFFF), const Color(0xff2684d0), const Color(0xff1A2980)], | |
[Colors.yellow, Colors.orange, Colors.red, Colors.purple, const Color(0xff1a225f), const Color(0xff2E0580), ], // Deep blue to light aqua | |
[const Color(0xffF0C27B), const Color(0xffF56217), const Color(0xff0B486B)], | |
[const Color(0xff802338), const Color(0xff4389A2), const Color(0xffEECDA3)], | |
[const Color(0xffEDB174), const Color(0xff859398), const Color(0xff283048)], | |
]; | |
//create all images with (true) and without (false) the frames | |
List<bool> frameList = [true, false, ]; //true, false | |
//languages required, language code, and then the android, ios language codes for directory creation | |
final List<(String, String, String)> _languages = [ | |
('de', 'de-DE', 'de-DE'), | |
('es', 'es-ES', 'es-ES'), | |
('fr', 'fr-FR', 'fr-FR'), | |
('it', 'it-IT', 'it'), | |
('ja', 'ja-JP', 'ja'), | |
('ko', 'ko-KR', 'ko'), | |
('nl', 'nl-NL', 'nl-NL'), | |
('no', 'no-NO', 'no'), | |
('pl', 'pl-PL', 'pl'), | |
('ru', 'ru-RU', 'ru'), | |
('zh', 'zh-CN', 'zh-Hans'), // Simplified Chinese | |
('pt', 'pt-PT', 'pt-PT'), | |
('en', 'en-US', 'en-US'), | |
('hi', 'hi-IN', 'hi'), // Hindi (India) | |
('ms', 'ms-MY', 'ms'), // Malay (Malaysia) | |
]; | |
@override | |
void initState() { | |
super.initState(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
if (!kDebugMode) { | |
return widget.child; | |
} | |
_storeStrings = [ | |
(S.of(context).theUltimateWeatherDashboard,'portrait'), | |
(S.of(context).personalWeatherStationConnectivity,'portrait'), | |
(S.of(context).selectionOfForecastProviders,'portrait'), | |
(S.of(context).monitorMultipleLocations,'portrait'), | |
(S.of(context).fortyPlusColorThemesIncludingDynamicWeatherThemes,'portrait'), | |
(S.of(context).customWidgetsForPersonalizedForecasts,'portrait'), | |
(S.of(context).seeRainMapForecasts,'landscape'), | |
(S.of(context).interactiveWeatherMaps,'landscape'), | |
]; | |
return Screenshot( | |
controller: _screenshotController, | |
child: Shortcuts( | |
shortcuts: <LogicalKeySet, Intent>{ | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit1): const ActivateIntent(1), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit2): const ActivateIntent(2), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit3): const ActivateIntent(3), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit4): const ActivateIntent(4), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit5): const ActivateIntent(5), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit6): const ActivateIntent(6), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit7): const ActivateIntent(7), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.digit8): const ActivateIntent(8), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyH): const ActivateIntent(10), | |
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS): const ActivateIntent(20), | |
}, | |
child: Actions( | |
actions: <Type, Action<Intent>>{ | |
ActivateIntent: CallbackAction<ActivateIntent>( | |
onInvoke: (ActivateIntent intent) { | |
_handleCaptureScreenIntent(intent.number); | |
return null; | |
}, | |
), | |
}, | |
child: Focus( | |
autofocus: true, | |
child: !showScreenshot ? widget.child : Container( | |
decoration: BoxDecoration( | |
gradient: LinearGradient(colors: _colorList[imageNumber - 1], | |
begin: imageNumber % 2 == 1 ? Alignment.bottomRight : Alignment.bottomCenter, | |
end: imageNumber % 2 == 1 ? Alignment.topLeft : Alignment.topCenter) | |
), | |
child: Stack( | |
children: [ | |
Padding( | |
padding: EdgeInsets.only(top: 150.0, left: variableWidthFrame/2, right: variableWidthFrame/2), | |
child: Container( | |
decoration: const BoxDecoration(color: Colors.grey, | |
borderRadius: BorderRadius.only(topLeft: Radius.circular(26), topRight: Radius.circular(26)), | |
), | |
child: Padding( | |
padding: const EdgeInsets.only(left: 16.0, right: 16, top: 16), | |
child: ClipRRect( | |
borderRadius: const BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)), | |
child: widget.child), | |
), | |
), | |
), | |
Positioned( | |
top: 30, | |
left: 0, | |
right: 0, | |
child: Material( | |
color: Colors.transparent, | |
child: SizedBox( | |
height: 100, | |
child: Center( | |
child: Padding( | |
padding: const EdgeInsets.symmetric(horizontal: 68), | |
child: AutoSizeText( | |
_storeStrings[imageNumber - 1].$1, | |
maxLines: 2, | |
textAlign: TextAlign.center, | |
style: context.style.displayMedium!.copyWith(fontWeight: FontWeight.w800, fontFamily: GoogleFonts | |
.outfit() | |
.fontFamily), | |
), | |
))))), | |
], | |
), | |
), | |
), | |
), | |
), | |
); | |
} | |
void _handleCaptureScreenIntent(int number) async { | |
if (number < 10) { | |
setState(() { | |
imageNumber = number; | |
}); | |
} | |
if (number == 10) { | |
//hide process ctrl-h | |
setState(() { | |
showScreenshot = false; | |
}); | |
return; | |
} | |
if (number == 20) { | |
//save process ctrl-s | |
await windowManager.ensureInitialized(); | |
for (var withFrame in frameList) { | |
setState(() { | |
showScreenshot = withFrame; | |
}); | |
for (var (device, size, density, platform, appDir, tablet) in _sizes) { | |
//switch to landscape if set for that and the device is a tablet | |
if (_storeStrings[imageNumber - 1].$2=='landscape'&&tablet){ | |
size = Size(size.height, size.width); //switch the dimensions around | |
} | |
for (var (lang, androidLang, iosLang) in _languages) { | |
String directory = '${(await getApplicationDocumentsDirectory()).path}/screenshots/${withFrame ? 'frame' : 'frameless'}'; | |
if (platform=='android'){ | |
directory = '$directory/android/metadata/android/$androidLang/images/$appDir'; | |
} else if (platform=='ios'){ | |
directory = '$directory/ios/screenshots/$iosLang'; | |
} | |
//change the provider to the current locale (this is using riverpod, but any other state provider management that's listening for lang changes would work | |
ref.watch(settingsProvider).updateLocale(Locale(lang)); | |
WindowOptions windowOptions = WindowOptions( | |
size: Size((size.width / density) + (withFrame ? variableWidthFrame+32 : 0), (size.height / density) + (withFrame ? heightFrame : 0)), | |
center: true, | |
alwaysOnTop: false, | |
); | |
windowManager.waitUntilReadyToShow(windowOptions, () async { | |
await windowManager.show(); | |
await windowManager.focus(); | |
}); | |
String fileName = '${imageNumber}_$device.jpg'; | |
await _screenshotController.captureAndSave(directory, fileName: fileName, pixelRatio: density, delay: const Duration(milliseconds: 2000)); | |
// print('resize $directory/$fileName'); | |
await resizeAndCropImage('$directory/$fileName', size.width.toInt(), size.height.toInt()); //size.width | |
} | |
} | |
} | |
// print('Save complete'); | |
return; | |
} | |
setState(() { | |
showScreenshot = true; | |
}); | |
} | |
Future<void> resizeAndCropImage(String filePath, int newWidth, int newHeight) async { | |
// Read the image file | |
final File file = File(filePath); | |
final List<int> imageBytes = await file.readAsBytes(); | |
// Convert List<int> to Uint8List | |
final Uint8List uint8list = Uint8List.fromList(imageBytes); | |
// Decode the image | |
img_lib.Image originalImage = img_lib.decodeImage(uint8list)!; | |
// Calculate the aspect ratios | |
double originalAspectRatio = originalImage.width / originalImage.height; | |
double newAspectRatio = newWidth / newHeight; | |
int resizeWidth; | |
int resizeHeight; | |
// Determine new dimensions that cover the area | |
if (originalAspectRatio > newAspectRatio) { | |
// Fit to height | |
resizeHeight = newHeight; | |
resizeWidth = (newHeight * originalAspectRatio).round(); | |
} else { | |
// Fit to width | |
resizeWidth = newWidth; | |
resizeHeight = (newWidth / originalAspectRatio).round(); | |
} | |
// Resize the image while maintaining aspect ratio | |
img_lib.Image resizedImage = img_lib.copyResize( | |
originalImage, | |
width: resizeWidth, | |
height: resizeHeight, | |
); | |
// Calculate crop coordinates | |
int cropX = (resizeWidth - newWidth) ~/ 2; | |
int cropY = (resizeHeight - newHeight) ~/ 2; | |
// Crop the image to the specified dimensions | |
img_lib.Image croppedImage = img_lib.copyCrop( | |
resizedImage, | |
x: cropX, | |
y: cropY, | |
width: newWidth, | |
height: newHeight, | |
); | |
// Encode the resized and cropped image | |
List<int> croppedImageBytes = img_lib.encodeJpg(croppedImage); | |
// Save the resized and cropped image to the same file | |
await file.writeAsBytes(croppedImageBytes); | |
} | |
} | |
class ActivateIntent extends Intent { | |
const ActivateIntent(this.number); | |
final int number; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment