🎨 Use feature based folder structure
This commit is contained in:
187
lib/drive/files/file_detail.dart
Normal file
187
lib/drive/files/file_detail.dart
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
311
lib/drive/files/file_list.dart
Normal file
311
lib/drive/files/file_list.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user