flutter_fast_exif_reader 1.0.1
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.
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),
),
],
),
),
),
],
),
),
),
);
}
}