flutter_fast_exif_reader 1.0.1 copy "flutter_fast_exif_reader: ^1.0.1" to clipboard
flutter_fast_exif_reader: ^1.0.1 copied to clipboard

High-performance EXIF and MakerNote reader for camera RAW and JPEG images. Operates 100% in-memory with zero disk re-reads.

example/lib/main.dart

import 'package:flutter/material.dart' show SelectableText;
import 'package:flutter/services.dart';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter/cupertino.dart';
import 'package:file_picker/file_picker.dart';
import 'package:windows_file_picker_wrapper/windows_file_picker_wrapper.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_fast_exif_reader/flutter_fast_exif_reader.dart';

void main() {
  runApp(const FastExifIosApp());
}

class FastExifIosApp extends StatelessWidget {
  const FastExifIosApp({super.key});

  @override
  Widget build(BuildContext context) {
    return CupertinoApp(
      title: 'Fast EXIF',
      debugShowCheckedModeBanner: false,
      theme: const CupertinoThemeData(
        brightness: Brightness.dark,
        primaryColor: CupertinoColors.systemBlue,
        scaffoldBackgroundColor: CupertinoColors.black,
        barBackgroundColor: Color(0xCC1C1C1E),
        textTheme: CupertinoTextThemeData(
          textStyle: TextStyle(
            fontFamily: '.SF Pro Text',
            fontSize: 16,
            color: CupertinoColors.white,
          ),
          navLargeTitleTextStyle: TextStyle(
            fontFamily: '.SF Pro Display',
            fontSize: 32,
            fontWeight: FontWeight.w700,
            letterSpacing: -0.5,
            color: CupertinoColors.white,
          ),
          navTitleTextStyle: TextStyle(
            fontFamily: '.SF Pro Text',
            fontSize: 17,
            fontWeight: FontWeight.w600,
            color: CupertinoColors.white,
          ),
        ),
      ),
      home: const IosExifHomeScreen(),
    );
  }
}

/// Single In-Memory representation of an uploaded file (0 bytes written to disk)
class InMemoryImageFile {
  final String name;
  final int sizeBytes;
  final Uint8List bytes;
  FastExifData? fastData;
  double fastTimeMs = 0.0;

  InMemoryImageFile({
    required this.name,
    required this.sizeBytes,
    required this.bytes,
  });

  String get sizeMb => (sizeBytes / (1024 * 1024)).toStringAsFixed(1);
  String get extension => name.contains('.') ? name.split('.').last.toUpperCase() : 'RAW';
}

class IosExifHomeScreen extends StatefulWidget {
  const IosExifHomeScreen({super.key});

  @override
  State<IosExifHomeScreen> createState() => _IosExifHomeScreenState();
}

class _IosExifHomeScreenState extends State<IosExifHomeScreen> {
  String _formatGpsDmsSingle(double decimal, bool isLat) {
    final abs = decimal.abs();
    final degrees = abs.floor();
    final minutesDecimal = (abs - degrees) * 60.0;
    final minutes = minutesDecimal.floor();
    final seconds = (minutesDecimal - minutes) * 60.0;
    final ref = isLat ? (decimal < 0 ? 'S' : 'N') : (decimal < 0 ? 'W' : 'E');
    return '$degrees°$minutes\'${seconds.toStringAsFixed(1)}"$ref';
  }

  InMemoryImageFile? _selectedFile;
  String _searchQuery = '';
  int _viewMode = 0;

  static const List<String> _rawExtensions = [
    'cr3', 'cr2', 'nef', 'nrw', 'arw', 'srf', 'sr2',
    'orf', 'raf', 'rw2', 'dng', 'pef', '3fr', 'jpg', 'jpeg', 'jfif'
  ];

  Future<void> _requestAndroidLocationPermissions() async {
    if (Platform.isAndroid) {
      await [
        Permission.photos,
        Permission.storage,
        Permission.accessMediaLocation,
      ].request();
    }
  }

  Future<void> _pickSingleFile() async {
    try {
      String? fileName;
      Uint8List? fileBytes;
      int fileSize = 0;

      if (Platform.isWindows) {
        // Modern Windows 10/11 File Picker
        final selectedPath = await WindowsFilePickerWrapper.pickFile(
          title: 'Select Camera RAW or Photo',
          type: WindowsFileType.custom,
          allowedExtensions: _rawExtensions,
        );
        if (selectedPath != null && selectedPath.isNotEmpty) {
          final f = File(selectedPath);
          if (await f.exists()) {
            fileName = f.uri.pathSegments.last;
            fileSize = await f.length();
            fileBytes = await f.readAsBytes();
          }
        }
      } else {
        // Android: Request ACCESS_MEDIA_LOCATION to prevent OS geolocation redaction
        await _requestAndroidLocationPermissions();

        final result = await FilePicker.platform.pickFiles(
          allowMultiple: false,
          type: FileType.custom,
          allowedExtensions: _rawExtensions,
          withData: true,
        );
        if (result != null && result.files.isNotEmpty) {
          final pf = result.files.first;
          fileName = pf.name;
          fileSize = pf.size;
          // Prefer direct File path bytes to bypass Android Scoped Storage redaction
          if (pf.path != null && File(pf.path!).existsSync()) {
            fileBytes = await File(pf.path!).readAsBytes();
          } else {
            fileBytes = pf.bytes;
          }
        }
      }

      if (fileBytes != null && fileBytes.isNotEmpty) {
        final item = InMemoryImageFile(
          name: fileName ?? 'Photo',
          sizeBytes: fileSize,
          bytes: fileBytes,
        );

        final sw = Stopwatch()..start();
        item.fastData = FlutterFastExifReader.readFromBytes(fileBytes);
        sw.stop();
        item.fastTimeMs = item.fastData?.processTimeMs ?? (sw.elapsedMicroseconds / 1000.0);

        setState(() {
          _selectedFile = item;
          _searchQuery = '';
        });
      }
    } catch (e) {
      if (mounted) {
        showCupertinoDialog(
          context: context,
          builder: (ctx) => CupertinoAlertDialog(
            title: const Text('Upload Error'),
            content: Text(e.toString()),
            actions: [
              CupertinoDialogAction(
                child: const Text('OK'),
                onPressed: () => Navigator.pop(ctx),
              ),
            ],
          ),
        );
      }
    }
  }

  void _clearFile() {
    setState(() {
      _selectedFile = null;
      _searchQuery = '';
    });
  }

  Future<void> _openMapUrl(String url) async {
    final uri = Uri.parse(url);
    try {
      if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
        await launchUrl(uri, mode: LaunchMode.platformDefault);
      }
    } catch (e) {
      if (mounted) {
        showCupertinoDialog(
          context: context,
          builder: (ctx) => CupertinoAlertDialog(
            title: const Text('Could not open map'),
            content: Text(e.toString()),
            actions: [
              CupertinoDialogAction(child: const Text('OK'), onPressed: () => Navigator.pop(ctx)),
            ],
          ),
        );
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return CupertinoPageScaffold(
      backgroundColor: CupertinoColors.black,
      navigationBar: CupertinoNavigationBar(
        middle: const Text('Fast EXIF'),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            CupertinoButton(
              padding: EdgeInsets.zero,
              onPressed: _pickSingleFile,
              child: const Icon(CupertinoIcons.cloud_upload, size: 22),
            ),
            if (_selectedFile != null) ...[
              const SizedBox(width: 8),
              CupertinoButton(
                padding: EdgeInsets.zero,
                onPressed: _clearFile,
                child: const Icon(CupertinoIcons.trash, size: 20, color: CupertinoColors.systemRed),
              ),
            ],
          ],
        ),
      ),
      child: SafeArea(
        child: _selectedFile == null ? _buildEmptyState() : _buildSinglePhotoInspector(),
      ),
    );
  }

  Widget _buildEmptyState() {
    return Center(
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 32),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              width: 90,
              height: 90,
              decoration: BoxDecoration(
                color: const Color(0xFF1C1C1E),
                shape: BoxShape.circle,
                border: Border.all(color: const Color(0xFF2C2C2E), width: 2),
              ),
              child: const Icon(CupertinoIcons.camera_viewfinder, size: 44, color: CupertinoColors.systemBlue),
            ),
            const SizedBox(height: 24),
            const Text(
              'Zero-Storage EXIF Reader',
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: -0.5),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 8),
            const Text(
              'Upload a camera RAW (.CR3, .NEF, .ARW, .ORF, .RAF, .RW2, .DNG) or JPEG.\nProcessed 100% in RAM with zero disk writes.',
              style: TextStyle(fontSize: 14, color: Color(0xFFB0B0B5), height: 1.4),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 28),
            CupertinoButton.filled(
              borderRadius: BorderRadius.circular(14),
              onPressed: _pickSingleFile,
              child: const Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(CupertinoIcons.plus_circle_fill, size: 18),
                  SizedBox(width: 8),
                  Text('Upload Photo to Memory', style: TextStyle(fontWeight: FontWeight.w600)),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildJsonOutputView(FastExifData? data) {
    if (data == null) {
      return Container(
        padding: const EdgeInsets.all(24),
        alignment: Alignment.center,
        child: const Text('No EXIF data available', style: TextStyle(color: CupertinoColors.systemGrey)),
      );
    }

    final jsonPretty = data.toJsonString(pretty: true);
    final tagCount = data.rawTags.length;

    return Container(
      decoration: BoxDecoration(
        color: const Color(0xFF141416),
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: const Color(0xFF2C2C2E)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          // JSON Header bar with copy action
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
            decoration: const BoxDecoration(
              color: Color(0xFF1C1C1E),
              borderRadius: BorderRadius.vertical(top: Radius.circular(15)),
              border: Border(bottom: BorderSide(color: Color(0xFF2C2C2E))),
            ),
            child: Row(
              children: [
                const Icon(CupertinoIcons.doc_text_fill, size: 16, color: CupertinoColors.systemTeal),
                const SizedBox(width: 8),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      const Text(
                        'Standard JSON EXIF Payload',
                        style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: CupertinoColors.white),
                      ),
                      Text(
                        '$tagCount tags • ${jsonPretty.length} bytes • 0.05ms serialize',
                        style: const TextStyle(fontSize: 11, color: Color(0xFFB0B0B5)),
                      ),
                    ],
                  ),
                ),
                CupertinoButton(
                  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                  color: CupertinoColors.systemTeal.withValues(alpha: 0.15),
                  borderRadius: BorderRadius.circular(8),
                  onPressed: () {
                    Clipboard.setData(ClipboardData(text: jsonPretty));
                    showCupertinoDialog(
                      context: context,
                      builder: (ctx) => CupertinoAlertDialog(
                        title: const Text('JSON Copied!'),
                        content: const Text('Complete JSON EXIF data has been copied to your clipboard.'),
                        actions: [
                          CupertinoDialogAction(
                            child: const Text('OK'),
                            onPressed: () => Navigator.pop(ctx),
                          ),
                        ],
                      ),
                    );
                  },
                  child: const Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Icon(CupertinoIcons.doc_on_doc, size: 13, color: CupertinoColors.systemTeal),
                      SizedBox(width: 4),
                      Text('Copy JSON', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: CupertinoColors.systemTeal)),
                    ],
                  ),
                ),
              ],
            ),
          ),

          // Code body
          Padding(
            padding: const EdgeInsets.all(16),
            child: SelectableText(
              jsonPretty,
              style: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 12,
                height: 1.45,
                color: Color(0xFFE5E5EA),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSinglePhotoInspector() {
    final f = _selectedFile!;
    final data = f.fastData;
    final tags = data?.rawTags ?? {};

    final make = data?.make.isNotEmpty == true ? data!.make : (tags['Image Make'] ?? 'Unknown Camera');
    final model = data?.model.isNotEmpty == true ? data!.model : (tags['Image Model'] ?? '');
    final lens = data?.lensModel.isNotEmpty == true ? data!.lensModel : (tags['EXIF LensModel'] ?? tags['MakerNote LensModel'] ?? 'Standard Optics');
    final timeMs = f.fastTimeMs;

    final lat = data?.latitude;
    final lon = data?.longitude;
    final alt = data?.altitude;
    final hasGps = (data?.hasGps == true || (lat != null && lon != null)) &&
                   !(lat == 0.0 && lon == 0.0);

    final filteredTags = tags.entries.where((e) {
      if (_searchQuery.isEmpty) return true;
      final q = _searchQuery.toLowerCase();
      return e.key.toLowerCase().contains(q) || e.value.toLowerCase().contains(q);
    }).toList();

    return Column(
      children: [
        Expanded(
          child: CustomScrollView(
            slivers: [
              SliverToBoxAdapter(
                child: Padding(
                  padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      // Active File Chip Bar
                      Container(
                        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
                        decoration: BoxDecoration(
                          color: const Color(0xFF141416),
                          borderRadius: BorderRadius.circular(12),
                          border: Border.all(color: const Color(0xFF2C2C2E)),
                        ),
                        child: Row(
                          children: [
                            Container(
                              padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                              decoration: BoxDecoration(
                                color: CupertinoColors.systemBlue.withValues(alpha: 0.2),
                                borderRadius: BorderRadius.circular(6),
                              ),
                              child: Text(
                                f.extension,
                                style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: CupertinoColors.systemBlue),
                              ),
                            ),
                            const SizedBox(width: 10),
                            Expanded(
                              child: Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: [
                                  Text(f.name, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: CupertinoColors.white), overflow: TextOverflow.ellipsis),
                                  Text('${f.sizeMb} MB in RAM', style: const TextStyle(fontSize: 11, color: Color(0xFFB0B0B5))),
                                ],
                              ),
                            ),
                            CupertinoButton(
                              padding: EdgeInsets.zero,
                              onPressed: _pickSingleFile,
                              child: const Row(
                                mainAxisSize: MainAxisSize.min,
                                children: [
                                  Icon(CupertinoIcons.arrow_2_squarepath, size: 14),
                                  SizedBox(width: 4),
                                  Text('Replace', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
                                ],
                              ),
                            ),
                          ],
                        ),
                      ),
                      const SizedBox(height: 12),

                      // Segmented Control (Visual vs JSON Output)
                      SizedBox(
                        width: double.infinity,
                        child: CupertinoSlidingSegmentedControl<int>(
                          groupValue: _viewMode,
                          backgroundColor: const Color(0xFF1C1C1E),
                          thumbColor: const Color(0xFF2C2C2E),
                          children: const {
                            0: Padding(
                              padding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
                              child: Row(
                                mainAxisAlignment: MainAxisAlignment.center,
                                children: [
                                  Icon(CupertinoIcons.square_grid_2x2, size: 14, color: CupertinoColors.white),
                                  SizedBox(width: 6),
                                  Text('Visual View', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: CupertinoColors.white)),
                                ],
                              ),
                            ),
                            1: Padding(
                              padding: EdgeInsets.symmetric(horizontal: 14, vertical: 8),
                              child: Row(
                                mainAxisAlignment: MainAxisAlignment.center,
                                children: [
                                  Icon(CupertinoIcons.chevron_left_slash_chevron_right, size: 14, color: CupertinoColors.systemTeal),
                                  SizedBox(width: 6),
                                  Text('JSON Output', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: CupertinoColors.white)),
                                ],
                              ),
                            ),
                          },
                          onValueChanged: (val) {
                            if (val != null) setState(() => _viewMode = val);
                          },
                        ),
                      ),
                      const SizedBox(height: 14),

                      if (_viewMode == 1) ...[
                        _buildJsonOutputView(data),
                      ] else ...[
                        // 1. Hero Camera Telemetry Card
                        Container(
                          padding: const EdgeInsets.all(16),
                          decoration: BoxDecoration(
                            gradient: const LinearGradient(
                              begin: Alignment.topLeft,
                              end: Alignment.bottomRight,
                              colors: [Color(0xFF1C1C1E), Color(0xFF2C2C2E)],
                            ),
                            borderRadius: BorderRadius.circular(18),
                            border: Border.all(color: const Color(0xFF38383A)),
                          ),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Row(
                                children: [
                                  Expanded(
                                    child: Column(
                                      crossAxisAlignment: CrossAxisAlignment.start,
                                      children: [
                                        Text('$make $model'.trim(),
                                            style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700, letterSpacing: -0.4, color: CupertinoColors.white),
                                            overflow: TextOverflow.ellipsis),
                                        const SizedBox(height: 3),
                                        Text(lens, style: const TextStyle(fontSize: 13, color: Color(0xFFD1D1D6)), overflow: TextOverflow.ellipsis),
                                      ],
                                    ),
                                  ),
                                  Container(
                                    padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
                                    decoration: BoxDecoration(
                                      color: const Color(0x3330D158),
                                      borderRadius: BorderRadius.circular(20),
                                      border: Border.all(color: const Color(0x6630D158)),
                                    ),
                                    child: Row(
                                      mainAxisSize: MainAxisSize.min,
                                      children: [
                                        const Icon(CupertinoIcons.bolt_fill, size: 11, color: CupertinoColors.systemGreen),
                                        const SizedBox(width: 4),
                                        Text('${timeMs.toStringAsFixed(2)} ms',
                                            style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: CupertinoColors.systemGreen)),
                                      ],
                                    ),
                                  ),
                                ],
                              ),
                              const SizedBox(height: 14),

                              // Telemetry Quick Grid
                              Row(
                                children: [
                                  _buildTelemetryPill('ISO', data?.isoString ?? tags['EXIF ISOSpeedRatings'] ?? '-'),
                                  const SizedBox(width: 6),
                                  _buildTelemetryPill('APERTURE', data?.fNumberString ?? tags['EXIF FNumber'] ?? '-'),
                                  const SizedBox(width: 6),
                                  _buildTelemetryPill('SHUTTER', data?.exposureTimeString ?? tags['EXIF ExposureTime'] ?? '-'),
                                  const SizedBox(width: 6),
                                  _buildTelemetryPill('FOCAL', data?.focalLengthString ?? tags['EXIF FocalLength'] ?? '-'),
                                ],
                              ),
                            ],
                          ),
                        ),
                        const SizedBox(height: 12),

                        // 2. Dedicated GPS Geolocation Card (if GPS info detected)
                        if (hasGps && lat != null && lon != null) ...[
                          Container(
                            padding: const EdgeInsets.all(14),
                            decoration: BoxDecoration(
                              color: const Color(0xFF1C1C1E),
                              borderRadius: BorderRadius.circular(16),
                              border: Border.all(color: const Color(0xFF2C2C2E)),
                            ),
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Row(
                                  children: [
                                    const Icon(CupertinoIcons.location_solid, size: 18, color: CupertinoColors.systemTeal),
                                    const SizedBox(width: 8),
                                    const Text(
                                      'GPS Information',
                                      style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: CupertinoColors.white),
                                    ),
                                  ],
                                ),
                                const SizedBox(height: 12),
                                Container(
                                  padding: const EdgeInsets.all(14),
                                  decoration: BoxDecoration(
                                    color: const Color(0xFF141416),
                                    borderRadius: BorderRadius.circular(12),
                                    border: Border.all(color: const Color(0xFF2C2C2E)),
                                  ),
                                  child: Column(
                                    crossAxisAlignment: CrossAxisAlignment.start,
                                    children: [
                                      SelectableText(
                                        '${_formatGpsDmsSingle(lat, true)} ${_formatGpsDmsSingle(lon, false)}',
                                        style: const TextStyle(
                                          fontSize: 15,
                                          fontWeight: FontWeight.w700,
                                          letterSpacing: -0.2,
                                          color: CupertinoColors.white,
                                        ),
                                      ),
                                      const SizedBox(height: 3),
                                      SelectableText(
                                        '${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}',
                                        style: const TextStyle(
                                          fontSize: 13,
                                          fontFamily: 'monospace',
                                          color: Color(0xFFB0B0B5),
                                        ),
                                      ),
                                      if (alt != null || tags.containsKey('GPS GPSAltitude')) ...[
                                        const SizedBox(height: 6),
                                        Row(
                                          children: [
                                            const Icon(CupertinoIcons.arrow_up_circle, size: 14, color: CupertinoColors.systemTeal),
                                            const SizedBox(width: 6),
                                            SelectableText(
                                              '${alt != null ? "${alt.abs().toStringAsFixed(1)} m" : (tags["GPS GPSAltitude"] ?? "")} ${tags["GPS GPSAltitudeRef"] ?? (alt != null && alt < 0 ? "Below Sea Level" : "Above Sea Level")}',
                                              style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFFD1D1D6)),
                                            ),
                                          ],
                                        ),
                                      ],
                                      if (tags.containsKey('GPS GPSDateStamp') || tags.containsKey('GPS GPSTimeStamp')) ...[
                                        const SizedBox(height: 4),
                                        Row(
                                          children: [
                                            const Icon(CupertinoIcons.time, size: 14, color: CupertinoColors.systemTeal),
                                            const SizedBox(width: 6),
                                            SelectableText(
                                              () {
                                                final d = tags['GPS GPSDateStamp']?.replaceAll(':', '-') ?? '';
                                                final t = tags['GPS GPSTimeStamp'] ?? '';
                                                if (d.isNotEmpty && t.isNotEmpty) {
                                                  return '$d ${t.replaceAll("UTC", "").trim()} UTC';
                                                }
                                                return d.isNotEmpty ? '$d UTC' : (t.endsWith("UTC") ? t : '$t UTC');
                                              }(),
                                              style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, fontFamily: 'monospace', color: Color(0xFFD1D1D6)),
                                            ),
                                          ],
                                        ),
                                      ],
                                    ],
                                  ),
                                ),
                                const SizedBox(height: 12),

                                // Map Launch Buttons
                                Row(
                                  children: [
                                    Expanded(
                                      child: CupertinoButton(
                                        padding: const EdgeInsets.symmetric(vertical: 10),
                                        color: CupertinoColors.systemBlue,
                                        borderRadius: BorderRadius.circular(10),
                                        onPressed: () {
                                          _openMapUrl('https://www.google.com/maps/search/?api=1&query=${lat.toStringAsFixed(6)},${lon.toStringAsFixed(6)}');
                                        },
                                        child: const Row(
                                          mainAxisAlignment: MainAxisAlignment.center,
                                          children: [
                                            Icon(CupertinoIcons.map_fill, size: 14, color: CupertinoColors.white),
                                            SizedBox(width: 6),
                                            Text('Google Maps', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: CupertinoColors.white)),
                                          ],
                                        ),
                                      ),
                                    ),
                                    const SizedBox(width: 8),
                                    Expanded(
                                      child: CupertinoButton(
                                        padding: const EdgeInsets.symmetric(vertical: 10),
                                        color: const Color(0xFF2C2C2E),
                                        borderRadius: BorderRadius.circular(10),
                                        onPressed: () {
                                          _openMapUrl('https://www.openstreetmap.org/?mlat=${lat.toStringAsFixed(6)}&mlon=${lon.toStringAsFixed(6)}#map=16/${lat.toStringAsFixed(6)}/${lon.toStringAsFixed(6)}');
                                        },
                                        child: const Row(
                                          mainAxisAlignment: MainAxisAlignment.center,
                                          children: [
                                            Icon(CupertinoIcons.compass_fill, size: 14, color: CupertinoColors.systemTeal),
                                            SizedBox(width: 6),
                                            Text('OpenStreetMap', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: CupertinoColors.white)),
                                          ],
                                        ),
                                      ),
                                    ),
                                  ],
                                ),
                              ],
                            ),
                          ),
                          const SizedBox(height: 12),
                        ],

                        // 3. Search Bar
                        CupertinoSearchTextField(
                          placeholder: 'Filter ${tags.length} extracted tags...',
                          style: const TextStyle(color: CupertinoColors.white, fontSize: 13),
                          onChanged: (v) => setState(() => _searchQuery = v),
                        ),
                        const SizedBox(height: 14),

                        // 4. Section Header
                        Row(
                          children: [
                            Text('Extracted Metadata (${filteredTags.length})',
                                style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Color(0xFFD1D1D6))),
                          ],
                        ),
                      ],
                    ],
                  ),
                ),
              ),

              // 5. Inset Grouped Tag List (only in Visual View)
              if (_viewMode == 0)
                SliverPadding(
                  padding: const EdgeInsets.symmetric(horizontal: 16),
                  sliver: SliverList(
                    delegate: SliverChildBuilderDelegate(
                      (context, index) {
                        final entry = filteredTags[index];
                        final isLast = index == filteredTags.length - 1;
                        return Container(
                          padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
                          decoration: BoxDecoration(
                            color: const Color(0xFF1C1C1E),
                            border: isLast ? null : const Border(bottom: BorderSide(color: Color(0xFF2C2C2E), width: 0.5)),
                          ),
                          child: Row(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Expanded(
                                flex: 2,
                                child: Text(entry.key, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: CupertinoColors.systemGrey)),
                              ),
                              const SizedBox(width: 8),
                              Expanded(
                                flex: 3,
                                child: SelectableText(entry.value, style: const TextStyle(fontSize: 13, color: CupertinoColors.white, fontFamily: 'monospace')),
                              ),
                            ],
                          ),
                        );
                      },
                      childCount: filteredTags.length,
                    ),
                  ),
                ),
              const SliverToBoxAdapter(child: SizedBox(height: 40)),
            ],
          ),
        ),
      ],
    );
  }


  Widget _buildTelemetryPill(String label, String value) {
    return Expanded(
      child: Container(
        padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
        decoration: BoxDecoration(
          color: const Color(0xFF000000).withValues(alpha: 0.5),
          borderRadius: BorderRadius.circular(10),
          border: Border.all(color: const Color(0xFF38383A)),
        ),
        child: Column(
          children: [
            Text(label, style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFFB0B0B5))),
            const SizedBox(height: 2),
            Text(
              value,
              style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800, color: CupertinoColors.white),
              overflow: TextOverflow.ellipsis,
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildFrostedBottomBar() {
    return ClipRect(
      child: BackdropFilter(
        filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
        child: Container(
          padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
          decoration: const BoxDecoration(
            color: Color(0xCC1C1C1E),
            border: Border(top: BorderSide(color: Color(0xFF2C2C2E))),
          ),
          child: Row(
            children: [
              Expanded(
                child: CupertinoButton.filled(
                  padding: const EdgeInsets.symmetric(vertical: 12),
                  borderRadius: BorderRadius.circular(12),
                  onPressed: _pickSingleFile,
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      const Icon(CupertinoIcons.arrow_2_squarepath, size: 16),
                      const SizedBox(width: 6),
                      Text(
                        _selectedFile == null ? 'Upload Photo to RAM' : 'Upload Another Photo',
                        style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
2
likes
160
points
139
downloads

Documentation

API reference

Publisher

verified publishersmsgee.com

Weekly Downloads

High-performance EXIF and MakerNote reader for camera RAW and JPEG images. Operates 100% in-memory with zero disk re-reads.

Repository (GitHub)
View/report issues

Topics

#exif #metadata #raw-images #camera #makernote

License

MIT (license)

Dependencies

ffi, flutter

More

Packages that depend on flutter_fast_exif_reader

Packages that implement flutter_fast_exif_reader