557 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			557 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:fl_chart/fl_chart.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter_hooks/flutter_hooks.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/pods/file_pool.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<SnCloudFile> {
 | |
|   String? _poolId;
 | |
|   bool _includeRecycled = false;
 | |
| 
 | |
|   void setFilters(String? poolId, bool includeRecycled) {
 | |
|     _poolId = poolId;
 | |
|     _includeRecycled = includeRecycled;
 | |
|     ref.invalidateSelf();
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null);
 | |
| 
 | |
|   @override
 | |
|   Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async {
 | |
|     final client = ref.read(apiClientProvider);
 | |
|     final offset = cursor == null ? 0 : int.parse(cursor);
 | |
|     final take = 20;
 | |
| 
 | |
|     final queryParameters = <String, dynamic>{'offset': offset, 'take': take};
 | |
| 
 | |
|     // Add filter parameters
 | |
|     if (_poolId != null) {
 | |
|       queryParameters['pool'] = _poolId!;
 | |
|     }
 | |
|     if (_includeRecycled) {
 | |
|       queryParameters['recycled'] = 'true';
 | |
|     }
 | |
| 
 | |
|     final response = await client.get(
 | |
|       '/drive/files/me',
 | |
|       queryParameters: queryParameters,
 | |
|     );
 | |
| 
 | |
|     final List<SnCloudFile> items =
 | |
|         (response.data as List)
 | |
|             .map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
 | |
|             .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<Map<String, dynamic>?> billingUsage(Ref ref) async {
 | |
|   final client = ref.read(apiClientProvider);
 | |
|   final response = await client.get('/drive/billing/usage');
 | |
|   return response.data;
 | |
| }
 | |
| 
 | |
| @riverpod
 | |
| Future<Map<String, dynamic>?> 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) {
 | |
|     // Filter state
 | |
|     final selectedPool = useState<String?>(null);
 | |
|     final includeRecycled = useState(false);
 | |
| 
 | |
|     final usageAsync = ref.watch(billingUsageProvider);
 | |
|     final quotaAsync = ref.watch(billingQuotaProvider);
 | |
| 
 | |
|     // Update notifier filters when state changes
 | |
|     useEffect(() {
 | |
|       final notifier = ref.read(cloudFileListNotifierProvider.notifier);
 | |
|       notifier.setFilters(selectedPool.value, includeRecycled.value);
 | |
|       return null;
 | |
|     }, [selectedPool.value, includeRecycled.value]);
 | |
| 
 | |
|     return AppScaffold(
 | |
|       appBar: AppBar(title: Text('Files'), leading: const PageBackButton()),
 | |
|       body: usageAsync.when(
 | |
|         data:
 | |
|             (usage) => quotaAsync.when(
 | |
|               data:
 | |
|                   (quota) => _buildQuotaUI(
 | |
|                     usage,
 | |
|                     quota,
 | |
|                     ref,
 | |
|                     selectedPool,
 | |
|                     includeRecycled,
 | |
|                   ),
 | |
|               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<String, dynamic>? usage,
 | |
|     Map<String, dynamic>? quota,
 | |
|     WidgetRef ref,
 | |
|     ValueNotifier<String?> selectedPool,
 | |
|     ValueNotifier<bool> includeRecycled,
 | |
|   ) {
 | |
|     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),
 | |
|         SliverToBoxAdapter(
 | |
|           child: _buildFilters(ref, selectedPool, includeRecycled),
 | |
|         ),
 | |
|         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<String, dynamic> usage) {
 | |
|     final pools = usage['pool_usages'] as List<dynamic>;
 | |
|     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<String, dynamic>;
 | |
|             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<String, dynamic>? 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 _buildFilters(
 | |
|     WidgetRef ref,
 | |
|     ValueNotifier<String?> selectedPool,
 | |
|     ValueNotifier<bool> includeRecycled,
 | |
|   ) {
 | |
|     final poolsAsync = ref.watch(poolsProvider);
 | |
| 
 | |
|     return Card(
 | |
|       child: Padding(
 | |
|         padding: const EdgeInsets.all(16),
 | |
|         child: Column(
 | |
|           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|           children: [
 | |
|             Text(
 | |
|               'filters'.tr(),
 | |
|               style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
 | |
|             ),
 | |
|             const Gap(16),
 | |
|             LayoutBuilder(
 | |
|               builder: (context, constraints) {
 | |
|                 final isWide = constraints.maxWidth > 600;
 | |
|                 return isWide
 | |
|                     ? Row(
 | |
|                       children: [
 | |
|                         Expanded(
 | |
|                           flex: 2,
 | |
|                           child: poolsAsync.when(
 | |
|                             data:
 | |
|                                 (pools) => DropdownButtonFormField<String?>(
 | |
|                                   value: selectedPool.value,
 | |
|                                   decoration: InputDecoration(
 | |
|                                     labelText: 'Pool',
 | |
|                                     border: const OutlineInputBorder(),
 | |
|                                   ),
 | |
|                                   items: [
 | |
|                                     DropdownMenuItem<String?>(
 | |
|                                       value: null,
 | |
|                                       child: Text('allPools'.tr()),
 | |
|                                     ),
 | |
|                                     ...pools.map(
 | |
|                                       (pool) => DropdownMenuItem<String?>(
 | |
|                                         value: pool.id,
 | |
|                                         child: Text(pool.name),
 | |
|                                       ),
 | |
|                                     ),
 | |
|                                   ],
 | |
|                                   onChanged:
 | |
|                                       (value) => selectedPool.value = value,
 | |
|                                 ),
 | |
|                             loading: () => const CircularProgressIndicator(),
 | |
|                             error: (e, _) => const Text('Error loading pools'),
 | |
|                           ),
 | |
|                         ),
 | |
|                         const Gap(8),
 | |
|                         Expanded(
 | |
|                           child: Row(
 | |
|                             children: [
 | |
|                               Text('includeRecycled'.tr()),
 | |
|                               const Gap(8),
 | |
|                               Switch(
 | |
|                                 value: includeRecycled.value,
 | |
|                                 onChanged:
 | |
|                                     (value) => includeRecycled.value = value,
 | |
|                                 padding: EdgeInsets.zero,
 | |
|                               ),
 | |
|                             ],
 | |
|                           ),
 | |
|                         ),
 | |
|                         const Gap(16),
 | |
|                         IconButton(
 | |
|                           icon: const Icon(Symbols.delete_sweep),
 | |
|                           tooltip: 'deleteRecycledFiles'.tr(),
 | |
|                           onPressed:
 | |
|                               includeRecycled.value
 | |
|                                   ? () => _deleteRecycledFiles(ref)
 | |
|                                   : null,
 | |
|                         ),
 | |
|                       ],
 | |
|                     )
 | |
|                     : Column(
 | |
|                       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                       children: [
 | |
|                         poolsAsync.when(
 | |
|                           data:
 | |
|                               (pools) => DropdownButtonFormField<String?>(
 | |
|                                 value: selectedPool.value,
 | |
|                                 decoration: const InputDecoration(
 | |
|                                   labelText: 'Pool',
 | |
|                                   border: OutlineInputBorder(),
 | |
|                                 ),
 | |
|                                 items: [
 | |
|                                   DropdownMenuItem<String?>(
 | |
|                                     value: null,
 | |
|                                     child: Text('allPools'.tr()),
 | |
|                                   ),
 | |
|                                   ...pools.map(
 | |
|                                     (pool) => DropdownMenuItem<String?>(
 | |
|                                       value: pool.id,
 | |
|                                       child: Text(pool.name),
 | |
|                                     ),
 | |
|                                   ),
 | |
|                                 ],
 | |
|                                 onChanged:
 | |
|                                     (value) => selectedPool.value = value,
 | |
|                               ),
 | |
|                           loading: () => const CircularProgressIndicator(),
 | |
|                           error: (e, _) => const Text('Error loading pools'),
 | |
|                         ),
 | |
|                         const Gap(16),
 | |
|                         Row(
 | |
|                           children: [
 | |
|                             Text('includeRecycled'.tr()),
 | |
|                             const Gap(8),
 | |
|                             Switch(
 | |
|                               value: includeRecycled.value,
 | |
|                               onChanged:
 | |
|                                   (value) => includeRecycled.value = value,
 | |
|                             ),
 | |
|                             const Spacer(),
 | |
|                             IconButton(
 | |
|                               icon: const Icon(Symbols.delete_sweep),
 | |
|                               tooltip: 'deleteRecycledFiles'.tr(),
 | |
|                               onPressed:
 | |
|                                   includeRecycled.value
 | |
|                                       ? () => _deleteRecycledFiles(ref)
 | |
|                                       : null,
 | |
|                             ),
 | |
|                           ],
 | |
|                         ),
 | |
|                       ],
 | |
|                     );
 | |
|               },
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     ).padding(horizontal: 8);
 | |
|   }
 | |
| 
 | |
|   Future<void> _deleteRecycledFiles(WidgetRef ref) async {
 | |
|     final confirmed = await showConfirmAlert(
 | |
|       'confirmDeleteRecycledFiles'.tr(),
 | |
|       'deleteRecycledFiles'.tr(),
 | |
|     );
 | |
|     if (!confirmed) return;
 | |
| 
 | |
|     if (ref.context.mounted) showLoadingModal(ref.context);
 | |
|     try {
 | |
|       final client = ref.read(apiClientProvider);
 | |
|       await client.delete('/drive/files/recycled');
 | |
|       ref.invalidate(cloudFileListNotifierProvider);
 | |
|       showSnackBar('recycledFilesDeleted'.tr());
 | |
|     } catch (e) {
 | |
|       showSnackBar('failedToDeleteRecycledFiles'.tr());
 | |
|     } finally {
 | |
|       if (ref.context.mounted) hideLoadingModal(ref.context);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   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),
 | |
|                   ),
 | |
|                 ],
 | |
|               ],
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |