hq_picker 0.0.4 copy "hq_picker: ^0.0.4" to clipboard
hq_picker: ^0.0.4 copied to clipboard

A stateful widget that allows users to pick media files (images, videos, audio, files) from their device.

example/lib/main.dart

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(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.deepPurple,
          brightness: Brightness.dark,
        ),
      ),
      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 = [];

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 3, vsync: this);
  }

  @override
  void dispose() {
    _tabController.dispose();
    super.dispose();
  }

  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(
          enableCropping: false,
          compressImage: false,
          // ── Theming example ──────────────────────────────
          theme: const HQPickerTheme(
            backgroundColor: Color(0xFF1E1E2E),
            appbarColor: Color(0xFF1E1E2E),
            backgroundDropDownColor: Color(0xFF2A2D3E),
            confirmButtonColor: Colors.deepPurple,
            badgeBackgroundColor: Colors.deepPurple,
          ),
          // ── Localization example ──────────────────────────
          localizations: const HQPickerLocalizations(
            confirm: 'Done',
            emptyList: 'No media found',
            gallery: 'Gallery',
            permissionRequired: 'Access Required',
            permissionDenied: 'Please allow media access in Settings.',
          ),
          // ── Custom snack-bar callback ─────────────────────
          onSnackBar: (ctx, msg) {
            ScaffoldMessenger.of(ctx).showSnackBar(
              SnackBar(
                content: Text(msg),
                behavior: SnackBarBehavior.floating,
                backgroundColor: Colors.deepPurple,
              ),
            );
          },
        ),
      );

      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,
        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});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final file = 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 = '';
    if (asset != null) {
      subtitle = '${asset.type.name.toUpperCase()} • ${asset.width}x${asset.height}';
    } else if (file != null) {
      subtitle = file.path;
    }

    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,
      ),
    );
  }
}