🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

View File

@@ -0,0 +1,187 @@
import 'package:flutter/foundation.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/drive/drive_models/file.dart';
import 'package:island/core/config.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/core/widgets/content/file_info_sheet.dart';
import 'package:island/core/widgets/content/file_viewer_contents.dart';
class FileDetailScreen extends HookConsumerWidget {
final SnCloudFile item;
const FileDetailScreen({super.key, required this.item});
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider);
final isWide = isWideScreen(context);
// Animation controller for the drawer
final animationController = useAnimationController(
duration: const Duration(milliseconds: 300),
);
final animation = useMemoized(
() => Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
),
[animationController],
);
final showDrawer = useState(false);
void showInfoSheet() {
if (isWide) {
// Show as animated right panel on wide screens
showDrawer.value = !showDrawer.value;
if (showDrawer.value) {
animationController.forward();
} else {
animationController.reverse();
}
} else {
// Show as bottom sheet on narrow screens
showModalBottomSheet(
useRootNavigator: true,
context: context,
isScrollControlled: true,
builder: (context) => FileInfoSheet(item: item),
);
}
}
// Listen to drawer state changes
useEffect(() {
void listener() {
if (!animationController.isAnimating) {
if (animationController.value == 0) {
showDrawer.value = false;
}
}
}
animationController.addListener(listener);
return () => animationController.removeListener(listener);
}, [animationController]);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
elevation: 0,
leading: IconButton(
icon: Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(item.name.isEmpty ? 'File Details' : item.name),
actions: _buildAppBarActions(context, ref, showInfoSheet),
),
body: LayoutBuilder(
builder: (context, constraints) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Stack(
children: [
// Main content area - resizes with animation
Positioned(
left: 0,
top: 0,
bottom: 0,
width: constraints.maxWidth - animation.value * 400,
child: _buildContent(context, ref, serverUrl),
),
// Animated drawer panel - overlays
if (isWide)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 400,
child: Transform.translate(
offset: Offset((1 - animation.value) * 400, 0),
child: SizedBox(
width: 400,
child: Material(
color: Theme.of(
context,
).colorScheme.surfaceContainer,
elevation: 8,
child: FileInfoSheet(
item: item,
onClose: showInfoSheet,
),
),
),
),
),
],
);
},
);
},
),
);
}
List<Widget> _buildAppBarActions(
BuildContext context,
WidgetRef ref,
VoidCallback showInfoSheet,
) {
final actions = <Widget>[];
// Add content-specific actions
switch (item.mimeType?.split('/').firstOrNull) {
case 'image':
if (!kIsWeb) {
actions.add(
IconButton(
icon: Icon(Icons.save_alt),
onPressed: () => FileDownloadService(ref).saveToGallery(item),
),
);
}
// HD/SD toggle will be handled in the image content overlay
break;
default:
if (!kIsWeb) {
actions.add(
IconButton(
icon: Icon(Icons.save_alt),
onPressed: () =>
FileDownloadService(ref).downloadWithProgress(item),
),
);
}
break;
}
// Always add info button
actions.add(
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
);
actions.add(const Gap(8));
return actions;
}
Widget _buildContent(BuildContext context, WidgetRef ref, String serverUrl) {
final uri = '$serverUrl/drive/files/${item.id}';
return switch (item.mimeType?.split('/').firstOrNull) {
'image' => ImageFileContent(item: item, uri: uri),
'video' => VideoFileContent(item: item, uri: uri),
'audio' => AudioFileContent(item: item, uri: uri),
_ when item.mimeType == 'application/pdf' => PdfFileContent(uri: uri),
_ when item.mimeType?.startsWith('text/') == true => TextFileContent(
uri: uri,
),
_ => GenericFileContent(item: item),
};
}
}

View File

@@ -0,0 +1,311 @@
import 'package:cross_file/cross_file.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.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/drive/drive_models/file.dart';
import 'package:island/drive/drive_models/file_pool.dart';
import 'package:island/drive/drive/file_list.dart';
import 'package:island/drive/drive_service.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:island/drive/drive_widgets/file_list_view.dart';
import 'package:island/accounts/usage_overview.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class FileListScreen extends HookConsumerWidget {
const FileListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Path navigation state
final currentPath = useState<String>('/');
final mode = useState<FileListMode>(FileListMode.normal);
final selectedPool = useState<SnFilePool?>(null);
final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider);
final viewMode = useState(FileListViewMode.list);
final isSelectionMode = useState<bool>(false);
final recycled = useState<bool>(false);
final query = useState<String?>(null);
final unindexedNotifier = ref.read(unindexedFileListProvider.notifier);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: SearchBar(
constraints: const BoxConstraints(maxWidth: 400, minHeight: 32),
hintText: 'searchFiles'.tr(),
hintStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)),
textStyle: WidgetStatePropertyAll(TextStyle(fontSize: 14)),
onChanged: (value) {
// Update the query state that will be passed to FileListView
query.value = value.isEmpty ? null : value;
},
leading: Icon(
Symbols.search,
size: 20,
color: Theme.of(context).colorScheme.onSurface,
),
),
leading: const PageBackButton(backTo: '/account'),
actions: [
// Selection mode toggle
IconButton(
icon: Icon(
isSelectionMode.value ? Symbols.close : Symbols.select_check_box,
),
onPressed: () => isSelectionMode.value = !isSelectionMode.value,
tooltip: isSelectionMode.value
? 'Exit Selection Mode'
: 'Enter Selection Mode',
),
// Recycle toggle (only in unindexed mode)
if (mode.value == FileListMode.unindexed)
IconButton(
icon: Icon(
recycled.value
? Symbols.delete_forever
: Symbols.restore_from_trash,
),
onPressed: () {
recycled.value = !recycled.value;
unindexedNotifier.setRecycled(recycled.value);
},
tooltip: recycled.value
? 'Show Active Files'
: 'Show Recycle Bin',
),
IconButton(
icon: const Icon(Symbols.bar_chart),
onPressed: () =>
_showUsageSheet(context, usageAsync.value, quotaAsync.value),
),
const Gap(8),
],
),
floatingActionButton: mode.value == FileListMode.normal
? FloatingActionButton(
onPressed: () => _showActionBottomSheet(
context,
ref,
currentPath,
selectedPool,
),
tooltip: 'Add files or create directory',
child: const Icon(Symbols.add),
)
: null,
body: usageAsync.when(
data: (usage) => quotaAsync.when(
data: (quota) => FileListView(
usage: usage,
quota: quota,
currentPath: currentPath,
selectedPool: selectedPool,
onPickAndUpload: () => _pickAndUploadFile(
ref,
currentPath.value,
selectedPool.value?.id,
),
onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode,
viewMode: viewMode,
isSelectionMode: isSelectionMode,
query: query,
),
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')),
),
);
}
Future<void> _pickAndUploadFile(
WidgetRef ref,
String currentPath,
String? poolId,
) async {
try {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
withData: false,
);
if (result != null && result.files.isNotEmpty) {
for (final file in result.files) {
if (file.path != null) {
// Create UniversalFile from the picked file
final universalFile = UniversalFile(
data: XFile(file.path!),
type: UniversalFileType.file,
displayName: file.name,
);
// Upload the file with the current path
final completer = FileUploader.createCloudFile(
fileData: universalFile,
ref: ref,
path: currentPath,
poolId: poolId,
onProgress: (progress, _) {
// Progress is handled by the upload tasks system
if (progress != null) {
debugPrint('Upload progress: ${(progress * 100).toInt()}%');
}
},
);
completer.future
.then((uploadedFile) {
if (uploadedFile != null) {
ref.invalidate(indexedCloudFileListProvider);
}
})
.catchError((error) {
showSnackBar('Failed to upload file: $error');
});
}
}
}
} catch (e) {
showSnackBar('Error picking file: $e');
}
}
Future<void> _showCreateDirectoryDialog(
BuildContext context,
ValueNotifier<String> currentPath,
) async {
final controller = TextEditingController(text: currentPath.value);
String? newPath;
void handleChangeDirectory(BuildContext context) {
newPath = controller.text.trim();
if (newPath!.isNotEmpty) {
// Normalize the path
String fullPath = newPath!;
// Ensure it starts with /
if (!fullPath.startsWith('/')) {
fullPath = '/$fullPath';
}
// Remove double slashes and normalize
fullPath = fullPath.replaceAll(RegExp(r'/+'), '/');
currentPath.value = fullPath;
Navigator.of(context).pop();
}
}
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Navigate to Directory'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Gap(8),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Directory path',
hintText: 'e.g., documents, projects/my-app',
helperText:
'Enter a directory path. The directory will be created when you upload files to it.',
helperMaxLines: 3,
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onSubmitted: (_) {
handleChangeDirectory(context);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton.icon(
onPressed: () => handleChangeDirectory(context),
label: const Text('Go to Directory'),
icon: const Icon(Symbols.arrow_right_alt),
),
],
),
);
}
void _showUsageSheet(
BuildContext context,
Map<String, dynamic>? usage,
Map<String, dynamic>? quota,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: 'Usage Overview',
child: UsageOverviewWidget(
usage: usage,
quota: quota,
).padding(horizontal: 8, vertical: 16),
),
);
}
void _showActionBottomSheet(
BuildContext context,
WidgetRef ref,
ValueNotifier<String> currentPath,
ValueNotifier<SnFilePool?> selectedPool,
) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Symbols.create_new_folder),
title: const Text('Create Directory'),
onTap: () {
Navigator.of(context).pop();
_showCreateDirectoryDialog(context, currentPath);
},
),
ListTile(
leading: const Icon(Symbols.upload_file),
title: const Text('Upload File'),
onTap: () {
Navigator.of(context).pop();
_pickAndUploadFile(
ref,
currentPath.value,
selectedPool.value?.id,
);
},
),
],
),
),
);
}
}