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 { String? _poolId; bool _includeRecycled = false; void setFilters(String? poolId, bool includeRecycled) { _poolId = poolId; _includeRecycled = includeRecycled; ref.invalidateSelf(); } @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}; // 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 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) { // Filter state final selectedPool = useState(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? usage, Map? quota, WidgetRef ref, ValueNotifier selectedPool, ValueNotifier 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 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 _buildFilters( WidgetRef ref, ValueNotifier selectedPool, ValueNotifier 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( value: selectedPool.value, decoration: InputDecoration( labelText: 'Pool', border: const OutlineInputBorder(), ), items: [ DropdownMenuItem( value: null, child: Text('allPools'.tr()), ), ...pools.map( (pool) => DropdownMenuItem( 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( value: selectedPool.value, decoration: const InputDecoration( labelText: 'Pool', border: OutlineInputBorder(), ), items: [ DropdownMenuItem( value: null, child: Text('allPools'.tr()), ), ...pools.map( (pool) => DropdownMenuItem( 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 _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), ), ], ], ), ], ), ), ); } }