diff --git a/client/lib/app.dart b/client/lib/app.dart index 499045d7..55bc7d5c 100644 --- a/client/lib/app.dart +++ b/client/lib/app.dart @@ -3,6 +3,7 @@ import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:go_router/go_router.dart'; import 'package:offtheline/offtheline.dart'; import 'package:phylum/app_shortcuts.dart'; +import 'package:phylum/integrations/download_manager.dart'; import 'package:phylum/libphylum/phylum_account.dart'; import 'package:phylum/ui/app/app_layout.dart'; import 'package:phylum/ui/app/nav_forward.dart'; @@ -28,6 +29,7 @@ class _PhylumAppState extends State { Provider.value(value: widget.account), StateNotifierProvider.value(key: ValueKey(widget.account.id), value: widget.account.actionQueue), StateNotifierProvider.value(key: ValueKey(widget.account.id), value: historyManager), + StateNotifierProvider(create: (context) => DownloadManager(widget.account)) ], child: MaterialApp.router( key: ValueKey(widget.account), diff --git a/client/lib/integrations/directories.dart b/client/lib/integrations/directories.dart new file mode 100644 index 00000000..4d322e29 --- /dev/null +++ b/client/lib/integrations/directories.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +class PhylumDirectories { + late final Directory? tmp; + late final Directory? downloads; + + static final PhylumDirectories instance = PhylumDirectories._(); + + PhylumDirectories._(); + + Future initialize() async { + if (kIsWeb) return; + + final tmpDirectory = await getTemporaryDirectory(); + tmpDirectory.create(recursive: true); + if (!tmpDirectory.existsSync()) { + throw 'Unable to create temporary directory'; + } + tmp = tmpDirectory; + + // final downloadsDirectory = switch() { + + // } + final downloadsDirectory = await (Platform.isLinux || Platform.isWindows || Platform.isMacOS || Platform.isFuchsia + ? getDownloadsDirectory() + : Platform.isAndroid + ? getExternalStorageDirectory() + : getApplicationDocumentsDirectory()); + downloadsDirectory!.createSync(recursive: true); + if (!downloadsDirectory.existsSync()) { + throw 'Unable to create temporary directory'; + } + downloads = downloadsDirectory; + } +} diff --git a/client/lib/integrations/download_manager.dart b/client/lib/integrations/download_manager.dart new file mode 100644 index 00000000..95ade323 --- /dev/null +++ b/client/lib/integrations/download_manager.dart @@ -0,0 +1,130 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:phylum/integrations/directories.dart'; +import 'package:phylum/libphylum/db/db.dart'; +import 'package:phylum/libphylum/phylum_account.dart'; +import 'package:phylum/libphylum/requests/resource_contents_request.dart'; +import 'package:state_notifier/state_notifier.dart'; + +class DownloadManagerState { + final List tasks; + const DownloadManagerState([this.tasks = const []]); +} + +class DownloadManager extends StateNotifier { + final PhylumAccount account; + + DownloadManager(this.account) : super(const DownloadManagerState()); + + void downloadResource(Resource r) async { + final output = _createDownloadFile(PhylumDirectories.instance.downloads!, r.name); + if (output == null) { + return; + } + final task = DownloadTask(resourceId: r.id, resourceName: r.name, savePath: output.path, contentLength: r.size); + state = DownloadManagerState([...state.tasks, task]); + IOSink? sink; + sink = output.openWrite(); + final response = await account.api.sendRequestRaw(ResourceContentsRequest(r.id)); + if (response == null) return; + final length = response.contentLength ?? r.size; + int received = 0; + response.stream + .map((s) { + received += s.length; + task._status.value = DownloadStatusRunning(received, length); + return s; + }) + .pipe(sink) + .whenComplete(() => task._status.value = const DownloadStatusFinished()) + .onError((err, _) { + task._status.value = DownloadStatusError(err.toString()); + return null; + }); + + l() { + final status = task._status.value; + debugPrint(status.toString()); + if (status is DownloadStatusError || status is DownloadStatusFinished) { + task._status.removeListener(l); + } + } + + task._status.addListener(l); + } + + File? _createDownloadFile(Directory dir, String name) { + final file = File(p.join(dir.path, name)); + if (!file.existsSync()) { + file.createSync(); + return file; + } + + final basename = p.basename(name); + final ext = p.extension(name); + for (int i = 1; i < 1000; i++) { + final file = File(p.join(dir.path, '$basename ($i).$ext')); + if (!file.existsSync()) { + file.createSync(); + return file; + } + } + + return null; + } +} + +class DownloadTask { + final String resourceId; + final String resourceName; + final String savePath; + final int contentLength; + final ValueNotifier _status = ValueNotifier(const DownloadStatusEnqueued()); + ValueListenable get status => _status; + + DownloadTask({ + required this.resourceId, + required this.resourceName, + required this.savePath, + required this.contentLength, + }); +} + +sealed class DownloadStatus { + const DownloadStatus(); +} + +class DownloadStatusEnqueued extends DownloadStatus { + const DownloadStatusEnqueued(); + + @override + String toString() => "Download Enqueued"; +} + +class DownloadStatusRunning extends DownloadStatus { + final int received; + final int total; + + const DownloadStatusRunning(this.received, this.total); + + @override + String toString() => "Download Running: $received / $total (${received / total * 100} %)"; +} + +class DownloadStatusFinished extends DownloadStatus { + const DownloadStatusFinished(); + + @override + String toString() => "Download Finished"; +} + +class DownloadStatusError extends DownloadStatus { + final String error; + + const DownloadStatusError(this.error); + + @override + String toString() => "Download Error: $error"; +} diff --git a/client/lib/main.dart b/client/lib/main.dart index 21a04929..21912d4f 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -10,6 +10,7 @@ import 'package:offtheline/offtheline.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:phylum/app.dart'; +import 'package:phylum/integrations/directories.dart'; import 'package:phylum/libphylum/actions/deserializers.dart'; import 'package:phylum/libphylum/db/db.dart'; import 'package:phylum/libphylum/phylum_account.dart'; @@ -21,6 +22,7 @@ const storageDir = String.fromEnvironment("STORAGE_DIR"); void main() async { WidgetsFlutterBinding.ensureInitialized(); + PhylumDirectories.instance.initialize(); OTL.logger = Logger(level: Level.info); GoRouter.optionURLReflectsImperativeAPIs = true; diff --git a/client/lib/ui/preview/resource_preview.dart b/client/lib/ui/preview/resource_preview.dart index 485827f1..ca727fd4 100644 --- a/client/lib/ui/preview/resource_preview.dart +++ b/client/lib/ui/preview/resource_preview.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:phylum/integrations/download_manager.dart'; import 'package:phylum/libphylum/db/db.dart'; import 'package:phylum/libphylum/phylum_account.dart'; import 'package:provider/provider.dart'; @@ -33,7 +34,7 @@ class _ResourcePreviewState extends State { @override Widget build(BuildContext context) { - final resource = this.resource; + final r = resource; return Theme( data: ThemeData.dark(), child: Scaffold( @@ -42,21 +43,27 @@ class _ResourcePreviewState extends State { leading: CloseButton( onPressed: () => Navigator.of(context).pop(), ), - actions: [IconButton(onPressed: () {}, icon: const Icon(Icons.download))], - title: (resource != null) ? Text(resource.name) : null, + actions: [IconButton(onPressed: (r == null || r.dir) ? null : () => downloadResource(r), icon: const Icon(Icons.download))], + title: (r != null) ? Text(r.name) : null, backgroundColor: Colors.transparent, ), body: Center( child: loading ? const CircularProgressIndicator() - : resource == null + : r == null ? const Text('Error loading details', style: TextStyle(fontSize: 18)) - : buildPreview(resource)), + : buildPreview(r)), ), ); } Widget buildPreview(Resource r) { + if (r.dir) { + return const Text( + 'Cannot preview directory', + style: TextStyle(fontSize: 18), + ); + } if (r.size > maxPreviewSize) { return const Text( 'File too large to preview', @@ -79,4 +86,8 @@ class _ResourcePreviewState extends State { ), ); } + + void downloadResource(Resource r) async { + context.read().downloadResource(r); + } } diff --git a/client/pubspec.lock b/client/pubspec.lock index d4092035..e921a2eb 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -576,12 +576,18 @@ packages: offtheline: dependency: "direct main" description: - path: "." - ref: "7e4457c8e7f86662cd6d8424383423ceec7c06bb" - resolved-ref: "7e4457c8e7f86662cd6d8424383423ceec7c06bb" - url: "https://codeberg.org/shroff/offtheline.git" - source: git + path: "../../offtheline" + relative: true + source: path version: "0.13.0" + open_file: + dependency: "direct main" + description: + name: open_file + sha256: a5a32d44acb7c899987d0999e1e3cbb0a0f1adebbf41ac813ec6d2d8faa0af20 + url: "https://pub.dev" + source: hosted + version: "3.3.2" package_config: dependency: transitive description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 909831b6..4558a762 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -19,9 +19,8 @@ dependencies: logger: mime: offtheline: - git: - url: https://codeberg.org/shroff/offtheline.git - ref: 7e4457c8e7f86662cd6d8424383423ceec7c06bb + path: ../../offtheline + open_file: path: path_provider: provider: