diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 55633f45..c2cd2f5b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -898,5 +898,9 @@ "type": "String" } } - } + }, + "invalidFile": "Invalid file", + "downloadFeed": "Download feed", + "asJSON": "As JSON", + "asOPML": "As OPML" } diff --git a/lib/src/screens/settings/feed_settings_screen.dart b/lib/src/screens/settings/feed_settings_screen.dart index 2e2261e4..589462ec 100644 --- a/lib/src/screens/settings/feed_settings_screen.dart +++ b/lib/src/screens/settings/feed_settings_screen.dart @@ -1,5 +1,10 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:auto_route/auto_route.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/feed.dart'; @@ -14,6 +19,8 @@ import 'package:interstellar/src/screens/explore/explore_screen.dart'; import 'package:interstellar/src/screens/feed/feed_agregator.dart'; import 'package:interstellar/src/screens/feed/feed_screen.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; +import 'package:interstellar/src/utils/globals.dart'; +import 'package:interstellar/src/utils/share.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/context_menu.dart'; import 'package:interstellar/src/widgets/list_tile_switch.dart'; @@ -119,6 +126,70 @@ class _FeedSettingsScreenState extends State { ), icon: const Icon(Symbols.delete_rounded), ), + LoadingIconButton( + onPressed: () async => ContextMenu( + title: l(context).downloadFeed, + items: [ + ContextMenuItem( + title: l(context).asJSON, + onTap: () async { + final feed = ac.feeds[entry.key]!; + + final config = await ConfigShare.create( + type: ConfigShareType.feed, + name: entry.key, + payload: feed.toJson(), + ); + + final file = XFile.fromData( + Uint8List.fromList( + jsonEncode(config.toJson()).codeUnits, + ), + mimeType: 'application/json', + ); + + if (!context.mounted) return; + await downloadFile( + context, + file, + '${entry.key}.json', + defaultDir: ac.defaultDownloadDir, + ); + if (!context.mounted) return; + context.router.pop(); + }, + ), + ContextMenuItem( + title: l(context).asOPML, + onTap: () async { + final feed = ac.feeds[entry.key]!; + + final opml = convertFeedToOPML( + context, + entry.key, + feed, + ); + + final file = XFile.fromData( + Uint8List.fromList(opml.codeUnits), + mimeType: 'application/xml', + ); + + if (!context.mounted) return; + await downloadFile( + context, + file, + '${entry.key}.xml', + defaultDir: ac.defaultDownloadDir, + ); + if (!context.mounted) return; + context.router.pop(); + }, + ), + ], + ).openMenu(context), + icon: const Icon(Symbols.download_rounded), + ), IconButton( onPressed: () async { final feed = ac.feeds[entry.key]!; @@ -165,6 +236,41 @@ class _FeedSettingsScreenState extends State { leading: const Icon(Symbols.add_rounded), title: Text(l(context).feeds_new), onTap: () => newFeed(context), + trailing: LoadingTextButton( + onPressed: () async { + XFile? file; + try { + final result = await FilePicker.platform.pickFiles(); + file = result?.files.single.xFile; + } catch (e) { + // + } + if (file == null) return; + + final json = jsonDecode(await file.readAsString()); + + final config = ConfigShare.fromJson(json); + + if (config.type != ConfigShareType.feed) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).invalidFile), + showCloseIcon: true, + ), + ); + return; + } + + final feed = Feed.fromJson({...config.payload}); + + if (!context.mounted) return; + await context.router.push( + EditFeedRoute(feed: config.name, feedData: feed), + ); + }, + label: Text(l(context).feeds_import), + ), ), ], ), diff --git a/lib/src/screens/settings/filter_lists_screen.dart b/lib/src/screens/settings/filter_lists_screen.dart index 45801101..a89de29b 100644 --- a/lib/src/screens/settings/filter_lists_screen.dart +++ b/lib/src/screens/settings/filter_lists_screen.dart @@ -1,10 +1,17 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:auto_route/auto_route.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/filter_list.dart'; import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/models/config_share.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; +import 'package:interstellar/src/utils/globals.dart'; +import 'package:interstellar/src/utils/share.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/list_tile_select.dart'; import 'package:interstellar/src/widgets/list_tile_switch.dart'; @@ -37,58 +44,82 @@ class _FilterListsScreenState extends State { body: ListView( children: [ ...ac.filterLists.keys.map( - (name) => Row( - children: [ - Expanded( - child: ListTile( - title: Text(name), - onTap: () => context.router.push( - EditFilterListRoute(filterList: name), - ), - trailing: IconButton( - onPressed: () async { - final filterList = context - .read() - .filterLists[name]!; - - final config = await ConfigShare.create( - type: ConfigShareType.filterList, - name: name, - payload: filterList.toJson(), - ); - - if (!context.mounted) return; - var communityName = mbinConfigsCommunityName; - if (communityName.endsWith( - context.read().instanceHost, - )) { - communityName = communityName.split('@').first; - } - - final community = await context - .read() - .api - .community - .getByName(communityName); - - if (!context.mounted) return; - - await context.router.push( - CreateRoute( - initTitle: '[Filter List] $name', - initBody: - 'Short description here...\n\n${config.toMarkdown()}', - initCommunity: community, - ), - ); - }, - icon: const Icon(Symbols.share_rounded), - ), + (name) => ListTile( + title: Text(name), + onTap: () => + context.router.push(EditFilterListRoute(filterList: name)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + LoadingIconButton( + onPressed: () async { + final filterList = context + .read() + .filterLists[name]!; + + final config = await ConfigShare.create( + type: ConfigShareType.filterList, + name: name, + payload: filterList.toJson(), + ); + + final file = XFile.fromData( + Uint8List.fromList( + jsonEncode(config.toJson()).codeUnits, + ), + mimeType: 'application/json', + ); + + if (!context.mounted) return; + await downloadFile( + context, + file, + '$name.json', + defaultDir: ac.defaultDownloadDir, + ); + }, + icon: const Icon(Symbols.download_rounded), ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Switch( + IconButton( + onPressed: () async { + final filterList = context + .read() + .filterLists[name]!; + + final config = await ConfigShare.create( + type: ConfigShareType.filterList, + name: name, + payload: filterList.toJson(), + ); + + if (!context.mounted) return; + var communityName = mbinConfigsCommunityName; + if (communityName.endsWith( + context.read().instanceHost, + )) { + communityName = communityName.split('@').first; + } + + final community = await context + .read() + .api + .community + .getByName(communityName); + + if (!context.mounted) return; + + await context.router.push( + CreateRoute( + initTitle: '[Filter List] $name', + initBody: + 'Short description here...\n\n${config.toMarkdown()}', + initCommunity: community, + ), + ); + }, + icon: const Icon(Symbols.share_rounded), + ), + Switch( value: ac.profile.filterLists[name] ?? false, onChanged: (value) { ac.updateProfile( @@ -101,8 +132,8 @@ class _FilterListsScreenState extends State { ); }, ), - ), - ], + ], + ), ), ), ListTile( @@ -110,6 +141,47 @@ class _FilterListsScreenState extends State { title: Text(l(context).filterList_new), onTap: () => context.router.push(EditFilterListRoute(filterList: null)), + trailing: LoadingTextButton( + onPressed: () async { + XFile? file; + try { + final result = await FilePicker.platform.pickFiles(); + file = result?.files.single.xFile; + } catch (e) { + // + } + if (file == null) return; + + final json = jsonDecode(await file.readAsString()); + + final config = ConfigShare.fromJson(json); + + if (config.type != ConfigShareType.filterList) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).invalidFile), + showCloseIcon: true, + ), + ); + return; + } + + final filterList = FilterList.fromJson({ + ...config.payload, + 'name': config.name, + }); + + if (!context.mounted) return; + await context.router.push( + EditFilterListRoute( + filterList: config.name, + importFilterList: filterList, + ), + ); + }, + label: Text(l(context).filterList_import), + ), ), ], ), diff --git a/lib/src/screens/settings/profile_selection.dart b/lib/src/screens/settings/profile_selection.dart index bb3ad7e5..326f2519 100644 --- a/lib/src/screens/settings/profile_selection.dart +++ b/lib/src/screens/settings/profile_selection.dart @@ -1,11 +1,18 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:auto_route/auto_route.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:interstellar/src/controller/controller.dart'; import 'package:interstellar/src/controller/profile.dart'; import 'package:interstellar/src/controller/router.gr.dart'; import 'package:interstellar/src/models/config_share.dart'; import 'package:interstellar/src/screens/settings/about_screen.dart'; import 'package:interstellar/src/screens/settings/account_selection.dart'; +import 'package:interstellar/src/utils/globals.dart'; +import 'package:interstellar/src/utils/share.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/list_tile_switch.dart'; import 'package:interstellar/src/widgets/loading_button.dart'; @@ -126,6 +133,36 @@ class _ProfileSelectWidgetState extends State<_ProfileSelectWidget> { }, icon: const Icon(Symbols.edit_rounded), ), + LoadingIconButton( + onPressed: () async { + final profile = + (await context.read().getProfile( + profileName, + )).exportReady(); + + final config = await ConfigShare.create( + type: ConfigShareType.profile, + name: profileName, + payload: profile.toJson(), + ); + + final file = XFile.fromData( + Uint8List.fromList( + jsonEncode(config.toJson()).codeUnits, + ), + mimeType: 'application/json', + ); + + if (!context.mounted) return; + await downloadFile( + context, + file, + '$profileName.json', + defaultDir: ac.defaultDownloadDir, + ); + }, + icon: const Icon(Symbols.download_rounded), + ), IconButton( onPressed: () async { final profile = @@ -182,6 +219,50 @@ class _ProfileSelectWidgetState extends State<_ProfileSelectWidget> { ); getProfiles(); }, + trailing: LoadingTextButton( + onPressed: () async { + XFile? file; + try { + final result = await FilePicker.platform.pickFiles(); + file = result?.files.single.xFile; + } catch (e) { + // + } + if (file == null) return; + + final json = jsonDecode(await file.readAsString()); + + final config = ConfigShare.fromJson(json); + + if (config.type != ConfigShareType.profile) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).invalidFile), + showCloseIcon: true, + ), + ); + context.router.pop(); + return; + } + + final profile = ProfileOptional.fromJson({ + ...config.payload, + 'name': config.name, + }); + + if (!context.mounted) return; + await context.router.push( + EditProfileRoute( + profile: config.name, + profileList: profileList!, + importProfile: profile, + ), + ); + getProfiles(); + }, + label: Text(l(context).profile_import), + ), ), const SizedBox(height: 16), ], diff --git a/lib/src/utils/platform/platform_native.dart b/lib/src/utils/platform/platform_native.dart index a1ef816b..119ed251 100644 --- a/lib/src/utils/platform/platform_native.dart +++ b/lib/src/utils/platform/platform_native.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; -import 'package:http/http.dart' as http; import 'package:interstellar/src/utils/utils.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:sqlite3/sqlite3.dart'; // get sqlite on native platforms. @@ -11,13 +11,11 @@ Future getSqlite() async { return sqlite3; } -Future downloadFromUri( - Uri uri, +Future downloadFromFile( + XFile file, String filename, { Directory? defaultDir, }) async { - final response = await http.get(uri); - // Whether to use bytes property or need to manually write file final useBytes = PlatformIs.mobile; String? filePath; @@ -25,12 +23,11 @@ Future downloadFromUri( try { filePath = await FilePicker.platform.saveFile( fileName: filename, - bytes: useBytes ? response.bodyBytes : null, + bytes: useBytes ? await file.readAsBytes() : null, ); - if (filePath == null) return; + if (filePath == null) return false; } catch (e) { - // If file saver fails, then try to download to downloads directory final dir = await getDownloadsDirectory(); if (dir == null) throw Exception('Downloads directory not found'); @@ -41,7 +38,9 @@ Future downloadFromUri( } if (!useBytes || defaultDir != null) { - final file = File(filePath); - await file.writeAsBytes(response.bodyBytes); + final outFile = File(filePath); + await outFile.writeAsBytes(await file.readAsBytes()); } + + return true; } diff --git a/lib/src/utils/platform/platform_none.dart b/lib/src/utils/platform/platform_none.dart index 52ae9ee2..5e7898f8 100644 --- a/lib/src/utils/platform/platform_none.dart +++ b/lib/src/utils/platform/platform_none.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:share_plus/share_plus.dart'; import 'package:sqlite3/common.dart'; // Stub to handle sqlite on different platforms. @@ -7,8 +8,8 @@ Future getSqlite() async { throw UnsupportedError('Unknown pipeline'); } -Future downloadFromUri( - Uri uri, +Future downloadFromFile( + XFile file, String filename, { Directory? defaultDir, }) async { diff --git a/lib/src/utils/platform/platform_web.dart b/lib/src/utils/platform/platform_web.dart index 33492d28..9875d629 100644 --- a/lib/src/utils/platform/platform_web.dart +++ b/lib/src/utils/platform/platform_web.dart @@ -1,8 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:http/http.dart' as http; -import 'package:mime/mime.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:sqlite3/wasm.dart'; import 'package:web/web.dart' as web; @@ -14,19 +13,19 @@ Future getSqlite() async { return sqlite; } -Future downloadFromUri( - Uri uri, +Future downloadFromFile( + XFile file, String filename, { Directory? defaultDir, }) async { - final response = await http.get(uri); - - final data = base64Encode(response.bodyBytes); - final mimeType = lookupMimeType(uri.toString()); + final data = base64Encode(await file.readAsBytes()); + final mimeType = file.mimeType; final a = web.HTMLAnchorElement() ..href = 'data:$mimeType;base64,$data' - ..download = 'image.${uri.pathSegments.last}' + ..download = filename ..click() ..remove(); + + return true; } diff --git a/lib/src/utils/share.dart b/lib/src/utils/share.dart index 9f2e71be..0304b81a 100644 --- a/lib/src/utils/share.dart +++ b/lib/src/utils/share.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:interstellar/src/utils/globals.dart'; import 'package:interstellar/src/utils/platform/platform.dart'; import 'package:interstellar/src/utils/utils.dart'; +import 'package:mime/mime.dart'; import 'package:path/path.dart'; import 'package:share_plus/share_plus.dart'; @@ -31,18 +32,41 @@ Future shareFile(Uri uri, String filename) async { return result; } -Future downloadFile( +Future downloadUri( BuildContext context, Uri uri, String filename, { Directory? defaultDir, }) async { - await downloadFromUri(uri, filename, defaultDir: defaultDir); - if (!context.mounted) return; - scaffoldMessengerKey.currentState?.showSnackBar( - SnackBar( - content: Text(l(context).downloaded_file(filename)), - showCloseIcon: true, - ), - ); + final response = await http.get(uri); + + final mimeType = lookupMimeType(uri.toString()); + final file = XFile.fromData(response.bodyBytes, mimeType: mimeType); + + if (await downloadFromFile(file, filename, defaultDir: defaultDir)) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).downloaded_file(filename)), + showCloseIcon: true, + ), + ); + } +} + +Future downloadFile( + BuildContext context, + XFile file, + String filename, { + Directory? defaultDir, +}) async { + if (await downloadFromFile(file, filename, defaultDir: defaultDir)) { + if (!context.mounted) return; + scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text(l(context).downloaded_file(filename)), + showCloseIcon: true, + ), + ); + } } diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index c2325d81..5ef9937c 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -8,7 +8,10 @@ import 'package:http/http.dart' as http; import 'package:interstellar/l10n/app_localizations.dart'; import 'package:interstellar/src/api/feed_source.dart'; import 'package:interstellar/src/controller/controller.dart'; +import 'package:interstellar/src/controller/feed.dart'; import 'package:interstellar/src/controller/server.dart'; +import 'package:interstellar/src/models/config_share.dart'; +import 'package:interstellar/src/utils/ap_urls.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -330,3 +333,56 @@ Future cacheRemoteFile(String url) async { return file; } + +Uri? getRssUrl(AppController ac, String name, FeedSource source) { + return switch (ac.serverSoftware) { + ServerSoftware.mbin => Uri.https( + ac.instanceHost, + 'rss?${source == FeedSource.community ? 'magazine' : 'user'}=$name&content=combined', + ), + ServerSoftware.piefed => + source == FeedSource.user + ? null + : Uri.https(ac.instanceHost, 'community/$name/feed'), + ServerSoftware.lemmy => Uri.https( + ac.instanceHost, + 'feeds/${source == FeedSource.community ? 'c' : 'u'}/$name.xml', + ), + }; +} + +String convertFeedToOPML(BuildContext context, String name, Feed feed) { + final ac = context.read(); + var output = ''; + output += ''; + output += + '\n$name\n\n\n'; + + for (final input in feed.inputs) { + if (input.sourceType != FeedSource.community && + input.sourceType != FeedSource.user) { + continue; + } + + final rss = getRssUrl(ac, input.name, input.sourceType); + if (rss == null) continue; + final apUrl = switch (input.sourceType) { + FeedSource.community => Uri.https( + ac.instanceHost, + ac.serverSoftware == ServerSoftware.mbin + ? '/m/${input.name}' + : '/c/${input.name}', + ), + FeedSource.user => Uri.https( + ac.instanceHost, + '/u/${ac.serverSoftware == ServerSoftware.mbin && getNameHost(context, input.name) != ac.instanceHost ? '@' : ''}${input.name}', + ), + _ => null, + }; + output += + '\n'; + } + + output += '\n\n'; + return output; +} diff --git a/lib/src/widgets/image.dart b/lib/src/widgets/image.dart index c4192a7c..34937ed1 100644 --- a/lib/src/widgets/image.dart +++ b/lib/src/widgets/image.dart @@ -161,7 +161,7 @@ class _AdvancedImagePageState extends State { actions: [ LoadingIconButton( onPressed: () async { - await downloadFile( + await downloadUri( context, Uri.parse(widget.image.src), widget.image.src.split('/').last, @@ -169,7 +169,7 @@ class _AdvancedImagePageState extends State { ); }, onLongPress: () async { - await downloadFile( + await downloadUri( context, Uri.parse(widget.image.src), widget.image.src.split('/').last,