diff --git a/lib/pods/site_files.dart b/lib/pods/site_files.dart index dcc2a287..41bcbba0 100644 --- a/lib/pods/site_files.dart +++ b/lib/pods/site_files.dart @@ -35,7 +35,24 @@ Future siteFileContent( final resp = await apiClient.get( '/zone/sites/$siteId/files/content/$relativePath', ); - return SnFileContent.fromJson(resp.data); + final content = + resp.data is String + ? resp.data + : SnFileContent.fromJson(resp.data).content; + return SnFileContent(content: content); +} + +@riverpod +Future siteFileContentRaw( + Ref ref, { + required String siteId, + required String relativePath, +}) async { + final apiClient = ref.watch(apiClientProvider); + final resp = await apiClient.get( + '/zone/sites/$siteId/files/content/$relativePath', + ); + return resp.data is String ? resp.data : resp.data['content'] as String; } class SiteFilesNotifier diff --git a/lib/widgets/sites/file_item.dart b/lib/widgets/sites/file_item.dart index 5e3e7606..2705adce 100644 --- a/lib/widgets/sites/file_item.dart +++ b/lib/widgets/sites/file_item.dart @@ -1,11 +1,18 @@ +import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter_code_editor/flutter_code_editor.dart'; +import 'package:flutter_highlight/themes/monokai-sublime.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/site_file.dart'; import 'package:island/models/publication_site.dart'; import 'package:island/pods/site_files.dart'; +import 'package:island/pods/network.dart'; import 'package:island/widgets/alert.dart'; +import 'package:island/widgets/content/sheet.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:styled_widget/styled_widget.dart'; class FileItem extends HookConsumerWidget { @@ -20,6 +27,85 @@ class FileItem extends HookConsumerWidget { this.onNavigateDirectory, }); + Future _downloadFile(BuildContext context, WidgetRef ref) async { + try { + final apiClient = ref.read(apiClientProvider); + + // Get downloads directory + Directory? directory; + if (Platform.isAndroid) { + directory = await getExternalStorageDirectory(); + if (directory != null) { + directory = Directory('${directory.path}/Download'); + } + } else { + directory = await getDownloadsDirectory(); + } + + if (directory == null) { + throw Exception('Unable to access downloads directory'); + } + + // Create directory if it doesn't exist + await directory.create(recursive: true); + + // Generate file path + final fileName = file.relativePath.split('/').last; + final filePath = '${directory.path}/$fileName'; + + // Use Dio's download method to directly stream from server to file + await apiClient.download( + '/zone/sites/${site.id}/files/content/${file.relativePath}', + filePath, + ); + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Downloaded to $filePath'))); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to download file: $e'))); + } + } + } + + Future _showEditSheet(BuildContext context, WidgetRef ref) async { + try { + final fileContent = await ref.read( + siteFileContentProvider( + siteId: site.id, + relativePath: file.relativePath, + ).future, + ); + + if (context.mounted) { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: false, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height, + ), + barrierColor: Theme.of(context).colorScheme.surfaceContainerLow, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow, + builder: (BuildContext context) { + return FileEditorSheet( + file: file, + site: site, + initialContent: fileContent.content, + ); + }, + ); + } + } catch (e) { + showErrorAlert(e); + } + } + @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); @@ -80,16 +166,10 @@ class FileItem extends HookConsumerWidget { onSelected: (value) async { switch (value) { case 'download': - // TODO: Implement file download - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Downloading ${file.relativePath}')), - ); + await _downloadFile(context, ref); break; case 'edit': - // TODO: Implement file editing - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Editing ${file.relativePath}')), - ); + await _showEditSheet(context, ref); break; case 'delete': final confirmed = await showDialog( @@ -136,13 +216,89 @@ class FileItem extends HookConsumerWidget { if (file.isDirectory) { onNavigateDirectory?.call(file.relativePath); } else { - // TODO: Open file preview/editor - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Opening file: ${file.relativePath}')), - ); + _showEditSheet(context, ref); } }, ), ); } } + +class FileEditorSheet extends HookConsumerWidget { + final SnSiteFileEntry file; + final SnPublicationSite site; + final String initialContent; + + const FileEditorSheet({ + super.key, + required this.file, + required this.site, + required this.initialContent, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final codeController = useMemoized( + () => CodeController( + text: initialContent, + language: null, // Let the editor auto-detect or use plain text + ), + ); + final isSaving = useState(false); + + final saveFile = useCallback(() async { + if (codeController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Content cannot be empty')), + ); + return; + } + + isSaving.value = true; + try { + await ref + .read( + siteFilesNotifierProvider((siteId: site.id, path: null)).notifier, + ) + .updateFileContent(file.relativePath, codeController.text); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('File saved successfully')), + ); + Navigator.of(context).pop(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to save file: $e'))); + } + } finally { + isSaving.value = false; + } + }, [codeController, ref, site.id, file.relativePath, context, isSaving]); + + return SheetScaffold( + heightFactor: 1, + titleText: 'Edit ${file.relativePath}', + actions: [ + FilledButton( + onPressed: isSaving.value ? null : saveFile, + child: Text(isSaving.value ? 'Saving...' : 'Save'), + ), + ], + child: SingleChildScrollView( + padding: EdgeInsets.zero, + child: CodeTheme( + data: CodeThemeData(styles: monokaiSublimeTheme), + child: CodeField( + controller: codeController, + minLines: 20, + maxLines: null, + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5266ae93..c453868e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + autotrie: + dependency: transitive + description: + name: autotrie + sha256: "55da6faefb53cfcb0abb2f2ca8636123fb40e35286bb57440d2cf467568188f8" + url: "https://pub.dev" + source: hosted + version: "2.0.0" avatar_stack: dependency: "direct main" description: @@ -766,6 +774,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.2.0" + flutter_code_editor: + dependency: "direct main" + description: + name: flutter_code_editor + sha256: "9af48ba8e3558b6ea4bb98b84c5eb1649702acf53e61a84d88383eeb79b239b0" + url: "https://pub.dev" + source: hosted + version: "0.3.5" flutter_colorpicker: dependency: "direct main" description: @@ -1253,6 +1269,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" hooks_riverpod: dependency: "direct main" description: @@ -1461,6 +1485,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + linked_scroll_controller: + dependency: transitive + description: + name: linked_scroll_controller + sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a + url: "https://pub.dev" + source: hosted + version: "0.2.0" lint: dependency: transitive description: @@ -1685,6 +1717,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + mocktail: + dependency: transitive + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" modal_bottom_sheet: dependency: "direct main" description: @@ -2246,6 +2286,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + scrollable_positioned_list: + dependency: transitive + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" sdp_transform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5ad8802a..5ac2c3c5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -170,6 +170,7 @@ dependencies: desktop_drop: ^0.7.0 flutter_animate: ^4.5.2 http_parser: ^4.1.2 + flutter_code_editor: ^0.3.5 dev_dependencies: flutter_test: