In many apps, you may want users to be able to save or share visual content generated in the UI. Flutter doesn’t ship with a “save widget to image” API, but with RepaintBoundary
plus a few small packages, you can capture any widget, save it to the device’s gallery, and share it through the native share sheet.
This article will go through the process of capturing and saving a widget step-by-step. We’ll be building a small Flutter app that renders a styled Quote Card and provides two actions:
Save the quote card to the device’s gallery as a PNG.
Share the image through the native share sheet (WhatsApp, Gmail, Messages, and so on).
Table of Contents:
Prerequisites
Flutter 3.x or later installed and configured
An Android device or emulator, and optionally an iOS device or simulator
Basic familiarity with Flutter widgets and project structure
Project Setup
Create a new project and open it in your IDE:
flutter create quote_share_app
<span class="hljs-built_in">cd</span> quote_share_app
Dependencies
Add the following to pubspec.yaml
under dependencies:
and run flutter pub get
:
<span class="hljs-attr">dependencies:</span>
<span class="hljs-attr">flutter:</span>
<span class="hljs-attr">sdk:</span> <span class="hljs-string">flutter</span>
<span class="hljs-attr">permission_handler:</span> <span class="hljs-string">^11.3.1</span>
<span class="hljs-attr">image_gallery_saver:</span> <span class="hljs-string">^2.0.3</span>
<span class="hljs-attr">path_provider:</span> <span class="hljs-string">^2.1.3</span>
<span class="hljs-attr">share_plus:</span> <span class="hljs-string">^9.0.0</span>
Notes about this code:
permission_handler
handles runtime permissions where required.image_gallery_saver
writes raw bytes to the photo gallery (Android and iOS).path_provider
creates a temporary file location before sharing.share_plus
invokes the platform share sheet.
Version numbers above are examples that work with Flutter 3.x at the time of writing. If you update, check each package’s README for any API changes.
Platform Configuration
Modern Android and iOS storage permissions are stricter than older blog posts often suggest. The snippets below are current best practices.
Android
Open android/app/src/main/AndroidManifest.xml
.
For Android 10 (API 29) and above, WRITE_EXTERNAL_STORAGE
is deprecated. For Android 13 (API 33)+ you request media-scoped permissions like READ_MEDIA_IMAGES
only if you are reading images. For saving your own image to the Pictures or DCIM collection, many devices don’t require the legacy external storage permissions when you write via MediaStore (plugins often handle this). image_gallery_saver
typically works without WRITE_EXTERNAL_STORAGE
on API 29+.
Add the following only if you target older devices and the plugin still requires it. Otherwise, you can omit storage permissions for modern SDKs.
<span class="hljs-comment"><!-- Optional for older devices pre-API 29 --></span>
<span class="hljs-tag"><<span class="hljs-name">uses-permission</span> <span class="hljs-attr">android:name</span>=<span class="hljs-string">"android.permission.WRITE_EXTERNAL_STORAGE"</span>
<span class="hljs-attr">android:maxSdkVersion</span>=<span class="hljs-string">"28"</span> /></span>
<span class="hljs-tag"><<span class="hljs-name">uses-permission</span> <span class="hljs-attr">android:name</span>=<span class="hljs-string">"android.permission.READ_EXTERNAL_STORAGE"</span>
<span class="hljs-attr">android:maxSdkVersion</span>=<span class="hljs-string">"32"</span> /></span>
<span class="hljs-comment"><!-- For Android 13+ if you ever need to read user images; not required just to write your own image --></span>
<span class="hljs-tag"><<span class="hljs-name">uses-permission</span> <span class="hljs-attr">android:name</span>=<span class="hljs-string">"android.permission.READ_MEDIA_IMAGES"</span> /></span>
Do not add android:requestLegacyExternalStorage="true"
. That flag was a temporary compatibility bridge for Android 10 and is not recommended anymore.
Gradle configuration: ensure your compileSdkVersion
and targetSdkVersion
are reasonably up to date (33 or 34). You usually don’t need special Gradle changes beyond what Flutter templates provide.
iOS
Open ios/Runner/Info.plist
and add the following keys to explain why you save to the user’s photo library:
<span class="hljs-tag"><<span class="hljs-name">key</span>></span>NSPhotoLibraryAddUsageDescription<span class="hljs-tag"></<span class="hljs-name">key</span>></span>
<span class="hljs-tag"><<span class="hljs-name">string</span>></span>The app needs access to save your generated images.<span class="hljs-tag"></<span class="hljs-name">string</span>></span>
<span class="hljs-tag"><<span class="hljs-name">key</span>></span>NSPhotoLibraryUsageDescription<span class="hljs-tag"></<span class="hljs-name">key</span>></span>
<span class="hljs-tag"><<span class="hljs-name">string</span>></span>The app needs access to your photo library.<span class="hljs-tag"></<span class="hljs-name">string</span>></span>
Some devices only require the Add usage description for writing, but supplying both keeps intent clear.
App Architecture and Files Overview
To keep the code maintainable, we will split it into small files:
lib/main.dart
lib/widgets/quote_card.dart
lib/utils/capture.dart
lib/services/permission_service.dart
lib/services/gallery_saver_service.dart
lib/services/share_service.dart
lib/screens/quote_screen.dart
This is the flow:
QuoteCard
renders the visual widget we want to capture.captureWidgetToPngBytes(GlobalKey)
converts that widget into PNG bytes usingRepaintBoundary
.PermissionService
requests storage or photo library permissions when needed.GallerySaverService
saves bytes to the gallery.ShareService
writes bytes to a temporary file and triggers the share sheet.QuoteScreen
wires everything together with two buttons: Save and Share.
Code Sections With Explanations
1. lib/main.dart
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:flutter/material.dart'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'screens/quote_screen.dart'</span>;
<span class="hljs-keyword">void</span> main() {
runApp(<span class="hljs-keyword">const</span> QuoteShareApp());
}
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">QuoteShareApp</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">StatelessWidget</span> </span>{
<span class="hljs-keyword">const</span> QuoteShareApp({<span class="hljs-keyword">super</span>.key});
<span class="hljs-meta">@override</span>
Widget build(BuildContext context) {
<span class="hljs-keyword">return</span> MaterialApp(
title: <span class="hljs-string">'Quote Share App'</span>,
debugShowCheckedModeBanner: <span class="hljs-keyword">false</span>,
theme: ThemeData(
colorSchemeSeed: Colors.teal,
useMaterial3: <span class="hljs-keyword">true</span>,
),
home: <span class="hljs-keyword">const</span> QuoteScreen(),
);
}
}
Code explanation:
runApp
bootstraps the app.MaterialApp
provides theming and navigation.QuoteScreen
is our only screen; it displays the card and buttons.
2. lib/widgets/quote_card.dart
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:flutter/material.dart'</span>;
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">QuoteCard</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">StatelessWidget</span> </span>{
<span class="hljs-keyword">final</span> <span class="hljs-built_in">String</span> quote;
<span class="hljs-keyword">final</span> <span class="hljs-built_in">String</span> author;
<span class="hljs-keyword">const</span> QuoteCard({
<span class="hljs-keyword">super</span>.key,
<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.quote,
<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.author,
});
<span class="hljs-meta">@override</span>
Widget build(BuildContext context) {
<span class="hljs-keyword">return</span> Container(
width: <span class="hljs-built_in">double</span>.infinity,
padding: <span class="hljs-keyword">const</span> EdgeInsets.all(<span class="hljs-number">24</span>),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(<span class="hljs-number">20</span>),
boxShadow: [
BoxShadow(
color: Colors.teal.shade200.withOpacity(<span class="hljs-number">0.4</span>),
blurRadius: <span class="hljs-number">12</span>,
offset: <span class="hljs-keyword">const</span> Offset(<span class="hljs-number">2</span>, <span class="hljs-number">6</span>),
),
],
border: Border.all(color: Colors.teal.shade200, width: <span class="hljs-number">1</span>),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
<span class="hljs-string">'"<span class="hljs-subst">$quote</span>"'</span>,
style: <span class="hljs-keyword">const</span> TextStyle(
fontSize: <span class="hljs-number">22</span>,
fontStyle: FontStyle.italic,
color: Colors.black87,
height: <span class="hljs-number">1.4</span>,
),
),
<span class="hljs-keyword">const</span> SizedBox(height: <span class="hljs-number">16</span>),
Align(
alignment: Alignment.bottomRight,
child: Text(
<span class="hljs-string">'- <span class="hljs-subst">$author</span>'</span>,
style: <span class="hljs-keyword">const</span> TextStyle(
fontSize: <span class="hljs-number">16</span>,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
),
],
),
);
}
}
Code explanation:
Pure UI. This widget is what we will capture into an image.
The stylings (padding, shadows, rounded corners) ensure that the result looks good when saved or shared.
3. lib/utils/capture.dart
<span class="hljs-keyword">import</span> <span class="hljs-string">'dart:typed_data'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'dart:ui'</span> <span class="hljs-keyword">as</span> ui;
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:flutter/material.dart'</span>;
<span class="hljs-comment">/// <span class="markdown">Captures the widget referenced by [boundaryKey] into PNG bytes.</span></span>
<span class="hljs-comment">/// <span class="markdown">Place a RepaintBoundary keyed with [boundaryKey] around the widget you want to capture.</span></span>
Future<Uint8List?> captureWidgetToPngBytes(GlobalKey boundaryKey, {<span class="hljs-built_in">double</span> pixelRatio = <span class="hljs-number">3.0</span>}) <span class="hljs-keyword">async</span> {
<span class="hljs-keyword">try</span> {
<span class="hljs-keyword">final</span> context = boundaryKey.currentContext;
<span class="hljs-keyword">if</span> (context == <span class="hljs-keyword">null</span>) <span class="hljs-keyword">return</span> <span class="hljs-keyword">null</span>;
<span class="hljs-keyword">final</span> renderObject = context.findRenderObject();
<span class="hljs-keyword">if</span> (renderObject <span class="hljs-keyword">is</span>! RenderRepaintBoundary) <span class="hljs-keyword">return</span> <span class="hljs-keyword">null</span>;
<span class="hljs-comment">// If the boundary hasn't painted yet, wait a frame and try again.</span>
<span class="hljs-keyword">if</span> (renderObject.debugNeedsPaint) {
<span class="hljs-keyword">await</span> Future.delayed(<span class="hljs-keyword">const</span> <span class="hljs-built_in">Duration</span>(milliseconds: <span class="hljs-number">20</span>));
<span class="hljs-keyword">return</span> captureWidgetToPngBytes(boundaryKey, pixelRatio: pixelRatio);
}
<span class="hljs-comment">// Render to an Image with a higher pixelRatio for sharpness on high-dpi screens.</span>
<span class="hljs-keyword">final</span> ui.Image image = <span class="hljs-keyword">await</span> renderObject.toImage(pixelRatio: pixelRatio);
<span class="hljs-comment">// Encode the Image to PNG and return bytes.</span>
<span class="hljs-keyword">final</span> byteData = <span class="hljs-keyword">await</span> image.toByteData(format: ui.ImageByteFormat.png);
<span class="hljs-keyword">return</span> byteData?.buffer.asUint8List();
} <span class="hljs-keyword">catch</span> (e) {
debugPrint(<span class="hljs-string">'captureWidgetToPngBytes error: <span class="hljs-subst">$e</span>'</span>);
<span class="hljs-keyword">return</span> <span class="hljs-keyword">null</span>;
}
}
Code explanation line by line:
We accept a
GlobalKey
that must be attached to aRepaintBoundary
wrapping the target widget.findRenderObject()
retrieves the render tree node.RenderRepaintBoundary
can snapshot itself to an image.debugNeedsPaint
indicates whether the widget is fully laid out and painted. If not, we wait briefly and retry.toImage(pixelRatio: 3.0)
renders at higher resolution for crisp output. Increase if you need even sharper images, but note memory tradeoffs.We encode the
ui.Image
to PNG viatoByteData
and return its bytes.
4. lib/services/permission_service.dart
<span class="hljs-keyword">import</span> <span class="hljs-string">'dart:io'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:permission_handler/permission_handler.dart'</span>;
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">PermissionService</span> </span>{
<span class="hljs-comment">/// <span class="markdown">Requests any storage/photo permissions needed for saving an image.</span></span>
<span class="hljs-comment">/// <span class="markdown">On modern Android and iOS, saving to the Photos collection may not require</span></span>
<span class="hljs-comment">/// <span class="markdown">the legacy WRITE permission, but some devices and OS versions still prompt.</span></span>
<span class="hljs-keyword">static</span> Future<<span class="hljs-built_in">bool</span>> requestSavePermission() <span class="hljs-keyword">async</span> {
<span class="hljs-keyword">if</span> (Platform.isAndroid) {
<span class="hljs-comment">// For Android 13+ you typically do not need WRITE permission to save your own image.</span>
<span class="hljs-comment">// Some OEMs still require storage permission for certain gallery operations.</span>
<span class="hljs-keyword">final</span> status = <span class="hljs-keyword">await</span> Permission.storage.request();
<span class="hljs-keyword">if</span> (status.isGranted) <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
<span class="hljs-keyword">if</span> (status.isPermanentlyDenied) {
<span class="hljs-keyword">await</span> openAppSettings();
}
<span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;
}
<span class="hljs-keyword">if</span> (Platform.isIOS) {
<span class="hljs-comment">// On iOS, request Photos permission for adding to library when needed.</span>
<span class="hljs-keyword">final</span> status = <span class="hljs-keyword">await</span> Permission.photosAddOnly.request();
<span class="hljs-comment">// Fallback if photosAddOnly is unavailable on older plugin versions:</span>
<span class="hljs-keyword">if</span> (status.isGranted) <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
<span class="hljs-comment">// Some iOS versions may use `Permission.photos`.</span>
<span class="hljs-keyword">final</span> photos = <span class="hljs-keyword">await</span> Permission.photos.request();
<span class="hljs-keyword">if</span> (photos.isGranted) <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
<span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;
}
<span class="hljs-comment">// Other platforms</span>
<span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
}
}
In the above code, the Android storage permissions are fragmented by API level and OEM behavior. Requesting Permission.storage
remains a pragmatic approach when using gallery saver plugins, though many modern devices will succeed even if the user denies it.
On iOS, we request photo-library add permission, which allows writing to the library.
5. lib/services/gallery_saver_service.dart
<span class="hljs-keyword">import</span> <span class="hljs-string">'dart:typed_data'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:image_gallery_saver/image_gallery_saver.dart'</span>;
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">GallerySaverService</span> </span>{
<span class="hljs-comment">/// <span class="markdown">Saves [pngBytes] to the gallery and returns a descriptive result map from the plugin.</span></span>
<span class="hljs-keyword">static</span> Future<<span class="hljs-built_in">Map?</span>> savePngBytesToGallery(Uint8List pngBytes, {<span class="hljs-built_in">String?</span> name}) <span class="hljs-keyword">async</span> {
<span class="hljs-keyword">final</span> result = <span class="hljs-keyword">await</span> ImageGallerySaver.saveImage(
pngBytes,
name: name, <span class="hljs-comment">// Optional file base name (plugin may append extension/time)</span>
quality: <span class="hljs-number">100</span>,
);
<span class="hljs-comment">// Plugin returns a platform-dependent structure. We bubble it up unchanged.</span>
<span class="hljs-keyword">return</span> result <span class="hljs-keyword">as</span> <span class="hljs-built_in">Map?</span>;
}
}
Code explanation:
image_gallery_saver
writes the provided bytes to the photo library.We pass
quality: 100
for best PNG quality. The plugin may place the file in DCIM/Pictures on Android and Photos on iOS.
This code defines a utility class that saves raw PNG image data (bytes) into the device’s photo gallery. Let me explain it step by step:
<span class="hljs-keyword">import</span> <span class="hljs-string">'dart:typed_data'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:image_gallery_saver/image_gallery_saver.dart'</span>;
dart:typed_data
is imported because the image is represented asUint8List
(a list of unsigned 8-bit integers, basically raw binary data).image_gallery_saver
is a Flutter plugin that lets you save images and videos to the device’s gallery.
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">GallerySaverService</span> </span>{
<span class="hljs-comment">/// <span class="markdown">Saves [pngBytes] to the gallery and returns a descriptive result map from the plugin.</span></span>
<span class="hljs-keyword">static</span> Future<<span class="hljs-built_in">Map?</span>> savePngBytesToGallery(Uint8List pngBytes, {<span class="hljs-built_in">String?</span> name}) <span class="hljs-keyword">async</span> {
The class is called
GallerySaverService
.It has a static method
savePngBytesToGallery
that takes:pngBytes
: the raw PNG image data you want to save.name
: an optional file name to use for the saved image.
<span class="hljs-keyword">final</span> result = <span class="hljs-keyword">await</span> ImageGallerySaver.saveImage(
pngBytes,
name: name, <span class="hljs-comment">// Optional file base name (plugin may append extension/time)</span>
quality: <span class="hljs-number">100</span>,
);
ImageGallerySaver.saveImage
is called to save the image to the gallery.pngBytes
is passed in directly.name
is optional. The plugin may add an extension like.png
and/or a timestamp to ensure uniqueness.quality: 100
ensures the best quality is saved (this parameter mostly applies to JPG, but still ensures maximum fidelity).
<span class="hljs-comment">// Plugin returns a platform-dependent structure. We bubble it up unchanged.</span>
<span class="hljs-keyword">return</span> result <span class="hljs-keyword">as</span> <span class="hljs-built_in">Map?</span>;
}
}
The plugin returns a result that may vary depending on platform (Android or iOS). Usually it’s a map containing information like file path and whether it was successful.
This method just forwards that result without altering it.
as Map?
ensures the return type is a nullable Map.
In short: This class takes PNG image bytes, saves them to the user’s gallery, and returns a result map containing info about the saved file.
6. lib/services/share_service.dart
<span class="hljs-keyword">import</span> <span class="hljs-string">'dart:io'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'dart:typed_data'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:path_provider/path_provider.dart'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:share_plus/share_plus.dart'</span>;
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ShareService</span> </span>{
<span class="hljs-comment">/// <span class="markdown">Writes [pngBytes] to a temporary file and invokes the platform share sheet.</span></span>
<span class="hljs-keyword">static</span> Future<<span class="hljs-keyword">void</span>> sharePngBytes(Uint8List pngBytes, {<span class="hljs-built_in">String?</span> text}) <span class="hljs-keyword">async</span> {
<span class="hljs-keyword">final</span> tempDir = <span class="hljs-keyword">await</span> getTemporaryDirectory();
<span class="hljs-keyword">final</span> filePath = <span class="hljs-string">'<span class="hljs-subst">${tempDir.path}</span>/quote_<span class="hljs-subst">${DateTime.now().millisecondsSinceEpoch}</span>.png'</span>;
<span class="hljs-keyword">final</span> file = File(filePath);
<span class="hljs-keyword">await</span> file.writeAsBytes(pngBytes, flush: <span class="hljs-keyword">true</span>);
<span class="hljs-keyword">await</span> Share.shareXFiles(
[XFile(file.path)],
text: text ?? <span class="hljs-string">'Sharing a quote from my app.'</span>,
);
}
}
Sharing generally requires a file path, not raw bytes. We create a temporary file, write the bytes, and pass it to share_plus
using shareXFiles
.
This code defines a ShareService
class in Flutter/Dart that allows you to share an image (provided as raw PNG bytes) through the platform’s native share sheet (the system dialog that lets you share to WhatsApp, Gmail, Messenger, and so on).
Here’s a breakdown of what’s happening:
Imports
dart:io
: Gives access to theFile
class for reading/writing files.dart:typed_data
: ProvidesUint8List
, the data type used for raw byte arrays (like image data).path_provider
: Used to get system directories (in this case, a temporary directory).share_plus
: Provides the API for invoking the share sheet with text, files, images, and so on.
Class:
ShareService
- A utility class that contains one static method
sharePngBytes
.
- A utility class that contains one static method
Method:
sharePngBytes(Uint8List pngBytes, {String? text})
Step 1: Get a temporary directory using
getTemporaryDirectory()
. This directory is suitable for writing temporary files that don’t need to persist.Step 2: Generate a unique file path inside that temp directory. The filename uses the current timestamp (
DateTime.now().millisecondsSinceEpoch
) so each shared image is unique, avoiding overwrites.Step 3: Create a
File
object at that path and write thepngBytes
into it usingfile.writeAsBytes()
. Settingflush: true
ensures the data is written immediately.Step 4: Use
Share.shareXFiles
from theshare_plus
package to open the native share sheet, passing the newly created file as anXFile
. An optional text message can also be attached. If no text is provided, it defaults to “Sharing a quote from my app.”
Why this is useful:
Flutter apps often generate images dynamically (like screenshots, charts, or quote cards). Since the share sheet requires an actual file (not just raw bytes), this service handles the conversion from memory (Uint8List
) into a temporary file, then shares it seamlessly.
7. lib/screens/quote_screen.dart
<span class="hljs-keyword">import</span> <span class="hljs-string">'dart:typed_data'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'package:flutter/material.dart'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'../widgets/quote_card.dart'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'../utils/capture.dart'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'../services/permission_service.dart'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'../services/gallery_saver_service.dart'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">'../services/share_service.dart'</span>;
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">QuoteScreen</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">StatefulWidget</span> </span>{
<span class="hljs-keyword">const</span> QuoteScreen({<span class="hljs-keyword">super</span>.key});
<span class="hljs-meta">@override</span>
State<QuoteScreen> createState() => _QuoteScreenState();
}
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">_QuoteScreenState</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">State</span><<span class="hljs-title">QuoteScreen</span>> </span>{
<span class="hljs-comment">// This key will be attached to a RepaintBoundary that wraps the quote card.</span>
<span class="hljs-keyword">final</span> GlobalKey _captureKey = GlobalKey();
<span class="hljs-built_in">bool</span> _isSaving = <span class="hljs-keyword">false</span>;
<span class="hljs-built_in">bool</span> _isSharing = <span class="hljs-keyword">false</span>;
Future<Uint8List?> _capture() <span class="hljs-keyword">async</span> {
<span class="hljs-keyword">return</span> captureWidgetToPngBytes(_captureKey, pixelRatio: <span class="hljs-number">3.0</span>);
}
Future<<span class="hljs-keyword">void</span>> _saveImage() <span class="hljs-keyword">async</span> {
setState(() => _isSaving = <span class="hljs-keyword">true</span>);
<span class="hljs-keyword">try</span> {
<span class="hljs-comment">// Request permissions when relevant (see notes in PermissionService).</span>
<span class="hljs-keyword">final</span> granted = <span class="hljs-keyword">await</span> PermissionService.requestSavePermission();
<span class="hljs-keyword">if</span> (!granted) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
<span class="hljs-keyword">const</span> SnackBar(content: Text(<span class="hljs-string">'Permission required to save images.'</span>)),
);
}
<span class="hljs-keyword">return</span>;
}
<span class="hljs-keyword">final</span> bytes = <span class="hljs-keyword">await</span> _capture();
<span class="hljs-keyword">if</span> (bytes == <span class="hljs-keyword">null</span>) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
<span class="hljs-keyword">const</span> SnackBar(content: Text(<span class="hljs-string">'Failed to capture image.'</span>)),
);
}
<span class="hljs-keyword">return</span>;
}
<span class="hljs-keyword">final</span> result = <span class="hljs-keyword">await</span> GallerySaverService.savePngBytesToGallery(
bytes,
name: <span class="hljs-string">'quote_<span class="hljs-subst">${DateTime.now().millisecondsSinceEpoch}</span>'</span>,
);
<span class="hljs-keyword">if</span> (mounted) {
<span class="hljs-keyword">final</span> ok = result != <span class="hljs-keyword">null</span>;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(ok ? <span class="hljs-string">'Image saved to gallery.'</span> : <span class="hljs-string">'Save failed.'</span>)),
);
}
} <span class="hljs-keyword">catch</span> (e) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(<span class="hljs-string">'Error: <span class="hljs-subst">$e</span>'</span>)),
);
}
} <span class="hljs-keyword">finally</span> {
<span class="hljs-keyword">if</span> (mounted) setState(() => _isSaving = <span class="hljs-keyword">false</span>);
}
}
Future<<span class="hljs-keyword">void</span>> _shareImage() <span class="hljs-keyword">async</span> {
setState(() => _isSharing = <span class="hljs-keyword">true</span>);
<span class="hljs-keyword">try</span> {
<span class="hljs-keyword">final</span> bytes = <span class="hljs-keyword">await</span> _capture();
<span class="hljs-keyword">if</span> (bytes == <span class="hljs-keyword">null</span>) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
<span class="hljs-keyword">const</span> SnackBar(content: Text(<span class="hljs-string">'Failed to capture image.'</span>)),
);
}
<span class="hljs-keyword">return</span>;
}
<span class="hljs-keyword">await</span> ShareService.sharePngBytes(bytes, text: <span class="hljs-string">'Here is a quote I wanted to share.'</span>);
} <span class="hljs-keyword">catch</span> (e) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(<span class="hljs-string">'Error: <span class="hljs-subst">$e</span>'</span>)),
);
}
} <span class="hljs-keyword">finally</span> {
<span class="hljs-keyword">if</span> (mounted) setState(() => _isSharing = <span class="hljs-keyword">false</span>);
}
}
<span class="hljs-meta">@override</span>
Widget build(BuildContext context) {
<span class="hljs-keyword">const</span> quote = <span class="hljs-string">"Believe you can and you're halfway there."</span>;
<span class="hljs-keyword">const</span> author = <span class="hljs-string">'Theodore Roosevelt'</span>;
<span class="hljs-keyword">return</span> Scaffold(
appBar: AppBar(
title: <span class="hljs-keyword">const</span> Text(<span class="hljs-string">'Quote Share'</span>),
centerTitle: <span class="hljs-keyword">true</span>,
),
body: Padding(
padding: <span class="hljs-keyword">const</span> EdgeInsets.all(<span class="hljs-number">20.0</span>),
child: Column(
children: [
<span class="hljs-comment">// The RepaintBoundary must directly wrap the content you want to capture.</span>
RepaintBoundary(
key: _captureKey,
child: <span class="hljs-keyword">const</span> QuoteCard(
quote: quote,
author: author,
),
),
<span class="hljs-keyword">const</span> SizedBox(height: <span class="hljs-number">24</span>),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FilledButton.icon(
onPressed: _isSaving ? <span class="hljs-keyword">null</span> : _saveImage,
icon: _isSaving
? <span class="hljs-keyword">const</span> SizedBox(width: <span class="hljs-number">16</span>, height: <span class="hljs-number">16</span>, child: CircularProgressIndicator(strokeWidth: <span class="hljs-number">2</span>))
: <span class="hljs-keyword">const</span> Icon(Icons.download),
label: Text(_isSaving ? <span class="hljs-string">'Saving...'</span> : <span class="hljs-string">'Save'</span>),
),
OutlinedButton.icon(
onPressed: _isSharing ? <span class="hljs-keyword">null</span> : _shareImage,
icon: _isSharing
? <span class="hljs-keyword">const</span> SizedBox(width: <span class="hljs-number">16</span>, height: <span class="hljs-number">16</span>, child: CircularProgressIndicator(strokeWidth: <span class="hljs-number">2</span>))
: <span class="hljs-keyword">const</span> Icon(Icons.share),
label: Text(_isSharing ? <span class="hljs-string">'Sharing...'</span> : <span class="hljs-string">'Share'</span>),
),
],
),
],
),
),
);
}
}
Code explanation highlights:
GlobalKey
_captureKey
identifies theRepaintBoundary
.Buttons call
_saveImage
and_shareImage
.We show progress indicators and disable buttons while busy.
SnackBars provide user feedback for success or errors.
Let’s break down what each part of this code is doing step by step.
State variables
<span class="hljs-built_in">bool</span> _isSaving = <span class="hljs-keyword">false</span>;
<span class="hljs-built_in">bool</span> _isSharing = <span class="hljs-keyword">false</span>;
_isSaving
is used to track whether an image is currently being saved._isSharing
is used to track whether an image is currently being shared.These flags can be used to disable UI buttons, show a loading spinner, or prevent duplicate actions while the save/share process is in progress.
Capture function
Future<Uint8List?> _capture() <span class="hljs-keyword">async</span> {
<span class="hljs-keyword">return</span> captureWidgetToPngBytes(_captureKey, pixelRatio: <span class="hljs-number">3.0</span>);
}
This function captures a Flutter widget (referenced by
_captureKey
) and converts it into PNG image bytes (Uint8List
).pixelRatio: 3.0
ensures the captured image is high resolution (3x the screen density).It returns the raw PNG bytes that can later be saved or shared.
Save function
Future<<span class="hljs-keyword">void</span>> _saveImage() <span class="hljs-keyword">async</span> {
setState(() => _isSaving = <span class="hljs-keyword">true</span>);
<span class="hljs-keyword">try</span> {
<span class="hljs-keyword">final</span> granted = <span class="hljs-keyword">await</span> PermissionService.requestSavePermission();
<span class="hljs-keyword">if</span> (!granted) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
<span class="hljs-keyword">const</span> SnackBar(content: Text(<span class="hljs-string">'Permission required to save images.'</span>)),
);
}
<span class="hljs-keyword">return</span>;
}
Sets
_isSaving
totrue
to indicate saving has started.Requests storage/gallery permissions using
PermissionService
.If permission is not granted, shows a
SnackBar
and stops.
<span class="hljs-keyword">final</span> bytes = <span class="hljs-keyword">await</span> _capture();
<span class="hljs-keyword">if</span> (bytes == <span class="hljs-keyword">null</span>) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
<span class="hljs-keyword">const</span> SnackBar(content: Text(<span class="hljs-string">'Failed to capture image.'</span>)),
);
}
<span class="hljs-keyword">return</span>;
}
Captures the widget as PNG bytes.
If capture fails, shows an error and exits.
<span class="hljs-keyword">final</span> result = <span class="hljs-keyword">await</span> GallerySaverService.savePngBytesToGallery(
bytes,
name: <span class="hljs-string">'quote_<span class="hljs-subst">${DateTime.now().millisecondsSinceEpoch}</span>'</span>,
);
Saves the PNG bytes into the device’s photo gallery.
A unique filename is generated using the current timestamp.
<span class="hljs-keyword">if</span> (mounted) {
<span class="hljs-keyword">final</span> ok = result != <span class="hljs-keyword">null</span>;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(ok ? <span class="hljs-string">'Image saved to gallery.'</span> : <span class="hljs-string">'Save failed.'</span>)),
);
}
} <span class="hljs-keyword">catch</span> (e) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(<span class="hljs-string">'Error: <span class="hljs-subst">$e</span>'</span>)),
);
}
} <span class="hljs-keyword">finally</span> {
<span class="hljs-keyword">if</span> (mounted) setState(() => _isSaving = <span class="hljs-keyword">false</span>);
}
}
If saving succeeds, shows a success message; otherwise, shows failure.
If an exception occurs, shows an error with details.
Finally, resets
_isSaving
back tofalse
.
Share function
Future<<span class="hljs-keyword">void</span>> _shareImage() <span class="hljs-keyword">async</span> {
setState(() => _isSharing = <span class="hljs-keyword">true</span>);
<span class="hljs-keyword">try</span> {
<span class="hljs-keyword">final</span> bytes = <span class="hljs-keyword">await</span> _capture();
<span class="hljs-keyword">if</span> (bytes == <span class="hljs-keyword">null</span>) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
<span class="hljs-keyword">const</span> SnackBar(content: Text(<span class="hljs-string">'Failed to capture image.'</span>)),
);
}
<span class="hljs-keyword">return</span>;
}
<span class="hljs-keyword">await</span> ShareService.sharePngBytes(bytes, text: <span class="hljs-string">'Here is a quote I wanted to share.'</span>);
Sets
_isSharing
totrue
at the start.Captures the widget as PNG bytes.
If successful, calls
ShareService.sharePngBytes
to share the image with some text. This will typically open the system share sheet (WhatsApp, Email, and so on).
} <span class="hljs-keyword">catch</span> (e) {
<span class="hljs-keyword">if</span> (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(<span class="hljs-string">'Error: <span class="hljs-subst">$e</span>'</span>)),
);
}
} <span class="hljs-keyword">finally</span> {
<span class="hljs-keyword">if</span> (mounted) setState(() => _isSharing = <span class="hljs-keyword">false</span>);
}
}
If an error occurs, shows a
SnackBar
.Resets
_isSharing
back tofalse
after completion.
Summary:
_capture()
converts a widget into an image (PNG bytes)._saveImage()
captures the widget, checks permissions, and saves it to the gallery while handling errors and state._shareImage()
captures the widget and shares it using the system share options while handling errors and state._isSaving
and_isSharing
are flags that help manage UI state during operations.
Testing the Flow
To test this setup, you’ll want to run it on a real device for the most accurate behavior.
First, tap Share. The share sheet should appear and allow sending the image via installed apps. Then tap Save. On some devices you may be prompted for permission – accept it. Check your Photos or Gallery app for the saved image.
If the image appears blurry, increase pixelRatio
in captureWidgetToPngBytes
to 3.0 or 4.0. Be mindful of memory.
Troubleshooting and Common Pitfalls
There are a number of common issues you might come across while saving and sharing your Flutter widgets as images. But don’t worry – we’ll address a lot of them quickly and efficiently here.
First, let’s say the saved image is empty or black. To fix this, make sure that the widget is fully painted. We already wait briefly if debugNeedsPaint
is true. Also, make sure the RepaintBoundary
directly wraps the target content and not a parent that has zero size.
What if permission is denied on Android even though you allowed it? Well, some OEMs have aggressive storage policies. Try again, and confirm the app has Photos or Files access in system settings. If your targetSdk is very new, just make sure that your plugins are updated.
If the image isn’t visible in the Gallery, just give it a moment – some galleries index asynchronously. YOu can also try another gallery app to confirm the file exists.
If sharing fails on iOS simulator, some share targets are unavailable in the simulator. Just try testing on a real device.
Lastly, if you have blurry or jagged text, you can increase pixelRatio
in toImage
and add padding around the card so shadows and edges are captured cleanly.
Enhancements and Alternatives
Use a programmatic watermark or logo
Instead of capturing a plain QuoteCard
, you can overlay a small brand logo or watermark widget before taking the screenshot. This helps with branding (users know where the quote came from) and discourages unauthorized reuse. A simple way is to wrap the card in a Stack
and place a Positioned
logo in a corner.
Use dynamic backgrounds
Rather than using a flat color, you could make the captured quote more visually engaging by adding gradient fills or even image backgrounds. For example, a gradient background can subtly elevate the design, while thematic images can match the tone of the quote (e.g., nature shots for inspirational quotes). Flutter’s BoxDecoration
with gradients or an Image.asset
/Image.network
background makes this straightforward.
Have multiple capture targets
If your app needs to capture more than just the quote card (for example, profile cards, stats, or receipts), you don’t want a single GlobalKey
. A map of keys like Map<String, GlobalKey>
lets you reference and capture the right widget dynamically. This adds flexibility and keeps your capture logic reusable across multiple UI components.
Alternative packages
There are some other packages you can consider using, like:
screenshot
: Provides a higher-level API that can simplify screen capturing without manually jugglingRepaintBoundary
and keys. Particularly useful for capturing the entire screen or full widgets with less boilerplate.widgets_to_image
: Another option that focuses on turning specific widgets into images with a slightly different API style. Could be more ergonomic depending on your use case.PDF generation (
printing
/pdf
): If your use case involves creating shareable documents rather than images (e.g., a formatted quote booklet), these packages are a better fit since they work with vector-based content and are resolution-independent.
Caching and performance
Capturing widgets frequently can create memory churn and slow down the app if every capture is re-rendered from scratch. Adding caching strategies (for example, keeping the last rendered image in memory) can reduce overhead. If you write captures to disk (outside of the OS’s temporary directories), make sure you clean them up after sharing to avoid filling up user storage. Throttling rapid captures (for example, debounce a “Save Quote” button) is also a good practice to keep the UI responsive.
Conclusion
You now have a complete, production-ready approach for capturing a Flutter widget to an image, saving it to the gallery, and sharing it through the native share sheet. The key pieces are RepaintBoundary
for pixel-perfect capture, careful handling of platform permissions, and small services that keep UI code clean. This pattern generalizes well to certificates, reports, memes, flashcards, and any other visual content your app creates.
If you prefer a more batteries-included path, the screenshot
package can achieve a similar result with slightly different tradeoffs.
References
Source: freeCodeCamp Programming Tutorials: Python, JavaScript, Git & MoreÂ