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
cd quote_share_app
Dependencies
Add the following to pubspec.yaml under dependencies: and run flutter pub get:
dependencies:
flutter:
sdk: flutter
permission_handler: ^11.3.1
image_gallery_saver: ^2.0.3
path_provider: ^2.1.3
share_plus: ^9.0.0
Notes about this code:
permission_handlerhandles runtime permissions where required.image_gallery_saverwrites raw bytes to the photo gallery (Android and iOS).path_providercreates a temporary file location before sharing.share_plusinvokes 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.
<!-- Optional for older devices pre-API 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<!-- For Android 13+ if you ever need to read user images; not required just to write your own image -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
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:
<key>NSPhotoLibraryAddUsageDescription</key>
<string>The app needs access to save your generated images.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>The app needs access to your photo library.</string>
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:
QuoteCardrenders the visual widget we want to capture.captureWidgetToPngBytes(GlobalKey)converts that widget into PNG bytes usingRepaintBoundary.PermissionServicerequests storage or photo library permissions when needed.GallerySaverServicesaves bytes to the gallery.ShareServicewrites bytes to a temporary file and triggers the share sheet.QuoteScreenwires everything together with two buttons: Save and Share.
Code Sections With Explanations
1. lib/main.dart
import 'package:flutter/material.dart';
import 'screens/quote_screen.dart';
void main() {
runApp(const QuoteShareApp());
}
class QuoteShareApp extends StatelessWidget {
const QuoteShareApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Quote Share App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.teal,
useMaterial3: true,
),
home: const QuoteScreen(),
);
}
}
Code explanation:
runAppbootstraps the app.MaterialAppprovides theming and navigation.QuoteScreenis our only screen; it displays the card and buttons.
2. lib/widgets/quote_card.dart
import 'package:flutter/material.dart';
class QuoteCard extends StatelessWidget {
final String quote;
final String author;
const QuoteCard({
super.key,
required this.quote,
required this.author,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.teal.shade50,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.teal.shade200.withOpacity(0.4),
blurRadius: 12,
offset: const Offset(2, 6),
),
],
border: Border.all(color: Colors.teal.shade200, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'"$quote"',
style: const TextStyle(
fontSize: 22,
fontStyle: FontStyle.italic,
color: Colors.black87,
height: 1.4,
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.bottomRight,
child: Text(
'- $author',
style: const TextStyle(
fontSize: 16,
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
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
/// Captures the widget referenced by [boundaryKey] into PNG bytes.
/// Place a RepaintBoundary keyed with [boundaryKey] around the widget you want to capture.
Future<Uint8List?> captureWidgetToPngBytes(GlobalKey boundaryKey, {double pixelRatio = 3.0}) async {
try {
final context = boundaryKey.currentContext;
if (context == null) return null;
final renderObject = context.findRenderObject();
if (renderObject is! RenderRepaintBoundary) return null;
// If the boundary hasn't painted yet, wait a frame and try again.
if (renderObject.debugNeedsPaint) {
await Future.delayed(const Duration(milliseconds: 20));
return captureWidgetToPngBytes(boundaryKey, pixelRatio: pixelRatio);
}
// Render to an Image with a higher pixelRatio for sharpness on high-dpi screens.
final ui.Image image = await renderObject.toImage(pixelRatio: pixelRatio);
// Encode the Image to PNG and return bytes.
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
return byteData?.buffer.asUint8List();
} catch (e) {
debugPrint('captureWidgetToPngBytes error: $e');
return null;
}
}
Code explanation line by line:
We accept a
GlobalKeythat must be attached to aRepaintBoundarywrapping the target widget.findRenderObject()retrieves the render tree node.RenderRepaintBoundarycan snapshot itself to an image.debugNeedsPaintindicates 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.Imageto PNG viatoByteDataand return its bytes.
4. lib/services/permission_service.dart
import 'dart:io';
import 'package:permission_handler/permission_handler.dart';
class PermissionService {
/// Requests any storage/photo permissions needed for saving an image.
/// On modern Android and iOS, saving to the Photos collection may not require
/// the legacy WRITE permission, but some devices and OS versions still prompt.
static Future<bool> requestSavePermission() async {
if (Platform.isAndroid) {
// For Android 13+ you typically do not need WRITE permission to save your own image.
// Some OEMs still require storage permission for certain gallery operations.
final status = await Permission.storage.request();
if (status.isGranted) return true;
if (status.isPermanentlyDenied) {
await openAppSettings();
}
return false;
}
if (Platform.isIOS) {
// On iOS, request Photos permission for adding to library when needed.
final status = await Permission.photosAddOnly.request();
// Fallback if photosAddOnly is unavailable on older plugin versions:
if (status.isGranted) return true;
// Some iOS versions may use `Permission.photos`.
final photos = await Permission.photos.request();
if (photos.isGranted) return true;
return false;
}
// Other platforms
return true;
}
}
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
import 'dart:typed_data';
import 'package:image_gallery_saver/image_gallery_saver.dart';
class GallerySaverService {
/// Saves [pngBytes] to the gallery and returns a descriptive result map from the plugin.
static Future<Map?> savePngBytesToGallery(Uint8List pngBytes, {String? name}) async {
final result = await ImageGallerySaver.saveImage(
pngBytes,
name: name, // Optional file base name (plugin may append extension/time)
quality: 100,
);
// Plugin returns a platform-dependent structure. We bubble it up unchanged.
return result as Map?;
}
}
Code explanation:
image_gallery_saverwrites the provided bytes to the photo library.We pass
quality: 100for 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:
import 'dart:typed_data';
import 'package:image_gallery_saver/image_gallery_saver.dart';
dart:typed_datais imported because the image is represented asUint8List(a list of unsigned 8-bit integers, basically raw binary data).image_gallery_saveris a Flutter plugin that lets you save images and videos to the device's gallery.
class GallerySaverService {
/// Saves [pngBytes] to the gallery and returns a descriptive result map from the plugin.
static Future<Map?> savePngBytesToGallery(Uint8List pngBytes, {String? name}) async {
The class is called
GallerySaverService.It has a static method
savePngBytesToGallerythat takes:pngBytes: the raw PNG image data you want to save.name: an optional file name to use for the saved image.
final result = await ImageGallerySaver.saveImage(
pngBytes,
name: name, // Optional file base name (plugin may append extension/time)
quality: 100,
);
ImageGallerySaver.saveImageis called to save the image to the gallery.pngBytesis passed in directly.nameis optional. The plugin may add an extension like.pngand/or a timestamp to ensure uniqueness.quality: 100ensures the best quality is saved (this parameter mostly applies to JPG, but still ensures maximum fidelity).
// Plugin returns a platform-dependent structure. We bubble it up unchanged.
return result as Map?;
}
}
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
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class ShareService {
/// Writes [pngBytes] to a temporary file and invokes the platform share sheet.
static Future<void> sharePngBytes(Uint8List pngBytes, {String? text}) async {
final tempDir = await getTemporaryDirectory();
final filePath = '${tempDir.path}/quote_${DateTime.now().millisecondsSinceEpoch}.png';
final file = File(filePath);
await file.writeAsBytes(pngBytes, flush: true);
await Share.shareXFiles(
[XFile(file.path)],
text: text ?? 'Sharing a quote from my app.',
);
}
}
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 theFileclass 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
Fileobject at that path and write thepngBytesinto it usingfile.writeAsBytes(). Settingflush: trueensures the data is written immediately.Step 4: Use
Share.shareXFilesfrom theshare_pluspackage 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
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../widgets/quote_card.dart';
import '../utils/capture.dart';
import '../services/permission_service.dart';
import '../services/gallery_saver_service.dart';
import '../services/share_service.dart';
class QuoteScreen extends StatefulWidget {
const QuoteScreen({super.key});
@override
State<QuoteScreen> createState() => _QuoteScreenState();
}
class _QuoteScreenState extends State<QuoteScreen> {
// This key will be attached to a RepaintBoundary that wraps the quote card.
final GlobalKey _captureKey = GlobalKey();
bool _isSaving = false;
bool _isSharing = false;
Future<Uint8List?> _capture() async {
return captureWidgetToPngBytes(_captureKey, pixelRatio: 3.0);
}
Future<void> _saveImage() async {
setState(() => _isSaving = true);
try {
// Request permissions when relevant (see notes in PermissionService).
final granted = await PermissionService.requestSavePermission();
if (!granted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Permission required to save images.')),
);
}
return;
}
final bytes = await _capture();
if (bytes == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to capture image.')),
);
}
return;
}
final result = await GallerySaverService.savePngBytesToGallery(
bytes,
name: 'quote_${DateTime.now().millisecondsSinceEpoch}',
);
if (mounted) {
final ok = result != null;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(ok ? 'Image saved to gallery.' : 'Save failed.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
Future<void> _shareImage() async {
setState(() => _isSharing = true);
try {
final bytes = await _capture();
if (bytes == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to capture image.')),
);
}
return;
}
await ShareService.sharePngBytes(bytes, text: 'Here is a quote I wanted to share.');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) setState(() => _isSharing = false);
}
}
@override
Widget build(BuildContext context) {
const quote = "Believe you can and you're halfway there.";
const author = 'Theodore Roosevelt';
return Scaffold(
appBar: AppBar(
title: const Text('Quote Share'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
// The RepaintBoundary must directly wrap the content you want to capture.
RepaintBoundary(
key: _captureKey,
child: const QuoteCard(
quote: quote,
author: author,
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FilledButton.icon(
onPressed: _isSaving ? null : _saveImage,
icon: _isSaving
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.download),
label: Text(_isSaving ? 'Saving...' : 'Save'),
),
OutlinedButton.icon(
onPressed: _isSharing ? null : _shareImage,
icon: _isSharing
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.share),
label: Text(_isSharing ? 'Sharing...' : 'Share'),
),
],
),
],
),
),
);
}
}
Code explanation highlights:
GlobalKey_captureKeyidentifies theRepaintBoundary.Buttons call
_saveImageand_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
bool _isSaving = false;
bool _isSharing = false;
_isSavingis used to track whether an image is currently being saved._isSharingis 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() async {
return captureWidgetToPngBytes(_captureKey, pixelRatio: 3.0);
}
This function captures a Flutter widget (referenced by
_captureKey) and converts it into PNG image bytes (Uint8List).pixelRatio: 3.0ensures 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<void> _saveImage() async {
setState(() => _isSaving = true);
try {
final granted = await PermissionService.requestSavePermission();
if (!granted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Permission required to save images.')),
);
}
return;
}
Sets
_isSavingtotrueto indicate saving has started.Requests storage/gallery permissions using
PermissionService.If permission is not granted, shows a
SnackBarand stops.
final bytes = await _capture();
if (bytes == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to capture image.')),
);
}
return;
}
Captures the widget as PNG bytes.
If capture fails, shows an error and exits.
final result = await GallerySaverService.savePngBytesToGallery(
bytes,
name: 'quote_${DateTime.now().millisecondsSinceEpoch}',
);
Saves the PNG bytes into the device’s photo gallery.
A unique filename is generated using the current timestamp.
if (mounted) {
final ok = result != null;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(ok ? 'Image saved to gallery.' : 'Save failed.')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
If saving succeeds, shows a success message; otherwise, shows failure.
If an exception occurs, shows an error with details.
Finally, resets
_isSavingback tofalse.
Share function
Future<void> _shareImage() async {
setState(() => _isSharing = true);
try {
final bytes = await _capture();
if (bytes == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to capture image.')),
);
}
return;
}
await ShareService.sharePngBytes(bytes, text: 'Here is a quote I wanted to share.');
Sets
_isSharingtotrueat the start.Captures the widget as PNG bytes.
If successful, calls
ShareService.sharePngBytesto share the image with some text. This will typically open the system share sheet (WhatsApp, Email, and so on).
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) setState(() => _isSharing = false);
}
}
If an error occurs, shows a
SnackBar.Resets
_isSharingback tofalseafter 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._isSavingand_isSharingare 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 jugglingRepaintBoundaryand 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.