diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 13dc50b2..ce0cff3b 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1041,5 +1041,10 @@ "save": "Save", "webView": "Web View", "messageActions": "Message Actions", - "viewEmbedLoadHint": "Tap to load" + "viewEmbedLoadHint": "Tap to load", + "files": "Files", + "confirmDeleteFile": "Are you sure you want to delete this file?", + "deleteFile": "Delete File", + "failedToDeleteFile": "Failed to delete file", + "drive": "Drive" } diff --git a/lib/database/drift_db.dart b/lib/database/drift_db.dart index ce3aaa38..2b4e57ea 100644 --- a/lib/database/drift_db.dart +++ b/lib/database/drift_db.dart @@ -148,7 +148,7 @@ class AppDatabase extends _$AppDatabase { ..where((m) => m.roomId.equals(roomId)); if (query.isNotEmpty) { - final searchTerm = '%${query}%'; + final searchTerm = '%$query%'; selectStatement = selectStatement..where( (m) => diff --git a/lib/route.dart b/lib/route.dart index 9c182366..1261f74e 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -18,6 +18,7 @@ import 'package:island/screens/developers/edit_project.dart'; import 'package:island/screens/developers/new_project.dart'; import 'package:island/screens/developers/project_detail.dart'; import 'package:island/screens/discovery/articles.dart'; +import 'package:island/screens/files/file_list.dart'; import 'package:island/screens/posts/post_categories_list.dart'; import 'package:island/screens/posts/post_category_detail.dart'; import 'package:island/screens/posts/post_search.dart'; @@ -654,6 +655,11 @@ final routerProvider = Provider((ref) { path: '/account/wallet', builder: (context, state) => const WalletScreen(), ), + GoRoute( + name: 'files', + path: '/account/files', + builder: (context, state) => const FileListScreen(), + ), GoRoute( name: 'relationships', path: '/account/relationships', diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 3831fb2b..6ccdb969 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -304,6 +304,16 @@ class AccountScreen extends HookConsumerWidget { context.pushNamed('wallet'); }, ), + ListTile( + minTileHeight: 48, + leading: const Icon(Symbols.files), + trailing: const Icon(Symbols.chevron_right), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + title: Text('files').tr(), + onTap: () { + context.pushNamed('files'); + }, + ), ListTile( minTileHeight: 48, leading: const Icon(Symbols.people), diff --git a/lib/screens/files/file_list.dart b/lib/screens/files/file_list.dart new file mode 100644 index 00000000..871bdf17 --- /dev/null +++ b/lib/screens/files/file_list.dart @@ -0,0 +1,353 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/file.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/utils/format.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/file_info_sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; +import 'package:styled_widget/styled_widget.dart'; + +part 'file_list.g.dart'; + +@riverpod +class CloudFileListNotifier extends _$CloudFileListNotifier + with CursorPagingNotifierMixin { + @override + Future> build() => fetch(cursor: null); + + @override + Future> fetch({required String? cursor}) async { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + final take = 20; + + final queryParameters = {'offset': offset, 'take': take}; + + final response = await client.get( + '/drive/files/me', + queryParameters: queryParameters, + ); + + final List items = + (response.data as List) + .map((e) => SnCloudFile.fromJson(e as Map)) + .toList(); + final total = int.parse(response.headers.value('X-Total') ?? '0'); + + final hasMore = offset + items.length < total; + final nextCursor = hasMore ? (offset + items.length).toString() : null; + + return CursorPagingData( + items: items, + hasMore: hasMore, + nextCursor: nextCursor, + ); + } +} + +@riverpod +Future?> billingUsage(Ref ref) async { + final client = ref.read(apiClientProvider); + final response = await client.get('/drive/billing/usage'); + return response.data; +} + +@riverpod +Future?> billingQuota(Ref ref) async { + final client = ref.read(apiClientProvider); + final response = await client.get('/drive/billing/quota'); + return response.data; +} + +class FileListScreen extends HookConsumerWidget { + const FileListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usageAsync = ref.watch(billingUsageProvider); + final quotaAsync = ref.watch(billingQuotaProvider); + return AppScaffold( + appBar: AppBar(title: Text('Files')), + body: usageAsync.when( + data: + (usage) => quotaAsync.when( + data: (quota) => _buildQuotaUI(usage, quota, ref), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error loading quota')), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error loading usage')), + ), + ); + } + + Widget _buildQuotaUI( + Map? usage, + Map? quota, + WidgetRef ref, + ) { + if (usage == null) return const SizedBox.shrink(); + return CustomScrollView( + slivers: [ + const SliverGap(8), + SliverToBoxAdapter( + child: Column( + children: [ + Row( + children: [ + Expanded( + child: _buildStatCard( + 'All Uploads', + '${((usage['total_usage_bytes'] as num) / (1024 * 1024 * 1024)).toStringAsFixed(3)} GiB', + ), + ), + Expanded( + child: _buildStatCard( + 'All Files', + '${usage['total_file_count']}', + ), + ), + ], + ), + Row( + children: [ + Expanded( + child: _buildStatCard( + 'Quota', + '${usage['total_quota']} MiB', + ), + ), + Expanded( + child: _buildStatCard( + 'Used Quota', + '${((usage['used_quota'] as num) / (usage['total_quota'] as num) * 100).toStringAsFixed(2)}%', + progress: + (usage['used_quota'] as num) / + (usage['total_quota'] as num), + ), + ), + ], + ), + ], + ).padding(horizontal: 8), + ), + SliverToBoxAdapter( + child: Row( + children: [ + Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text('Pool Usage'), + SizedBox( + height: 200, + child: PieChart(_buildPoolChartData(usage)), + ), + ], + ), + ), + ), + ), + Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text('Verbose Quota'), + SizedBox( + height: 200, + child: PieChart(_buildQuotaChartData(quota)), + ), + ], + ), + ), + ), + ), + ], + ).padding(horizontal: 8), + ), + const SliverGap(8), + PagingHelperSliverView( + provider: cloudFileListNotifierProvider, + futureRefreshable: cloudFileListNotifierProvider.future, + notifierRefreshable: cloudFileListNotifierProvider.notifier, + contentBuilder: + (data, widgetCount, endItemView) => SliverList.builder( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } + + final item = data.items[index]; + final itemType = item.mimeType?.split('/').firstOrNull; + return ListTile( + leading: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: SizedBox( + height: 48, + width: 48, + child: switch (itemType) { + 'image' => CloudImageWidget(file: item), + 'audio' => + const Icon(Symbols.audio_file, fill: 1).center(), + 'video' => + const Icon(Symbols.video_file, fill: 1).center(), + _ => + const Icon(Symbols.body_system, fill: 1).center(), + }, + ), + ), + title: + item.name.isEmpty + ? Text('untitled').tr().italic() + : Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(formatFileSize(item.size)), + onTap: () { + showModalBottomSheet( + useRootNavigator: true, + context: context, + isScrollControlled: true, + builder: (context) => FileInfoSheet(item: item), + ); + }, + trailing: IconButton( + icon: const Icon(Symbols.delete), + onPressed: () async { + final confirmed = await showConfirmAlert( + 'confirmDeleteFile'.tr(), + 'deleteFile'.tr(), + ); + if (!confirmed) return; + + if (context.mounted) showLoadingModal(context); + try { + final client = ref.read(apiClientProvider); + await client.delete('/drive/files/${item.id}'); + ref.invalidate(cloudFileListNotifierProvider); + } catch (e) { + showSnackBar('failedToDeleteFile'.tr()); + } finally { + if (context.mounted) hideLoadingModal(context); + } + }, + ), + ); + }, + ), + ), + ], + ); + } + + PieChartData _buildPoolChartData(Map usage) { + final pools = usage['pool_usages'] as List; + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.red, + Colors.purple, + ]; + return PieChartData( + sections: + pools.asMap().entries.map((entry) { + final pool = entry.value as Map; + final title = pool['pool_name'] as String; + final truncatedTitle = + title.length > 8 ? '${title.substring(0, 8)}...' : title; + return PieChartSectionData( + value: (pool['usage_bytes'] as num).toDouble(), + title: truncatedTitle, + color: colors[entry.key % colors.length], + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ); + }).toList(), + ); + } + + PieChartData _buildQuotaChartData(Map? quota) { + if (quota == null) return PieChartData(sections: []); + return PieChartData( + sections: [ + PieChartSectionData( + value: (quota['based_quota'] as num).toDouble(), + title: 'Base', + color: Colors.green, + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + PieChartSectionData( + value: (quota['extra_quota'] as num).toDouble(), + title: 'Extra', + color: Colors.orange, + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + Widget _buildStatCard(String label, String value, {double? progress}) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(label, style: const TextStyle(fontSize: 14)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (progress != null) ...[ + const SizedBox(height: 8), + SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator(value: progress), + ), + ], + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/files/file_list.g.dart b/lib/screens/files/file_list.g.dart new file mode 100644 index 00000000..b500d44a --- /dev/null +++ b/lib/screens/files/file_list.g.dart @@ -0,0 +1,69 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'file_list.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$billingUsageHash() => r'270ec8499378ee0c038aa44ad1c2e3ad9025740a'; + +/// See also [billingUsage]. +@ProviderFor(billingUsage) +final billingUsageProvider = + AutoDisposeFutureProvider?>.internal( + billingUsage, + name: r'billingUsageProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$billingUsageHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef BillingUsageRef = AutoDisposeFutureProviderRef?>; +String _$billingQuotaHash() => r'0696b500fa8bb1270641bcacf262be58caff9b38'; + +/// See also [billingQuota]. +@ProviderFor(billingQuota) +final billingQuotaProvider = + AutoDisposeFutureProvider?>.internal( + billingQuota, + name: r'billingQuotaProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$billingQuotaHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef BillingQuotaRef = AutoDisposeFutureProviderRef?>; +String _$cloudFileListNotifierHash() => + r'e2c8a076a9e635c7b43a87d00f78775427ba6334'; + +/// See also [CloudFileListNotifier]. +@ProviderFor(CloudFileListNotifier) +final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider< + CloudFileListNotifier, + CursorPagingData +>.internal( + CloudFileListNotifier.new, + name: r'cloudFileListNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$cloudFileListNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$CloudFileListNotifier = + AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/widgets/content/cloud_file_collection.dart b/lib/widgets/content/cloud_file_collection.dart index 08cb7b70..901d826d 100644 --- a/lib/widgets/content/cloud_file_collection.dart +++ b/lib/widgets/content/cloud_file_collection.dart @@ -1,15 +1,12 @@ -import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'dart:ui'; - import 'package:dismissible_page/dismissible_page.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:gal/gal.dart'; @@ -17,11 +14,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/file.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; -import 'package:island/utils/format.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/file_info_sheet.dart'; import 'package:island/widgets/content/sensitive.dart'; -import 'package:island/widgets/content/sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:path/path.dart' show extension; import 'package:path_provider/path_provider.dart'; @@ -361,284 +357,11 @@ class CloudFileZoomIn extends HookConsumerWidget { } void showInfoSheet() { - final theme = Theme.of(context); - final exifData = item.fileMeta?['exif'] as Map? ?? {}; - showModalBottomSheet( useRootNavigator: true, context: context, isScrollControlled: true, - builder: - (context) => SheetScaffold( - titleText: 'File Information', - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text('mimeType').tr(), - Text( - item.mimeType ?? 'unknown'.tr(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - SizedBox(height: 28, child: const VerticalDivider()), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text('fileSize').tr(), - Text( - formatFileSize(item.size), - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - if (item.hash != null) - SizedBox(height: 28, child: const VerticalDivider()), - if (item.hash != null) - Expanded( - child: GestureDetector( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text('fileHash').tr(), - Text( - '${item.hash!.substring(0, 6)}...', - style: theme.textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ], - ), - onLongPress: () { - Clipboard.setData( - ClipboardData(text: item.hash!), - ); - showSnackBar('File hash copied to clipboard'); - }, - ), - ), - ], - ).padding(horizontal: 24, vertical: 16), - const Divider(height: 1), - ListTile( - leading: const Icon(Symbols.tag), - title: Text('ID').tr(), - subtitle: Text( - item.id, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - contentPadding: EdgeInsets.symmetric(horizontal: 24), - trailing: IconButton( - icon: const Icon(Icons.copy), - onPressed: () { - Clipboard.setData(ClipboardData(text: item.id)); - showSnackBar('File ID copied to clipboard'); - }, - ), - ), - ListTile( - leading: const Icon(Symbols.file_present), - title: Text('Name').tr(), - subtitle: Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - contentPadding: EdgeInsets.symmetric(horizontal: 24), - trailing: IconButton( - icon: const Icon(Icons.copy), - onPressed: () { - Clipboard.setData(ClipboardData(text: item.name)); - showSnackBar('File name copied to clipboard'); - }, - ), - ), - if (exifData.isNotEmpty) ...[ - const Divider(height: 1), - Theme( - data: theme.copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - tilePadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - title: Text( - 'exifData'.tr(), - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...exifData.entries.map( - (entry) => ListTile( - dense: true, - contentPadding: EdgeInsets.symmetric( - horizontal: 24, - ), - title: - Text( - entry.key.contains('-') - ? entry.key.split('-').last - : entry.key, - style: theme.textTheme.bodyMedium - ?.copyWith( - fontWeight: FontWeight.w500, - ), - ).bold(), - subtitle: Text( - '${entry.value}'.isNotEmpty - ? '${entry.value}' - : 'N/A', - style: theme.textTheme.bodyMedium, - ), - onTap: () { - Clipboard.setData( - ClipboardData(text: '${entry.value}'), - ); - showSnackBar('Value copied to clipboard'); - }, - ), - ), - ], - ), - ], - ), - ), - ], - if (item.fileMeta != null && item.fileMeta!.isNotEmpty) ...[ - const Divider(height: 1), - Theme( - data: theme.copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - tilePadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - title: Text( - 'File Metadata', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...item.fileMeta!.entries.map( - (entry) => ListTile( - dense: true, - contentPadding: EdgeInsets.symmetric( - horizontal: 24, - ), - title: - Text( - entry.key, - style: theme.textTheme.bodyMedium - ?.copyWith( - fontWeight: FontWeight.w500, - ), - ).bold(), - subtitle: Text( - jsonEncode(entry.value), - style: theme.textTheme.bodyMedium, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - onTap: () { - Clipboard.setData( - ClipboardData( - text: jsonEncode(entry.value), - ), - ); - showSnackBar('Value copied to clipboard'); - }, - ), - ), - ], - ), - ], - ), - ), - ], - if (item.userMeta != null && item.userMeta!.isNotEmpty) ...[ - const Divider(height: 1), - Theme( - data: theme.copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - tilePadding: const EdgeInsets.symmetric( - horizontal: 24, - ), - title: Text( - 'User Metadata', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...item.userMeta!.entries.map( - (entry) => ListTile( - dense: true, - contentPadding: EdgeInsets.symmetric( - horizontal: 24, - ), - title: - Text( - entry.key, - style: theme.textTheme.bodyMedium - ?.copyWith( - fontWeight: FontWeight.w500, - ), - ).bold(), - subtitle: Text( - jsonEncode(entry.value), - style: theme.textTheme.bodyMedium, - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - onTap: () { - Clipboard.setData( - ClipboardData( - text: jsonEncode(entry.value), - ), - ); - showSnackBar('Value copied to clipboard'); - }, - ), - ), - ], - ), - ], - ), - ), - ], - const SizedBox(height: 16), - ], - ), - ), - ), + builder: (context) => FileInfoSheet(item: item), ); } diff --git a/lib/widgets/content/file_info_sheet.dart b/lib/widgets/content/file_info_sheet.dart new file mode 100644 index 00000000..9cf5a2aa --- /dev/null +++ b/lib/widgets/content/file_info_sheet.dart @@ -0,0 +1,280 @@ +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:island/models/file.dart'; +import 'package:island/utils/format.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class FileInfoSheet extends StatelessWidget { + final SnCloudFile item; + + const FileInfoSheet({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final exifData = item.fileMeta?['exif'] as Map? ?? {}; + + return SheetScaffold( + titleText: 'File Information', + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text('mimeType').tr(), + Text( + item.mimeType ?? 'unknown'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + SizedBox(height: 28, child: const VerticalDivider()), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text('fileSize').tr(), + Text( + formatFileSize(item.size), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + if (item.hash != null) + SizedBox(height: 28, child: const VerticalDivider()), + if (item.hash != null) + Expanded( + child: GestureDetector( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text('fileHash').tr(), + Text( + '${item.hash!.substring(0, 6)}...', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + onLongPress: () { + Clipboard.setData(ClipboardData(text: item.hash!)); + showSnackBar('File hash copied to clipboard'); + }, + ), + ), + ], + ).padding(horizontal: 24, vertical: 16), + const Divider(height: 1), + ListTile( + leading: const Icon(Symbols.tag), + title: Text('ID').tr(), + subtitle: Text( + item.id, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + trailing: IconButton( + icon: const Icon(Icons.copy), + onPressed: () { + Clipboard.setData(ClipboardData(text: item.id)); + showSnackBar('File ID copied to clipboard'); + }, + ), + ), + ListTile( + leading: const Icon(Symbols.file_present), + title: Text('Name').tr(), + subtitle: Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + trailing: IconButton( + icon: const Icon(Icons.copy), + onPressed: () { + Clipboard.setData(ClipboardData(text: item.name)); + showSnackBar('File name copied to clipboard'); + }, + ), + ), + if (exifData.isNotEmpty) ...[ + const Divider(height: 1), + Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text( + 'exifData'.tr(), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...exifData.entries.map( + (entry) => ListTile( + dense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 24, + ), + title: + Text( + entry.key.contains('-') + ? entry.key.split('-').last + : entry.key, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ).bold(), + subtitle: Text( + '${entry.value}'.isNotEmpty + ? '${entry.value}' + : 'N/A', + style: theme.textTheme.bodyMedium, + ), + onTap: () { + Clipboard.setData( + ClipboardData(text: '${entry.value}'), + ); + showSnackBar('Value copied to clipboard'); + }, + ), + ), + ], + ), + ], + ), + ), + ], + if (item.fileMeta != null && item.fileMeta!.isNotEmpty) ...[ + const Divider(height: 1), + Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text( + 'File Metadata', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...item.fileMeta!.entries.map( + (entry) => ListTile( + dense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 24, + ), + title: + Text( + entry.key, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ).bold(), + subtitle: Text( + jsonEncode(entry.value), + style: theme.textTheme.bodyMedium, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Clipboard.setData( + ClipboardData(text: jsonEncode(entry.value)), + ); + showSnackBar('Value copied to clipboard'); + }, + ), + ), + ], + ), + ], + ), + ), + ], + if (item.userMeta != null && item.userMeta!.isNotEmpty) ...[ + const Divider(height: 1), + Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text( + 'User Metadata', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...item.userMeta!.entries.map( + (entry) => ListTile( + dense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 24, + ), + title: + Text( + entry.key, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ).bold(), + subtitle: Text( + jsonEncode(entry.value), + style: theme.textTheme.bodyMedium, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + Clipboard.setData( + ClipboardData(text: jsonEncode(entry.value)), + ); + showSnackBar('Value copied to clipboard'); + }, + ), + ), + ], + ), + ], + ), + ), + ], + const SizedBox(height: 16), + ], + ), + ), + ); + } +}