hq_picker 1.1.1
hq_picker: ^1.1.1 copied to clipboard
A stateful widget that allows users to pick media files (images, videos, audio, files) from their device.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hq_picker/hq_picker.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'HQPicker Example',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(useMaterial3: true).copyWith(
scaffoldBackgroundColor: Colors.black,
colorScheme: const ColorScheme.dark(
primary: Colors.white,
onPrimary: Colors.black,
surface: Colors.black,
onSurface: Colors.white,
secondaryContainer: Color(0xFF262626),
onSecondaryContainer: Colors.white,
),
),
home: const PickerHomePage(),
);
}
}
// ─────────────────────────────────────────────
// Shape Option Model
// ─────────────────────────────────────────────
class _ShapeOption {
final String name;
final String description;
final IconData icon;
final HQPickerShape shape;
final bool isDirectory;
const _ShapeOption({
required this.name,
required this.description,
required this.icon,
required this.shape,
this.isDirectory = false,
});
}
const List<_ShapeOption> _shapes = [
_ShapeOption(
name: 'Instagram Style',
description: 'Full-screen preview grid layout',
icon: Icons.camera_alt_outlined,
shape: HQPickerShape.instagram,
),
_ShapeOption(
name: 'Telegram Sheet',
description: 'Sliding Telegram-style media sheet',
icon: Icons.send_rounded,
shape: HQPickerShape.telegram,
),
_ShapeOption(
name: 'Document Picker',
description: 'Native system document picker',
icon: Icons.description_outlined,
shape: HQPickerShape.document,
),
_ShapeOption(
name: 'Directory Picker',
description: 'Native system folder picker',
icon: Icons.folder_open_outlined,
shape: HQPickerShape.directory,
isDirectory: true,
),
];
// ─────────────────────────────────────────────
// Main Home Page
// ─────────────────────────────────────────────
class PickerHomePage extends StatefulWidget {
const PickerHomePage({super.key});
@override
State<PickerHomePage> createState() => _PickerHomePageState();
}
class _PickerHomePageState extends State<PickerHomePage>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
List<HQPickerResult> _results = [];
// ── Customization Settings State ──────────────────────────────────────────
int _gridCrossAxisCount = 3;
double _gridItemBorderRadius = 12.0;
HQPickerSelectionStyle _selectionStyle = HQPickerSelectionStyle.checkMark;
HQPickerBadgePosition _badgePosition = HQPickerBadgePosition.topRight;
bool _enableFullScreenPreview = true;
int _maxVideoDurationSeconds = 60;
HQPickerFileViewMode _fileViewMode = HQPickerFileViewMode.list;
HQPickerGestureAction _doubleTapAction = HQPickerGestureAction.none;
HQPickerGestureAction _longPressAction = HQPickerGestureAction.preview;
bool _useCustomPreviewDialog = false;
bool _useCustomConfirmButton = false;
bool _useCustomAppBar = false;
bool _useArabicLocalizations = false;
bool _useCustomLoadingWidget = false;
BoxFit _previewFit = BoxFit.cover;
BoxFit _gridItemFit = BoxFit.cover;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
void _openCustomizationBottomSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF1E1E2E),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Customization Settings',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white70),
onPressed: () => Navigator.pop(ctx),
),
],
),
const Divider(color: Colors.white24),
const SizedBox(height: 10),
// Grid Columns
Text(
'Grid Columns: $_gridCrossAxisCount',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
Slider(
value: _gridCrossAxisCount.toDouble(),
min: 2,
max: 5,
divisions: 3,
activeColor: Colors.deepPurpleAccent,
label: '$_gridCrossAxisCount',
onChanged: (v) {
setModalState(() => _gridCrossAxisCount = v.toInt());
setState(() {});
},
),
// Border Radius
Text(
'Border Radius: ${_gridItemBorderRadius.toInt()}px',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
Slider(
value: _gridItemBorderRadius,
min: 0,
max: 24,
divisions: 12,
activeColor: Colors.deepPurpleAccent,
label: '${_gridItemBorderRadius.toInt()}px',
onChanged: (v) {
setModalState(() => _gridItemBorderRadius = v);
setState(() {});
},
),
// Selection Style
const Text(
'Selection Badge Style',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: HQPickerSelectionStyle.values.map((style) {
final isSel = _selectionStyle == style;
return ChoiceChip(
label: Text(style.name),
selected: isSel,
selectedColor: Colors.deepPurple,
onSelected: (sel) {
if (sel) {
setModalState(() => _selectionStyle = style);
setState(() {});
}
},
);
}).toList(),
),
const SizedBox(height: 12),
// Badge Position
const Text(
'Badge Position',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: HQPickerBadgePosition.values.map((pos) {
final isSel = _badgePosition == pos;
return ChoiceChip(
label: Text(pos.name),
selected: isSel,
selectedColor: Colors.deepPurple,
onSelected: (sel) {
if (sel) {
setModalState(() => _badgePosition = pos);
setState(() {});
}
},
);
}).toList(),
),
const SizedBox(height: 12),
// Fullscreen preview & Max video duration
SwitchListTile(
title: const Text(
'Full-Screen Preview (Long Press)',
style: TextStyle(color: Colors.white),
),
subtitle: const Text(
'Long press asset tile to inspect in full-screen',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
value: _enableFullScreenPreview,
activeTrackColor: Colors.deepPurpleAccent,
onChanged: (v) {
setModalState(() => _enableFullScreenPreview = v);
setState(() {});
},
),
const SizedBox(height: 10),
Text(
'Max Video Duration: ${_maxVideoDurationSeconds}s',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
Slider(
value: _maxVideoDurationSeconds.toDouble(),
min: 15,
max: 180,
divisions: 11,
activeColor: Colors.deepPurpleAccent,
label: '${_maxVideoDurationSeconds}s',
onChanged: (v) {
setModalState(
() => _maxVideoDurationSeconds = v.toInt(),
);
setState(() {});
},
),
const SizedBox(height: 12),
// File View Mode
const Text(
'File View Mode',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: HQPickerFileViewMode.values.map((mode) {
final isSel = _fileViewMode == mode;
return ChoiceChip(
label: Text(mode.name.toUpperCase()),
selected: isSel,
selectedColor: Colors.deepPurple,
onSelected: (sel) {
if (sel) {
setModalState(() => _fileViewMode = mode);
setState(() {});
}
},
);
}).toList(),
),
const SizedBox(height: 12),
// Double Tap Action
const Text(
'Double Tap Action',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: HQPickerGestureAction.values.map((act) {
final isSel = _doubleTapAction == act;
return ChoiceChip(
label: Text(act.name),
selected: isSel,
selectedColor: Colors.deepPurple,
onSelected: (sel) {
if (sel) {
setModalState(() => _doubleTapAction = act);
setState(() {});
}
},
);
}).toList(),
),
const SizedBox(height: 12),
// Long Press Action
const Text(
'Long Press Action',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: HQPickerGestureAction.values.map((act) {
final isSel = _longPressAction == act;
return ChoiceChip(
label: Text(act.name),
selected: isSel,
selectedColor: Colors.deepPurple,
onSelected: (sel) {
if (sel) {
setModalState(() => _longPressAction = act);
setState(() {});
}
},
);
}).toList(),
),
const SizedBox(height: 12),
// Custom Builders Section
const Text(
' Custom Builders & Overrides',
style: TextStyle(
color: Colors.deepPurpleAccent,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
SwitchListTile(
title: const Text(
'Custom Preview Dialog Builder',
style: TextStyle(color: Colors.white),
),
subtitle: const Text(
'Override long press preview with custom widget',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
value: _useCustomPreviewDialog,
activeTrackColor: Colors.deepPurpleAccent,
onChanged: (v) {
setModalState(() => _useCustomPreviewDialog = v);
setState(() {});
},
),
SwitchListTile(
title: const Text(
'Custom Confirm Button Builder',
style: TextStyle(color: Colors.white),
),
subtitle: const Text(
'Custom send/confirm button with gradient & count badge',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
value: _useCustomConfirmButton,
activeTrackColor: Colors.deepPurpleAccent,
onChanged: (v) {
setModalState(() => _useCustomConfirmButton = v);
setState(() {});
},
),
SwitchListTile(
title: const Text(
'Custom AppBar Builder (Instagram)',
style: TextStyle(color: Colors.white),
),
subtitle: const Text(
'Override top app bar in Instagram picker shape',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
value: _useCustomAppBar,
activeTrackColor: Colors.deepPurpleAccent,
onChanged: (v) {
setModalState(() => _useCustomAppBar = v);
setState(() {});
},
),
const SizedBox(height: 12),
// Localizations & New Features
const Text(
'Localizations & Custom Loaders',
style: TextStyle(
color: Colors.deepPurpleAccent,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
SwitchListTile(
title: const Text('Arabic Localization (HQPickerLocalizations.ar)', style: TextStyle(color: Colors.white)),
value: _useArabicLocalizations,
activeTrackColor: Colors.deepPurpleAccent,
onChanged: (v) {
setModalState(() => _useArabicLocalizations = v);
setState(() {});
},
),
SwitchListTile(
title: const Text('Custom Loading Widget', style: TextStyle(color: Colors.white)),
subtitle: const Text('Replaces all CircularProgressIndicators', style: TextStyle(color: Colors.white54, fontSize: 12)),
value: _useCustomLoadingWidget,
activeTrackColor: Colors.deepPurpleAccent,
onChanged: (v) {
setModalState(() => _useCustomLoadingWidget = v);
setState(() {});
},
),
const SizedBox(height: 10),
// Preview BoxFit
const Text('Preview BoxFit', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: [BoxFit.cover, BoxFit.contain, BoxFit.fill].map((fit) {
final isSel = _previewFit == fit;
return ChoiceChip(
label: Text(fit.name),
selected: isSel,
selectedColor: Colors.deepPurple,
onSelected: (sel) {
if (sel) {
setModalState(() => _previewFit = fit);
setState(() {});
}
},
);
}).toList(),
),
const SizedBox(height: 12),
// Grid Item BoxFit
const Text('Grid Item BoxFit', style: TextStyle(color: Colors.white70, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: [BoxFit.cover, BoxFit.contain, BoxFit.fill].map((fit) {
final isSel = _gridItemFit == fit;
return ChoiceChip(
label: Text(fit.name),
selected: isSel,
selectedColor: Colors.deepPurple,
onSelected: (sel) {
if (sel) {
setModalState(() => _gridItemFit = fit);
setState(() {});
}
},
);
}).toList(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
padding: const EdgeInsets.symmetric(vertical: 14),
),
onPressed: () => Navigator.pop(ctx),
child: const Text(
'Apply Settings',
style: TextStyle(color: Colors.white),
),
),
),
],
),
),
);
},
);
},
);
}
Future<void> _pickMedia({
required HQPickerShape shape,
required int maxCount,
required HQPickerRequestType requestType,
}) async {
try {
List<String>? allowedExtensions;
if (shape == HQPickerShape.document) {
if (requestType == HQPickerRequestType.image) {
allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic'];
} else if (requestType == HQPickerRequestType.video) {
allowedExtensions = ['mp4', 'mov', 'avi', 'mkv', 'flv', 'wmv'];
}
}
final results = await HQPicker.pick(
context: context,
shape: shape,
maxCount: maxCount,
requestType: requestType,
allowedExtensions: allowedExtensions,
config: HQPickerConfig(
loadingWidget: _useCustomLoadingWidget
? const Center(
child: CircularProgressIndicator(color: Colors.deepPurpleAccent),
)
: null,
previewFit: _previewFit,
gridItemFit: _gridItemFit,
compressImage: false,
// ── Grid & Item Customization ─────────────────────
gridCrossAxisCount: _gridCrossAxisCount,
gridCrossAxisSpacing: 4.0,
gridMainAxisSpacing: 4.0,
gridItemBorderRadius: BorderRadius.circular(_gridItemBorderRadius),
// ── Selection Badge & Animation Customization ──────
selectionStyle: _selectionStyle,
badgePosition: _badgePosition,
enableSelectionAnimation: true,
// ── Fullscreen Preview & Constraints ───────────────
enableFullScreenPreview: _enableFullScreenPreview,
maxVideoDuration: Duration(seconds: _maxVideoDurationSeconds),
// ── File View Mode & Gestures ─────────────────────
fileViewMode: _fileViewMode,
doubleTapAction: _doubleTapAction,
longPressAction: _longPressAction,
customFileTypeIcons: {
'.pdf': const Icon(Icons.picture_as_pdf, color: Colors.red),
'.docx': const Icon(Icons.description, color: Colors.blue),
},
// ── Callbacks ─────────────────────────────────────
onMaxCountReached: () {
debugPrint('HQPicker: Max count limit reached!');
},
onAssetTap: (asset) {
debugPrint('HQPicker: Asset tapped: ${asset.id}');
},
onAlbumChanged: (album) {
debugPrint('HQPicker: Album changed: ${album.name}');
},
// ── Custom Builders & Overrides ───────────────────
previewDialogBuilder: _useCustomPreviewDialog
? (ctx, asset) {
return Dialog(
backgroundColor: const Color(0xFF1E1E2E),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Custom Preview',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white70),
onPressed: () => Navigator.pop(ctx),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
height: 280,
width: double.infinity,
child: AssetEntityImage(
asset,
isOriginal: true,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 12),
Text(
'ID: ${asset.id} (${asset.width}×${asset.height})',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
),
),
);
}
: null,
confirmButtonBuilder: _useCustomConfirmButton
? (ctx, selectedAssets, onConfirm) {
final count = selectedAssets.length;
return Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Colors.deepPurple, Colors.purpleAccent],
),
borderRadius: BorderRadius.circular(20),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onConfirm,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.send_rounded, color: Colors.white, size: 14),
const SizedBox(width: 6),
Text(
count > 0 ? 'Send ($count)' : 'Send',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
),
);
}
: null,
appBarBuilder: _useCustomAppBar
? (ctx, currentAlbum, selectedAssets, onConfirm, onBack) {
return AppBar(
backgroundColor: const Color(0xFF181824),
elevation: 2,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded, color: Colors.white),
onPressed: onBack,
),
title: Text(
currentAlbum?.name ?? 'Media Picker',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 17,
),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: Center(
child: TextButton.icon(
onPressed: onConfirm,
icon: const Icon(
Icons.check_circle_rounded,
size: 18,
color: Colors.amberAccent,
),
label: Text(
'Done (${selectedAssets.length})',
style: const TextStyle(
color: Colors.amberAccent,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
);
}
: null,
// ── Theming example ──────────────────────────────
theme: const HQPickerTheme(
backgroundColor: Color(0xFF1E1E2E),
appbarColor: Color(0xFF1E1E2E),
backgroundDropDownColor: Color(0xFF2A2D3E),
confirmButtonColor: Colors.deepPurple,
badgeBackgroundColor: Colors.deepPurple,
),
// ── Localization example ──────────────────────────
localizations: _useArabicLocalizations
? const HQPickerLocalizations.ar()
: const HQPickerLocalizations(
confirm: 'Done',
emptyList: 'No media found',
gallery: 'Gallery',
permissionRequired: 'Access Required',
permissionDenied: 'Please allow media access in Settings.',
),
// ── Scroll physics, sort order & custom icons ──
sortOrder: HQPickerSortOrder.newestFirst,
scrollPhysics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
icons: HQPickerIcons(
gifBadge: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'GIF',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
// ── Custom snack-bar callback ─────────────────────
onSnackBar: (ctx, msg) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(msg),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepPurple,
),
);
},
// ── Custom empty state widget ───────────────────
emptyWidget: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 48, color: Colors.white54),
SizedBox(height: 12),
Text(
'No items found in this album',
style: TextStyle(color: Colors.white54),
),
],
),
),
),
);
if (!mounted) return;
setState(() => _results = results);
if (results.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${results.length} item(s) selected'),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to pick: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
behavior: SnackBarBehavior.floating,
),
);
}
}
void _clearResults() {
setState(() => _results = []);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('HQPicker Example'),
centerTitle: false,
actions: [
IconButton(
icon: const Icon(Icons.tune),
tooltip: 'Customize Settings',
onPressed: _openCustomizationBottomSheet,
),
],
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.image_outlined), text: 'Images'),
Tab(icon: Icon(Icons.videocam_outlined), text: 'Videos'),
Tab(icon: Icon(Icons.perm_media_outlined), text: 'All Media'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_TabContentView(
key: const PageStorageKey('images_tab'),
subtitle: 'Pick single or multiple images using any picker shape',
requestType: HQPickerRequestType.image,
multiCount: 5,
results: _results,
onPick: _pickMedia,
onClearResults: _clearResults,
),
_TabContentView(
key: const PageStorageKey('videos_tab'),
subtitle: 'Pick single or multiple videos using any picker shape',
requestType: HQPickerRequestType.video,
multiCount: 5,
results: _results,
onPick: _pickMedia,
onClearResults: _clearResults,
),
_TabContentView(
key: const PageStorageKey('all_media_tab'),
subtitle: 'Pick images and videos combined using any picker shape',
requestType: HQPickerRequestType.all,
multiCount: 10,
results: _results,
onPick: _pickMedia,
onClearResults: _clearResults,
),
],
),
);
}
}
// ─────────────────────────────────────────────
// Tab Content View
// ─────────────────────────────────────────────
class _TabContentView extends StatefulWidget {
final String subtitle;
final HQPickerRequestType requestType;
final int multiCount;
final List<HQPickerResult> results;
final Future<void> Function({
required HQPickerShape shape,
required int maxCount,
required HQPickerRequestType requestType,
})
onPick;
final VoidCallback onClearResults;
const _TabContentView({
super.key,
required this.subtitle,
required this.requestType,
required this.multiCount,
required this.results,
required this.onPick,
required this.onClearResults,
});
@override
State<_TabContentView> createState() => _TabContentViewState();
}
class _TabContentViewState extends State<_TabContentView>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
final theme = Theme.of(context);
return CustomScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
// Section Header
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
widget.subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
// Shape Cards List
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final option = _shapes[index];
return _ShapeCardTile(
option: option,
multiCount: widget.multiCount,
onPickSingle: () => widget.onPick(
shape: option.shape,
maxCount: 1,
requestType: widget.requestType,
),
onPickMulti: () => widget.onPick(
shape: option.shape,
maxCount: widget.multiCount,
requestType: widget.requestType,
),
);
},
childCount: _shapes.length,
addAutomaticKeepAlives: true,
addRepaintBoundaries: true,
),
),
),
// Picked Results Panel
if (widget.results.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Selected Results (${widget.results.length})',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
TextButton.icon(
icon: const Icon(Icons.clear_all, size: 18),
label: const Text('Clear'),
onPressed: widget.onClearResults,
),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_ResultItemTile(result: widget.results[index]),
childCount: widget.results.length,
addAutomaticKeepAlives: true,
addRepaintBoundaries: true,
),
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
}
}
// ─────────────────────────────────────────────
// Clean Material Shape Card Tile
// ─────────────────────────────────────────────
class _ShapeCardTile extends StatelessWidget {
final _ShapeOption option;
final int multiCount;
final VoidCallback onPickSingle;
final VoidCallback onPickMulti;
const _ShapeCardTile({
required this.option,
required this.multiCount,
required this.onPickSingle,
required this.onPickMulti,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
radius: 20,
child: Icon(option.icon, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
option.description,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 14),
if (option.isDirectory)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Select Directory'),
onPressed: onPickSingle,
),
)
else
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.looks_one_outlined, size: 18),
label: const Text('Single'),
onPressed: onPickSingle,
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.tonalIcon(
icon: const Icon(Icons.photo_library_outlined, size: 18),
label: Text('Multiple ($multiCount)'),
onPressed: onPickMulti,
),
),
],
),
],
),
),
);
}
}
// ─────────────────────────────────────────────
// Result Item Tile
// ─────────────────────────────────────────────
class _ResultItemTile extends StatelessWidget {
final HQPickerResult result;
const _ResultItemTile({required this.result});
String _formatBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FutureBuilder<File?>(
future: result.getFile(),
builder: (context, snapshot) {
final file = snapshot.data ?? result.file;
final asset = result.asset;
final isImage =
asset?.type == AssetType.image ||
(file != null &&
[
'.jpg',
'.jpeg',
'.png',
'.gif',
'.webp',
'.heic',
].any((ext) => file.path.toLowerCase().endsWith(ext)));
final isVideo =
asset?.type == AssetType.video ||
(file != null &&
[
'.mp4',
'.mov',
'.avi',
'.mkv',
'.flv',
'.wmv',
].any((ext) => file.path.toLowerCase().endsWith(ext)));
Widget leadingWidget;
if (asset != null) {
leadingWidget = ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 44,
height: 44,
child: AssetEntityImage(
asset,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(200),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_fallbackIcon(theme, isImage, isVideo),
),
),
);
} else if (file != null && isImage) {
leadingWidget = ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
file,
width: 44,
height: 44,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_fallbackIcon(theme, isImage, isVideo),
),
);
} else {
leadingWidget = SizedBox(
width: 44,
height: 44,
child: _fallbackIcon(theme, isImage, isVideo),
);
}
String title = 'Picked File';
if (file != null) {
title = file.path.split('/').last;
} else if (asset != null) {
title = 'Asset ${asset.id}';
}
String subtitle = 'Loading file details...';
if (file != null) {
final sizeStr = file.existsSync()
? _formatBytes(file.lengthSync())
: 'Unknown size';
subtitle = '$sizeStr • ${file.path}';
} else if (asset != null) {
subtitle =
'${asset.type.name.toUpperCase()} • ${asset.width}x${asset.height}';
}
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
color: theme.colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: ListTile(
dense: true,
leading: leadingWidget,
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
);
},
);
}
Widget _fallbackIcon(ThemeData theme, bool isImage, bool isVideo) {
IconData iconData = Icons.insert_drive_file_outlined;
if (isImage) {
iconData = Icons.image_outlined;
} else if (isVideo) {
iconData = Icons.videocam_outlined;
}
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
iconData,
color: theme.colorScheme.onSurfaceVariant,
size: 22,
),
);
}
}