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/pool_provider.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),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|