unified_file_picker 0.1.1
unified_file_picker: ^0.1.1 copied to clipboard
Cross-platform Flutter file and folder picker with a unified, fully customizable explorer-style UI, type filters, thumbnails, search, and RTL.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:unified_file_picker/unified_file_picker.dart';
import 'demo_config.dart';
import 'live_preview_pane.dart';
/// Live customization studio for **unified_file_picker** by quds.cc.
///
/// Run from the `example/` directory (or use the workspace launch config).
void main() {
runApp(const QudsFilePickerExampleApp());
}
class QudsFilePickerExampleApp extends StatefulWidget {
const QudsFilePickerExampleApp({super.key});
@override
State<QudsFilePickerExampleApp> createState() =>
_QudsFilePickerExampleAppState();
}
class _QudsFilePickerExampleAppState extends State<QudsFilePickerExampleApp> {
Locale _locale = const Locale('en');
TextDirection _textDirection = TextDirection.ltr;
void _toggleLocale() {
setState(() {
if (_locale.languageCode == 'en') {
_locale = const Locale('ar');
_textDirection = TextDirection.rtl;
} else {
_locale = const Locale('en');
_textDirection = TextDirection.ltr;
}
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Unified File Picker — quds.cc',
locale: _locale,
localizationsDelegates: const [
UnifiedFilePickerLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('ar'),
],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF5B4FCF),
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF5B4FCF),
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: ExampleHomePage(
textDirection: _textDirection,
onToggleLocale: _toggleLocale,
localeLabel: _locale.languageCode == 'ar' ? 'English' : 'العربية',
),
);
}
}
class ExampleHomePage extends StatefulWidget {
const ExampleHomePage({
super.key,
required this.textDirection,
required this.onToggleLocale,
required this.localeLabel,
});
final TextDirection textDirection;
final VoidCallback onToggleLocale;
final String localeLabel;
@override
State<ExampleHomePage> createState() => _ExampleHomePageState();
}
class _ExampleHomePageState extends State<ExampleHomePage> {
DemoConfig _config = DemoConfig.defaults();
List<PickedFile> _selectedItems = const [];
String _status = 'Adjust the settings on the left — the picker updates live on the right.';
bool _showLivePreview = true;
double _previewWidth = 560;
FilePickerMode _previewMode = FilePickerMode.open;
static const double _minPreviewWidth = 360;
static const double _minSettingsWidth = 420;
static const double _splitBreakpoint = 960;
ColorScheme get _scheme => ColorScheme.fromSeed(
seedColor: _config.seedColor,
brightness: _config.isDark ? Brightness.dark : Brightness.light,
);
UnifiedFilePickerStrings? get _strings =>
_config.useBrandStrings ? const BrandFilePickerStrings() : null;
FilePickerOptions _previewOptions() {
return _config
.options(
mode: _previewMode,
type: FilePickerType.any,
selection: SelectionMode.multiple,
suggestedFileName: 'quds-notes',
strings: _strings,
textDirection: widget.textDirection,
)
.copyWith(
theme: _config.theme(_scheme),
presentation: FilePickerPresentation.fullscreen,
);
}
FilePickerOptions _themedOptions(FilePickerOptions options) {
return options.copyWith(
theme: _config.theme(_scheme),
strings: _strings ?? options.strings,
textDirection: widget.textDirection,
);
}
Future<void> _launchPicker(FilePickerOptions options) async {
final result = await UnifiedFilePicker.pick(
context,
options: _themedOptions(options),
);
if (!mounted || result == null) {
return;
}
setState(() {
_selectedItems = result;
_status = 'Selected ${result.length} item(s)';
});
}
Future<void> _pickFiles(FilePickerType type) {
return _launchPicker(
_config.options(
mode: FilePickerMode.open,
type: type,
selection: type == FilePickerType.documents
? SelectionMode.single
: SelectionMode.multiple,
strings: _strings,
textDirection: widget.textDirection,
),
);
}
Future<void> _pickCustomFiles() {
return _launchPicker(
_config.options(
mode: FilePickerMode.open,
selection: SelectionMode.multiple,
customFilters: const [
FileTypeFilter(label: 'Markdown', extensions: ['.md', '.markdown']),
FileTypeFilter(label: 'Code', extensions: ['.dart', '.js', '.py']),
],
strings: _strings,
textDirection: widget.textDirection,
),
);
}
Future<void> _pickFolder() {
return _launchPicker(
_config.options(
mode: FilePickerMode.folder,
selection: SelectionMode.single,
strings: _strings,
textDirection: widget.textDirection,
),
);
}
Future<void> _saveFile() async {
final options = _config.options(
mode: FilePickerMode.save,
suggestedFileName: 'quds-notes',
customFilters: const [
FileTypeFilter(label: 'Text', extensions: ['.txt']),
FileTypeFilter(label: 'Markdown', extensions: ['.md']),
],
strings: _strings,
textDirection: widget.textDirection,
);
final themed = _themedOptions(options);
final location = await UnifiedFilePicker.pick(context, options: themed);
if (!mounted || location == null || location.isEmpty) {
return;
}
final saved = await UnifiedFilePicker.writeBytes(
location.first,
'Saved from the quds.cc unified_file_picker example.\n'.codeUnits,
);
setState(() {
_selectedItems = location;
_status = saved
? 'Saved to ${location.first.path}'
: 'Save path chosen, but writing failed.';
});
}
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: widget.textDirection,
child: Scaffold(
appBar: AppBar(
title: const Text('unified_file_picker'),
actions: [
TextButton(
onPressed: widget.onToggleLocale,
child: Text(widget.localeLabel),
),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
final canSplit =
constraints.maxWidth >= _splitBreakpoint && _showLivePreview;
final maxPreviewWidth =
constraints.maxWidth - _minSettingsWidth - 10;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_PublisherBanner(scheme: _scheme),
const SizedBox(height: 16),
_CustomizationPanel(
config: _config,
scheme: _scheme,
showLivePreview: _showLivePreview,
onChanged: (value) => setState(() => _config = value),
onReset: () =>
setState(() => _config = DemoConfig.defaults()),
onShowLivePreviewChanged: (value) =>
setState(() => _showLivePreview = value),
),
const SizedBox(height: 24),
Text(
'Launch picker (modal)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
for (final type in FilePickerType.values)
FilledButton.icon(
onPressed: () => _pickFiles(type),
icon: Icon(_iconForType(type)),
label: Text(type.label),
),
FilledButton.icon(
onPressed: _pickCustomFiles,
icon: const Icon(Icons.tune_rounded),
label: const Text('Custom filters'),
),
FilledButton.icon(
onPressed: _pickFolder,
icon: const Icon(Icons.folder_rounded),
label: const Text('Pick folder'),
),
FilledButton.icon(
onPressed: _saveFile,
icon: const Icon(Icons.save_rounded),
label: const Text('Save file'),
),
],
),
const SizedBox(height: 24),
Text(_status, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (_selectedItems.isEmpty)
Text(
'Picker results appear here.',
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
)
else
..._selectedItems.map(
(item) => Card(
child: ListTile(
leading: Icon(
item.isDirectory
? Icons.folder_rounded
: Icons.insert_drive_file_rounded,
),
title: Text(item.name),
subtitle: Text(
item.path,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
),
),
],
),
),
if (canSplit) ...[
PreviewResizeHandle(
textDirection: widget.textDirection,
onDrag: (expandDelta) {
setState(() {
_previewWidth = (_previewWidth + expandDelta).clamp(
_minPreviewWidth,
maxPreviewWidth,
);
});
},
),
SizedBox(
width: _previewWidth.clamp(
_minPreviewWidth,
maxPreviewWidth,
),
child: LivePreviewPane(
options: _previewOptions(),
scheme: _scheme,
isDark: _config.isDark,
mode: _previewMode,
onModeChanged: (mode) =>
setState(() => _previewMode = mode),
onPicked: (items) => setState(() {
_selectedItems = items;
_status =
'Preview selected ${items.length} item(s)';
}),
onReload: () => setState(
() => _status = 'Preview reloaded.',
),
),
),
],
],
);
},
),
),
);
}
IconData _iconForType(FilePickerType type) {
switch (type) {
case FilePickerType.any:
return Icons.folder_open_rounded;
case FilePickerType.documents:
return Icons.description_rounded;
case FilePickerType.images:
return Icons.image_rounded;
case FilePickerType.videos:
return Icons.videocam_rounded;
}
}
}
class _PublisherBanner extends StatelessWidget {
const _PublisherBanner({required this.scheme});
final ColorScheme scheme;
@override
Widget build(BuildContext context) {
return Card(
color: scheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Fully customizable file picker',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Published by quds.cc. Tune colors, layout, transitions, '
'strings, icons, and behavior live — the side preview '
'updates instantly as you change settings.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: scheme.onPrimaryContainer,
),
),
],
),
),
);
}
}
class _CustomizationPanel extends StatefulWidget {
const _CustomizationPanel({
required this.config,
required this.scheme,
required this.showLivePreview,
required this.onChanged,
required this.onReset,
required this.onShowLivePreviewChanged,
});
final DemoConfig config;
final ColorScheme scheme;
final bool showLivePreview;
final ValueChanged<DemoConfig> onChanged;
final VoidCallback onReset;
final ValueChanged<bool> onShowLivePreviewChanged;
@override
State<_CustomizationPanel> createState() => _CustomizationPanelState();
}
class _CustomizationPanelState extends State<_CustomizationPanel> {
late final TextEditingController _titleController;
late final TextEditingController _confirmController;
late final TextEditingController _cancelController;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.config.customTitle);
_confirmController =
TextEditingController(text: widget.config.customConfirmLabel);
_cancelController =
TextEditingController(text: widget.config.customCancelLabel);
}
@override
void didUpdateWidget(covariant _CustomizationPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.config.customTitle != widget.config.customTitle &&
_titleController.text != widget.config.customTitle) {
_titleController.text = widget.config.customTitle;
}
if (oldWidget.config.customConfirmLabel != widget.config.customConfirmLabel &&
_confirmController.text != widget.config.customConfirmLabel) {
_confirmController.text = widget.config.customConfirmLabel;
}
if (oldWidget.config.customCancelLabel != widget.config.customCancelLabel &&
_cancelController.text != widget.config.customCancelLabel) {
_cancelController.text = widget.config.customCancelLabel;
}
}
@override
void dispose() {
_titleController.dispose();
_confirmController.dispose();
_cancelController.dispose();
super.dispose();
}
void _reset() {
widget.onReset();
_titleController.text = DemoConfig.defaults().customTitle;
_confirmController.text = DemoConfig.defaults().customConfirmLabel;
_cancelController.text = DemoConfig.defaults().customCancelLabel;
}
@override
Widget build(BuildContext context) {
final config = widget.config;
final onChanged = widget.onChanged;
final scheme = widget.scheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text(
'Customization',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
TextButton(onPressed: _reset, child: const Text('Reset')),
],
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Live side preview'),
subtitle: const Text(
'Shows the picker beside settings (wide windows). '
'Drag the divider to resize.',
),
value: widget.showLivePreview,
onChanged: widget.onShowLivePreviewChanged,
),
const SizedBox(height: 12),
_SectionLabel('Appearance'),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Dark picker theme'),
value: config.isDark,
onChanged: (value) => onChanged(config.copyWith(isDark: value)),
),
_ColorRow(
label: 'Accent color',
value: config.seedColor,
choices: const [
Color(0xFF5B4FCF),
Color(0xFF0F766E),
Color(0xFFBE123C),
Color(0xFF1D4ED8),
Color(0xFFCA8A04),
],
onChanged: (color) =>
onChanged(config.copyWith(seedColor: color)),
),
_SliderRow(
label: 'Corner radius',
value: config.borderRadius,
min: 4,
max: 28,
onChanged: (value) =>
onChanged(config.copyWith(borderRadius: value)),
),
_SliderRow(
label: 'Sidebar width',
value: config.sidebarWidth,
min: 160,
max: 320,
divisions: 8,
onChanged: (value) =>
onChanged(config.copyWith(sidebarWidth: value)),
),
const SizedBox(height: 8),
_SectionLabel('Modal'),
_EnumChips<FilePickerPresentation>(
label: 'Presentation',
value: config.presentation,
values: FilePickerPresentation.values,
labelFor: (value) => value.name,
onChanged: (value) =>
onChanged(config.copyWith(presentation: value)),
),
_EnumChips<FilePickerTransitionKind>(
label: 'Transition',
value: config.transitionKind,
values: FilePickerTransitionKind.values,
labelFor: (value) => value.name,
onChanged: (value) =>
onChanged(config.copyWith(transitionKind: value)),
),
_SliderRow(
label: 'Modal max width',
value: config.modalMaxWidth,
min: 640,
max: 1280,
divisions: 8,
onChanged: (value) =>
onChanged(config.copyWith(modalMaxWidth: value)),
),
_SliderRow(
label: 'Modal max height',
value: config.modalMaxHeight,
min: 480,
max: 900,
divisions: 7,
onChanged: (value) =>
onChanged(config.copyWith(modalMaxHeight: value)),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Dismiss on barrier tap'),
value: config.barrierDismissible,
onChanged: (value) =>
onChanged(config.copyWith(barrierDismissible: value)),
),
const SizedBox(height: 8),
_SectionLabel('Layout & content'),
_EnumChips<FilePickerViewMode>(
label: 'Initial view',
value: config.initialViewMode,
values: FilePickerViewMode.values,
labelFor: (value) => value.name,
onChanged: (value) =>
onChanged(config.copyWith(initialViewMode: value)),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final mode in FilePickerViewMode.values)
FilterChip(
label: Text(mode.name),
selected: config.allowedViewModes.contains(mode),
onSelected: (selected) {
final modes = Set<FilePickerViewMode>.from(
config.allowedViewModes,
);
if (selected) {
modes.add(mode);
} else if (modes.length > 1) {
modes.remove(mode);
}
onChanged(config.copyWith(allowedViewModes: modes));
},
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Show shortcuts sidebar'),
value: config.showShortcutsSidebar,
onChanged: (value) =>
onChanged(config.copyWith(showShortcutsSidebar: value)),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Show thumbnails'),
value: config.showThumbnails,
onChanged: (value) =>
onChanged(config.copyWith(showThumbnails: value)),
),
const SizedBox(height: 8),
_SectionLabel('Labels & strings'),
TextField(
decoration: const InputDecoration(
labelText: 'Dialog title',
border: OutlineInputBorder(),
),
controller: _titleController,
onChanged: (value) =>
onChanged(config.copyWith(customTitle: value)),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Confirm button',
border: OutlineInputBorder(),
),
controller: _confirmController,
onChanged: (value) =>
onChanged(config.copyWith(customConfirmLabel: value)),
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Cancel button',
border: OutlineInputBorder(),
),
controller: _cancelController,
onChanged: (value) =>
onChanged(config.copyWith(customCancelLabel: value)),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Use brand string overrides'),
subtitle: const Text('Extends EnglishFilePickerStrings'),
value: config.useBrandStrings,
onChanged: (value) =>
onChanged(config.copyWith(useBrandStrings: value)),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Use custom shortcut icons'),
value: config.useCustomIcons,
onChanged: (value) =>
onChanged(config.copyWith(useCustomIcons: value)),
),
const SizedBox(height: 8),
DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: scheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Active config: ${config.presentation.name} · '
'${config.transitionKind.name} · '
'${config.initialViewMode.name} · '
'${config.allowedViewModes.length} view modes',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
],
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
text,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
);
}
}
class _SliderRow extends StatelessWidget {
const _SliderRow({
required this.label,
required this.value,
required this.min,
required this.max,
required this.onChanged,
this.divisions,
});
final String label;
final double value;
final double min;
final double max;
final int? divisions;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$label: ${value.round()}'),
Slider(
value: value,
min: min,
max: max,
divisions: divisions,
label: value.round().toString(),
onChanged: onChanged,
),
],
);
}
}
class _ColorRow extends StatelessWidget {
const _ColorRow({
required this.label,
required this.value,
required this.choices,
required this.onChanged,
});
final String label;
final Color value;
final List<Color> choices;
final ValueChanged<Color> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final color in choices)
ChoiceChip(
label: const SizedBox(width: 18, height: 18),
selected: value.toARGB32() == color.toARGB32(),
avatar: CircleAvatar(backgroundColor: color, radius: 10),
onSelected: (_) => onChanged(color),
),
],
),
],
);
}
}
class _EnumChips<T> extends StatelessWidget {
const _EnumChips({
required this.label,
required this.value,
required this.values,
required this.labelFor,
required this.onChanged,
});
final String label;
final T value;
final List<T> values;
final String Function(T value) labelFor;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final item in values)
ChoiceChip(
label: Text(labelFor(item)),
selected: item == value,
onSelected: (_) => onChanged(item),
),
],
),
const SizedBox(height: 8),
],
);
}
}