image_picker2_plus 1.0.2
image_picker2_plus: ^1.0.2 copied to clipboard
A modern, zero-permission Flutter plugin to pick images and videos with OS-level selection limits, native compression, and a rich WhatsApp-style example.
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:photo_manager/photo_manager.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(),
home: const PickerHomePage(),
);
}
}
class PickerHomePage extends StatefulWidget {
const PickerHomePage({super.key});
@override
State<PickerHomePage> createState() => _PickerHomePageState();
}
class _PickerHomePageState extends State<PickerHomePage> {
List<File> _sentFiles = <File>[];
List<String> _sentCaptions = <String>[];
Future<void> _openWhatsAppStylePicker() async {
final result = await Navigator.of(context).push<PickerResult>(
MaterialPageRoute(builder: (_) => const WhatsAppStylePickerPage()),
);
if (!mounted || result == null) {
return;
}
setState(() {
_sentFiles = result.files;
_sentCaptions = result.captions;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('WhatsApp Style Picker Demo'),
backgroundColor: const Color(0xFF101D25),
),
backgroundColor: const Color(0xFF111B21),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: <Widget>[
ElevatedButton.icon(
onPressed: _openWhatsAppStylePicker,
icon: const Icon(Icons.photo_library),
label: const Text('Open Media Picker'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF00A884),
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 52),
),
),
const SizedBox(height: 12),
if (_sentCaptions.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: Text(
'Last captions: ${_sentCaptions.where((text) => text.trim().isNotEmpty).join(' | ')}',
style: const TextStyle(color: Colors.white70),
),
),
const SizedBox(height: 12),
Expanded(
child: _sentFiles.isEmpty
? const Center(
child: Text(
'Pick media and tap send',
style: TextStyle(color: Colors.white54),
),
)
: GridView.builder(
itemCount: _sentFiles.length,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
),
itemBuilder: (context, index) {
return ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.file(
_sentFiles[index],
fit: BoxFit.cover,
),
);
},
),
),
],
),
),
);
}
}
class PickerResult {
const PickerResult({required this.files, required this.captions});
final List<File> files;
final List<String> captions;
}
class WhatsAppStylePickerPage extends StatefulWidget {
const WhatsAppStylePickerPage({super.key});
@override
State<WhatsAppStylePickerPage> createState() => _WhatsAppStylePickerPageState();
}
class _WhatsAppStylePickerPageState extends State<WhatsAppStylePickerPage> {
static const int _maxSelection = 30;
final TextEditingController _captionController = TextEditingController();
final List<AssetEntity> _selectedAssets = <AssetEntity>[];
final Map<String, String> _captionsByAssetId = <String, String>{};
final Map<String, Future<Uint8List?>> _thumbnailFutures =
<String, Future<Uint8List?>>{};
List<AssetPathEntity> _albums = <AssetPathEntity>[];
List<AssetEntity> _assets = <AssetEntity>[];
AssetPathEntity? _currentAlbum;
int _tabIndex = 0;
bool _loading = true;
String _error = '';
@override
void initState() {
super.initState();
_loadGallery();
}
@override
void dispose() {
_captionController.dispose();
super.dispose();
}
Future<void> _loadGallery() async {
setState(() {
_loading = true;
_error = '';
});
final PermissionState permission = await PhotoManager.requestPermissionExtend();
if (!permission.isAuth) {
if (!mounted) {
return;
}
setState(() {
_loading = false;
_error = 'Photo permission denied. Enable it from app settings.';
});
return;
}
final List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true,
type: RequestType.common,
filterOption: FilterOptionGroup(
imageOption: const FilterOption(sizeConstraint: SizeConstraint(ignoreSize: true)),
videoOption: const FilterOption(durationConstraint: DurationConstraint(max: Duration(seconds: 3600))),
),
);
if (!mounted) {
return;
}
if (albums.isEmpty) {
setState(() {
_loading = false;
_albums = albums;
});
return;
}
final AssetPathEntity currentAlbum = albums.first;
final List<AssetEntity> assets = await currentAlbum.getAssetListPaged(page: 0, size: 150);
if (!mounted) {
return;
}
setState(() {
_loading = false;
_albums = albums;
_currentAlbum = currentAlbum;
_assets = assets;
});
}
Future<void> _openAlbum(AssetPathEntity album) async {
setState(() {
_currentAlbum = album;
_loading = true;
_tabIndex = 0;
});
final List<AssetEntity> assets = await album.getAssetListPaged(page: 0, size: 150);
if (!mounted) {
return;
}
setState(() {
_loading = false;
_assets = assets;
});
}
void _toggleSelection(AssetEntity entity) {
setState(() {
final int index = _selectedAssets.indexOf(entity);
if (index >= 0) {
_selectedAssets.removeAt(index);
_captionsByAssetId.remove(entity.id);
} else if (_selectedAssets.length < _maxSelection) {
_selectedAssets.add(entity);
}
});
_syncBottomCaptionField();
}
Future<Uint8List?> _thumbnailFuture(AssetEntity entity, int size) {
return _thumbnailFutures.putIfAbsent(
'${entity.id}_$size',
() => entity.thumbnailDataWithSize(ThumbnailSize.square(size)),
);
}
Future<void> _openPreviewEditor() async {
if (_selectedAssets.isEmpty) {
return;
}
final PreviewEditorResult? result =
await Navigator.of(context).push<PreviewEditorResult>(
MaterialPageRoute(
builder: (_) => PreviewEditorPage(
selectedAssets: List<AssetEntity>.from(_selectedAssets),
initialCaptions: Map<String, String>.from(_captionsByAssetId),
initialIndex: _selectedAssets.length - 1,
thumbnailProvider: _thumbnailFuture,
),
),
);
if (!mounted || result == null) {
return;
}
setState(() {
_selectedAssets
..clear()
..addAll(result.selectedAssets);
_captionsByAssetId
..clear()
..addAll(result.captionsByAssetId);
});
_syncBottomCaptionField();
if (result.shouldSend && _selectedAssets.isNotEmpty) {
await _sendSelection();
}
}
Future<void> _sendSelection() async {
final List<File> files = <File>[];
final List<String> captions = <String>[];
for (final AssetEntity asset in _selectedAssets) {
final File? file = await asset.file;
if (file != null) {
files.add(file);
captions.add(_captionsByAssetId[asset.id]?.trim() ?? '');
}
}
if (!mounted) {
return;
}
Navigator.of(context).pop(
PickerResult(files: files, captions: captions),
);
}
AssetEntity? get _activeCaptionAsset =>
_selectedAssets.isEmpty ? null : _selectedAssets.last;
void _syncBottomCaptionField() {
final AssetEntity? asset = _activeCaptionAsset;
final String nextValue =
asset == null ? '' : (_captionsByAssetId[asset.id] ?? '');
if (_captionController.text != nextValue) {
_captionController.value = TextEditingValue(
text: nextValue,
selection: TextSelection.collapsed(offset: nextValue.length),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: SafeArea(
child: Column(
children: <Widget>[
Container(
color: const Color(0xFF182229),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'Cancel',
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
const Spacer(),
_TopTabButton(
label: 'Photos',
selected: _tabIndex == 0,
onTap: () => setState(() => _tabIndex = 0),
),
const SizedBox(width: 8),
_TopTabButton(
label: 'Albums',
selected: _tabIndex == 1,
onTap: () => setState(() => _tabIndex = 1),
),
const Spacer(),
const CircleAvatar(
radius: 14,
backgroundColor: Color(0xFF2A3942),
child: Text('HD', style: TextStyle(color: Colors.white, fontSize: 10)),
),
],
),
),
Expanded(child: _buildContent()),
_buildBottomBar(),
],
),
),
);
}
Widget _buildContent() {
if (_loading) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF00A884)));
}
if (_error.isNotEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(20),
child: Text(_error, style: const TextStyle(color: Colors.white70), textAlign: TextAlign.center),
),
);
}
if (_tabIndex == 1) {
return ListView.separated(
itemCount: _albums.length,
separatorBuilder: (_, _) => const Divider(height: 1, color: Color(0xFF1F2C33)),
itemBuilder: (context, index) {
final AssetPathEntity album = _albums[index];
final bool selected = _currentAlbum?.id == album.id;
return ListTile(
onTap: () => _openAlbum(album),
tileColor: selected ? const Color(0xFF1A2A31) : Colors.transparent,
title: Text(album.name, style: const TextStyle(color: Colors.white)),
subtitle: FutureBuilder<int>(
future: album.assetCountAsync,
builder: (context, snapshot) {
return Text(
'${snapshot.data ?? 0} items',
style: const TextStyle(color: Colors.white54),
);
},
),
trailing: const Icon(Icons.chevron_right, color: Colors.white54),
);
},
);
}
return GridView.builder(
itemCount: _assets.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemBuilder: (context, index) {
final AssetEntity entity = _assets[index];
final int selectedIndex = _selectedAssets.indexOf(entity);
final bool isSelected = selectedIndex >= 0;
return GestureDetector(
key: ValueKey<String>(entity.id),
onTap: () => _toggleSelection(entity),
child: Stack(
fit: StackFit.expand,
children: <Widget>[
_AssetThumbnail(
future: _thumbnailFuture(entity, 300),
),
if (entity.type == AssetType.video)
const Positioned(
left: 6,
bottom: 6,
child: Icon(Icons.videocam, color: Colors.white, size: 18),
),
Positioned(
right: 6,
top: 6,
child: Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF00A884) : Colors.black45,
shape: BoxShape.circle,
border: Border.all(color: Colors.white70, width: 1),
),
child: Text(
isSelected ? '${selectedIndex + 1}' : '',
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
),
),
),
],
),
);
},
);
}
Widget _buildBottomBar() {
final bool canSend = _selectedAssets.isNotEmpty;
return Container(
color: const Color(0xFF1F2C33),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: Row(
children: <Widget>[
GestureDetector(
onTap: canSend ? _openPreviewEditor : null,
child: CircleAvatar(
radius: 20,
backgroundColor: canSend
? const Color(0xFF2A3942)
: const Color(0xFF25343C),
child: Icon(
Icons.edit,
color: canSend ? Colors.white : Colors.white38,
size: 20,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF2A3942),
borderRadius: BorderRadius.circular(21),
),
child: TextField(
controller: _captionController,
onChanged: (value) {
final AssetEntity? asset = _activeCaptionAsset;
if (asset == null) {
return;
}
_captionsByAssetId[asset.id] = value;
},
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: _activeCaptionAsset == null
? 'Select media to add captions'
: 'Add a caption for selected item...',
hintStyle: TextStyle(color: Colors.white54),
border: InputBorder.none,
),
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: canSend ? _sendSelection : null,
child: CircleAvatar(
radius: 22,
backgroundColor: canSend ? const Color(0xFF00A884) : const Color(0xFF3A4A53),
child: Stack(
clipBehavior: Clip.none,
children: <Widget>[
const Center(
child: Text(
'➤',
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
if (canSend)
Positioned(
right: -2,
top: -3,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${_selectedAssets.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
],
),
);
}
}
class _TopTabButton extends StatelessWidget {
const _TopTabButton({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: selected ? Colors.white : const Color(0xFF2A3942),
borderRadius: BorderRadius.circular(16),
),
child: Text(
label,
style: TextStyle(
color: selected ? Colors.black : Colors.white,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class PreviewEditorResult {
const PreviewEditorResult({
required this.selectedAssets,
required this.captionsByAssetId,
required this.shouldSend,
});
final List<AssetEntity> selectedAssets;
final Map<String, String> captionsByAssetId;
final bool shouldSend;
}
class PreviewEditorPage extends StatefulWidget {
const PreviewEditorPage({
super.key,
required this.selectedAssets,
required this.initialCaptions,
required this.initialIndex,
required this.thumbnailProvider,
});
final List<AssetEntity> selectedAssets;
final Map<String, String> initialCaptions;
final int initialIndex;
final Future<Uint8List?> Function(AssetEntity entity, int size)
thumbnailProvider;
@override
State<PreviewEditorPage> createState() => _PreviewEditorPageState();
}
class _PreviewEditorPageState extends State<PreviewEditorPage> {
late final TextEditingController _captionController;
late final PageController _pageController;
late List<AssetEntity> _selectedAssets;
late Map<String, String> _captionsByAssetId;
late int _currentIndex;
@override
void initState() {
super.initState();
_selectedAssets = List<AssetEntity>.from(widget.selectedAssets);
_captionsByAssetId = Map<String, String>.from(widget.initialCaptions);
_currentIndex = widget.initialIndex.clamp(0, _selectedAssets.length - 1);
_captionController = TextEditingController(
text: _captionsByAssetId[_selectedAssets[_currentIndex].id] ?? '',
);
_pageController = PageController(initialPage: _currentIndex);
}
@override
void dispose() {
_captionController.dispose();
_pageController.dispose();
super.dispose();
}
Future<void> _close({required bool shouldSend}) async {
_saveCurrentCaption();
Navigator.of(context).pop(
PreviewEditorResult(
selectedAssets: _selectedAssets,
captionsByAssetId: _captionsByAssetId,
shouldSend: shouldSend,
),
);
}
void _removeCurrent() {
if (_selectedAssets.isEmpty) {
return;
}
_saveCurrentCaption();
final String removedId = _selectedAssets[_currentIndex].id;
setState(() {
_selectedAssets.removeAt(_currentIndex);
_captionsByAssetId.remove(removedId);
if (_selectedAssets.isEmpty) {
Navigator.of(context).pop(
const PreviewEditorResult(
selectedAssets: <AssetEntity>[],
captionsByAssetId: <String, String>{},
shouldSend: false,
),
);
return;
}
if (_currentIndex >= _selectedAssets.length) {
_currentIndex = _selectedAssets.length - 1;
}
_loadCurrentCaption();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _selectedAssets.isNotEmpty) {
_pageController.jumpToPage(_currentIndex);
}
});
}
@override
Widget build(BuildContext context) {
final AssetEntity currentAsset = _selectedAssets[_currentIndex];
return Scaffold(
backgroundColor: const Color(0xFF0B0F17),
body: SafeArea(
child: Column(
children: <Widget>[
Container(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 6),
child: Row(
children: <Widget>[
IconButton(
onPressed: () => _close(shouldSend: false),
icon: const Icon(Icons.arrow_back, color: Colors.white70),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'Media Preview',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w700,
),
),
Text(
currentAsset.title ?? 'media.jpg',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white60,
fontSize: 12,
),
),
],
),
),
IconButton(
onPressed: _removeCurrent,
icon: const Icon(
Icons.delete_outline,
color: Color(0xFFE57373),
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF0F1522),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFF2A3942)),
),
child: Text(
'${_currentIndex + 1}/${_selectedAssets.length}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
),
Expanded(
child: PageView.builder(
controller: _pageController,
itemCount: _selectedAssets.length,
onPageChanged: (value) {
_saveCurrentCaption();
setState(() {
_currentIndex = value;
_loadCurrentCaption();
});
},
itemBuilder: (context, index) {
final asset = _selectedAssets[index];
final bool isVideo = asset.type == AssetType.video;
return FutureBuilder<File?>(
future: asset.file,
builder: (context, snapshot) {
final file = snapshot.data;
if (file == null) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFF00A884),
),
);
}
if (isVideo) {
return Center(
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(24),
),
child: const AspectRatio(
aspectRatio: 9 / 16,
child: Center(
child: Icon(
Icons.play_circle_fill,
color: Colors.white,
size: 72,
),
),
),
),
);
}
return InteractiveViewer(
minScale: 0.8,
maxScale: 4,
child: Center(
child: Image.file(file, fit: BoxFit.contain),
),
);
},
);
},
),
),
Container(
height: 86,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
alignment: Alignment.centerLeft,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _selectedAssets.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final asset = _selectedAssets[index];
final isActive = index == _currentIndex;
return GestureDetector(
onTap: () {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
},
child: Container(
width: 62,
height: 62,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isActive
? const Color(0xFF4C8DFF)
: Colors.transparent,
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.expand,
children: <Widget>[
_AssetThumbnail(
future: widget.thumbnailProvider(asset, 200),
),
Positioned(
right: 4,
bottom: 4,
child: Container(
width: 18,
height: 18,
decoration: const BoxDecoration(
color: Color(0xFF4C8DFF),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Caption for item ${_currentIndex + 1} of ${_selectedAssets.length}',
style: const TextStyle(
color: Colors.white60,
fontSize: 12,
),
),
),
),
Container(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: <Widget>[
Expanded(
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFF0F1522),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFF2A3942)),
),
child: TextField(
controller: _captionController,
onChanged: (value) {
_captionsByAssetId[currentAsset.id] = value;
},
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
hintText: 'Add a caption...',
border: InputBorder.none,
hintStyle: TextStyle(color: Colors.white54),
),
),
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: () => _close(shouldSend: true),
child: Stack(
clipBehavior: Clip.none,
children: <Widget>[
Container(
height: 48,
padding: const EdgeInsets.symmetric(
horizontal: 18,
),
decoration: BoxDecoration(
color: const Color(0xFF00A884),
borderRadius: BorderRadius.circular(18),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const <Widget>[
Icon(
Icons.send_rounded,
size: 20,
color: Colors.white,
),
SizedBox(width: 10),
Text(
'Send',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
],
),
),
Positioned(
right: 3,
top: -6,
child: Container(
width: 20,
height: 20,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Color(0xFF063B2F),
shape: BoxShape.circle,
),
child: Text(
'${_selectedAssets.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
],
),
),
],
),
),
);
}
void _saveCurrentCaption() {
if (_selectedAssets.isEmpty) {
return;
}
_captionsByAssetId[_selectedAssets[_currentIndex].id] =
_captionController.text;
}
void _loadCurrentCaption() {
if (_selectedAssets.isEmpty) {
_captionController.clear();
return;
}
final String value =
_captionsByAssetId[_selectedAssets[_currentIndex].id] ?? '';
_captionController.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(offset: value.length),
);
}
}
class _AssetThumbnail extends StatelessWidget {
const _AssetThumbnail({required this.future});
final Future<Uint8List?> future;
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: future,
builder: (context, snapshot) {
if (snapshot.data == null) {
return const ColoredBox(color: Color(0xFF1F2C33));
}
return Image.memory(snapshot.data!, fit: BoxFit.cover);
},
);
}
}