mirror of
https://github.com/localsend/localsend.git
synced 2026-06-23 04:10:07 +00:00
feat: use native media picker on iOS and Android
This commit is contained in:
+7
-6
@@ -17,6 +17,7 @@ import 'package:localsend_app/provider/window_dimensions_provider.dart';
|
||||
import 'package:localsend_app/theme.dart';
|
||||
import 'package:localsend_app/util/api_route_builder.dart';
|
||||
import 'package:localsend_app/util/native/cache_helper.dart';
|
||||
import 'package:localsend_app/util/native/cross_file_converters.dart';
|
||||
import 'package:localsend_app/util/native/platform_check.dart';
|
||||
import 'package:localsend_app/util/native/tray_helper.dart';
|
||||
import 'package:localsend_app/util/ui/snackbar.dart';
|
||||
@@ -168,17 +169,17 @@ Future<void> postInit(BuildContext context, Ref ref, bool appStart, void Functio
|
||||
if (appStart && !hasInitialShare && (checkPlatformWithGallery() || checkPlatformCanReceiveShareIntent())) {
|
||||
// Clear cache on every app start.
|
||||
// If we received a share intent, then don't clear it, otherwise the shared file will be lost.
|
||||
clearCache();
|
||||
ref.dispatch(ClearCacheAction());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSharedIntent(SharedMedia payload, Ref ref) async {
|
||||
final message = payload.content;
|
||||
if (message != null && message.trim().isNotEmpty) {
|
||||
ref.notifier(selectedSendingFilesProvider).addMessage(message);
|
||||
ref.redux(selectedSendingFilesProvider).dispatch(AddMessageAction(message: message));
|
||||
}
|
||||
await ref.notifier(selectedSendingFilesProvider).addFiles(
|
||||
files: payload.attachments?.where((a) => a != null).cast<SharedAttachment>() ?? <SharedAttachment>[],
|
||||
converter: CrossFileConverters.convertSharedAttachment,
|
||||
);
|
||||
await ref.redux(selectedSendingFilesProvider).dispatchAsync(AddFilesAction(
|
||||
files: payload.attachments?.where((a) => a != null).cast<SharedAttachment>() ?? <SharedAttachment>[],
|
||||
converter: CrossFileConverters.convertSharedAttachment,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:localsend_app/model/file_type.dart';
|
||||
import 'package:localsend_app/provider/apk_provider.dart';
|
||||
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
|
||||
import 'package:localsend_app/util/file_size_helper.dart';
|
||||
import 'package:localsend_app/util/native/cross_file_converters.dart';
|
||||
import 'package:localsend_app/util/ui/nav_bar_padding.dart';
|
||||
import 'package:localsend_app/widget/file_thumbnail.dart';
|
||||
import 'package:localsend_app/widget/responsive_list_view.dart';
|
||||
@@ -19,10 +20,15 @@ class ApkPickerPage extends StatefulWidget {
|
||||
State<ApkPickerPage> createState() => _ApkPickerPageState();
|
||||
}
|
||||
|
||||
class _ApkPickerPageState extends State<ApkPickerPage> {
|
||||
class _ApkPickerPageState extends State<ApkPickerPage> with Refena {
|
||||
void _pickApp(Application app) {
|
||||
// ignore: discarded_futures
|
||||
ref.redux(selectedSendingFilesProvider).dispatchAsync(AddFilesAction(files: [app], converter: CrossFileConverters.convertApplication));
|
||||
context.pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ref = context.ref;
|
||||
final apkParams = ref.watch(apkSearchParamProvider);
|
||||
final apkAsync = ref.watch(apkProvider);
|
||||
|
||||
@@ -101,11 +107,7 @@ class _ApkPickerPageState extends State<ApkPickerPage> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// ignore: discarded_futures
|
||||
ref.notifier(selectedSendingFilesProvider).addFiles(files: [app], converter: CrossFileConverters.convertApplication);
|
||||
context.pop();
|
||||
},
|
||||
onTap: () => _pickApp(app),
|
||||
customBorder: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:localsend_app/pages/tabs/settings_tab.dart';
|
||||
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
|
||||
import 'package:localsend_app/provider/ui/home_tab_provider.dart';
|
||||
import 'package:localsend_app/theme.dart';
|
||||
import 'package:localsend_app/util/native/cross_file_converters.dart';
|
||||
import 'package:localsend_app/widget/responsive_builder.dart';
|
||||
import 'package:refena_flutter/refena_flutter.dart';
|
||||
|
||||
@@ -96,13 +97,13 @@ class _HomePageState extends State<HomePage> with Refena {
|
||||
onDragDone: (event) async {
|
||||
if (event.files.length == 1 && Directory(event.files.first.path).existsSync()) {
|
||||
// user dropped a directory
|
||||
await ref.notifier(selectedSendingFilesProvider).addDirectory(event.files.first.path);
|
||||
await ref.redux(selectedSendingFilesProvider).dispatchAsync(AddDirectoryAction(event.files.first.path));
|
||||
} else {
|
||||
// user dropped one or more files
|
||||
await ref.notifier(selectedSendingFilesProvider).addFiles(
|
||||
await ref.redux(selectedSendingFilesProvider).dispatchAsync(AddFilesAction(
|
||||
files: event.files,
|
||||
converter: CrossFileConverters.convertXFile,
|
||||
);
|
||||
));
|
||||
}
|
||||
_goToPage(HomeTab.send.index);
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ class SelectedFilesPage extends StatelessWidget {
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.notifier(selectedSendingFilesProvider).reset();
|
||||
ref.redux(selectedSendingFilesProvider).dispatch(ClearSelectionAction());
|
||||
context.popUntilRoot();
|
||||
},
|
||||
child: Text(t.selectedFilesPage.deleteAll),
|
||||
@@ -110,8 +110,7 @@ class SelectedFilesPage extends StatelessWidget {
|
||||
final result =
|
||||
await showDialog<String>(context: context, builder: (_) => MessageInputDialog(initialText: message));
|
||||
if (result != null) {
|
||||
ref.notifier(selectedSendingFilesProvider).removeAt(index);
|
||||
ref.notifier(selectedSendingFilesProvider).addMessage(result, index: index);
|
||||
ref.redux(selectedSendingFilesProvider).dispatch(UpdateMessageAction(message: result, index: index));
|
||||
}
|
||||
},
|
||||
child: const Icon(Icons.edit),
|
||||
@@ -122,7 +121,7 @@ class SelectedFilesPage extends StatelessWidget {
|
||||
),
|
||||
onPressed: () {
|
||||
final currCount = ref.read(selectedSendingFilesProvider).length;
|
||||
ref.notifier(selectedSendingFilesProvider).removeAt(index);
|
||||
ref.redux(selectedSendingFilesProvider).dispatch(RemoveSelectedFileAction(index));
|
||||
if (currCount == 1) {
|
||||
context.popUntilRoot();
|
||||
}
|
||||
|
||||
@@ -103,10 +103,10 @@ class _SendTabState extends State<SendTab> with Refena {
|
||||
icon: option.icon,
|
||||
label: option.label,
|
||||
filled: false,
|
||||
onTap: () async => option.select(
|
||||
onTap: () async => ref.dispatchAsync(PickAction(
|
||||
option: option,
|
||||
context: context,
|
||||
ref: ref,
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -164,7 +164,11 @@ class _SendTabState extends State<SendTab> with Refena {
|
||||
),
|
||||
onPressed: () async {
|
||||
if (options.length == 1) {
|
||||
await options.first.select(context: context, ref: ref); // open directly
|
||||
// open directly
|
||||
await ref.dispatchAsync(PickAction(
|
||||
option: options.first,
|
||||
context: context,
|
||||
));
|
||||
return;
|
||||
}
|
||||
await AddFileDialog.open(
|
||||
|
||||
@@ -396,7 +396,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
||||
state = state.removeSession(ref, sessionId);
|
||||
if (sessionState.status == SessionStatus.finished && ref.read(settingsProvider).sendMode == SendMode.single) {
|
||||
// clear selected files
|
||||
ref.notifier(selectedSendingFilesProvider).reset();
|
||||
ref.redux(selectedSendingFilesProvider).dispatch(ClearSelectionAction());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import 'dart:convert' show utf8;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_apps/device_apps.dart';
|
||||
import 'package:file_picker/file_picker.dart' as file_picker;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:localsend_app/model/cross_file.dart';
|
||||
import 'package:localsend_app/model/file_type.dart';
|
||||
import 'package:localsend_app/util/file_path_helper.dart';
|
||||
@@ -12,30 +8,33 @@ import 'package:localsend_app/util/native/cache_helper.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:refena_flutter/refena_flutter.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
final _logger = Logger('SelectedSendingFiles');
|
||||
const _uuid = Uuid();
|
||||
|
||||
/// Manages files selected for sending.
|
||||
/// Will stay alive even after a session has been completed to send the same files to another device.
|
||||
final selectedSendingFilesProvider = NotifierProvider<SelectedSendingFilesNotifier, List<CrossFile>>((ref) {
|
||||
final selectedSendingFilesProvider = ReduxProvider<SelectedSendingFilesNotifier, List<CrossFile>>((ref) {
|
||||
return SelectedSendingFilesNotifier();
|
||||
});
|
||||
|
||||
class SelectedSendingFilesNotifier extends Notifier<List<CrossFile>> {
|
||||
SelectedSendingFilesNotifier();
|
||||
class SelectedSendingFilesNotifier extends ReduxNotifier<List<CrossFile>> {
|
||||
@override
|
||||
List<CrossFile> init() => [];
|
||||
}
|
||||
|
||||
class AddMessageAction extends ReduxAction<SelectedSendingFilesNotifier, List<CrossFile>> {
|
||||
final String message;
|
||||
final int? index;
|
||||
|
||||
AddMessageAction({
|
||||
required this.message,
|
||||
this.index,
|
||||
});
|
||||
|
||||
@override
|
||||
List<CrossFile> init() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Add a simple message
|
||||
/// Internally, the message will be stored into [CrossFile.bytes] as UTF-8
|
||||
void addMessage(String message, {int? index}) {
|
||||
List<CrossFile> reduce() {
|
||||
final List<int> bytes = utf8.encode(message);
|
||||
final file = CrossFile(
|
||||
name: '${_uuid.v4()}.txt',
|
||||
@@ -46,30 +45,62 @@ class SelectedSendingFilesNotifier extends Notifier<List<CrossFile>> {
|
||||
path: null,
|
||||
bytes: bytes,
|
||||
);
|
||||
state = List.unmodifiable([
|
||||
|
||||
return List.unmodifiable([
|
||||
...state,
|
||||
]..insert(index ?? state.length, file));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addFiles<T>({
|
||||
required Iterable<T> files,
|
||||
required Future<CrossFile> Function(T) converter,
|
||||
}) async {
|
||||
class UpdateMessageAction extends ReduxAction<SelectedSendingFilesNotifier, List<CrossFile>> {
|
||||
final String message;
|
||||
final int index;
|
||||
|
||||
UpdateMessageAction({
|
||||
required this.message,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
@override
|
||||
List<CrossFile> reduce() {
|
||||
dispatch(RemoveSelectedFileAction(index));
|
||||
dispatch(AddMessageAction(message: message, index: index));
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
class AddFilesAction<T> extends AsyncReduxAction<SelectedSendingFilesNotifier, List<CrossFile>> {
|
||||
final Iterable<T> files;
|
||||
final Future<CrossFile> Function(T) converter;
|
||||
|
||||
AddFilesAction({
|
||||
required this.files,
|
||||
required this.converter,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<CrossFile>> reduce() async {
|
||||
final newFiles = <CrossFile>[];
|
||||
for (final file in files) {
|
||||
// we do it sequential because there are bugs
|
||||
// https://github.com/fluttercandies/flutter_photo_manager/issues/589
|
||||
newFiles.add(await converter(file));
|
||||
}
|
||||
state = List.unmodifiable([
|
||||
return List.unmodifiable([
|
||||
...state,
|
||||
...newFiles,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds files inside the directory recursively.
|
||||
/// If [includeDirectory] is true, the directory itself will be added as part of each file path.
|
||||
Future<void> addDirectory(String directoryPath) async {
|
||||
/// Adds files inside the directory recursively.
|
||||
class AddDirectoryAction extends AsyncReduxAction<SelectedSendingFilesNotifier, List<CrossFile>> {
|
||||
final String directoryPath;
|
||||
|
||||
AddDirectoryAction(this.directoryPath);
|
||||
|
||||
@override
|
||||
Future<List<CrossFile>> reduce() async {
|
||||
_logger.info('Reading files in $directoryPath');
|
||||
final newFiles = <CrossFile>[];
|
||||
final directoryName = p.basename(directoryPath);
|
||||
@@ -89,88 +120,40 @@ class SelectedSendingFilesNotifier extends Notifier<List<CrossFile>> {
|
||||
newFiles.add(file);
|
||||
}
|
||||
}
|
||||
state = List.unmodifiable([
|
||||
|
||||
return List.unmodifiable([
|
||||
...state,
|
||||
...newFiles,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
void removeAt(int index) {
|
||||
state = List.unmodifiable([...state]..removeAt(index));
|
||||
class RemoveSelectedFileAction extends ReduxAction<SelectedSendingFilesNotifier, List<CrossFile>> with GlobalActions {
|
||||
final int index;
|
||||
|
||||
RemoveSelectedFileAction(this.index);
|
||||
|
||||
@override
|
||||
List<CrossFile> reduce() {
|
||||
return List<CrossFile>.unmodifiable([...state]..removeAt(index));
|
||||
}
|
||||
|
||||
@override
|
||||
void after() {
|
||||
if (state.isEmpty) {
|
||||
clearCache();
|
||||
global.dispatch(ClearCacheAction());
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = List.empty(growable: false);
|
||||
clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// Utility functions to convert third party models to common [CrossFile] model.
|
||||
class CrossFileConverters {
|
||||
static Future<CrossFile> convertPlatformFile(file_picker.PlatformFile file) async {
|
||||
return CrossFile(
|
||||
name: file.name,
|
||||
fileType: file.name.guessFileType(),
|
||||
size: file.size,
|
||||
thumbnail: null,
|
||||
asset: null,
|
||||
path: kIsWeb ? null : file.path,
|
||||
bytes: kIsWeb ? file.bytes! : null,
|
||||
);
|
||||
class ClearSelectionAction extends ReduxAction<SelectedSendingFilesNotifier, List<CrossFile>> with GlobalActions {
|
||||
@override
|
||||
List<CrossFile> reduce() {
|
||||
return List.empty(growable: false);
|
||||
}
|
||||
|
||||
static Future<CrossFile> convertAssetEntity(AssetEntity asset) async {
|
||||
final file = (await asset.originFile)!;
|
||||
return CrossFile(
|
||||
name: await asset.titleAsync,
|
||||
fileType: asset.type == AssetType.video ? FileType.video : FileType.image,
|
||||
size: await file.length(),
|
||||
thumbnail: null,
|
||||
asset: asset,
|
||||
path: file.path,
|
||||
bytes: null,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CrossFile> convertXFile(XFile file) async {
|
||||
return CrossFile(
|
||||
name: file.name,
|
||||
fileType: file.name.guessFileType(),
|
||||
size: await file.length(),
|
||||
thumbnail: null,
|
||||
asset: null,
|
||||
path: kIsWeb ? null : file.path,
|
||||
bytes: kIsWeb ? await file.readAsBytes() : null, // we can fetch it now because in Web it is already there
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CrossFile> convertSharedAttachment(SharedAttachment attachment) async {
|
||||
final file = File(attachment.path);
|
||||
final fileName = attachment.path.fileName;
|
||||
return CrossFile(
|
||||
name: fileName,
|
||||
fileType: fileName.guessFileType(),
|
||||
size: await file.length(),
|
||||
thumbnail: null,
|
||||
asset: null,
|
||||
path: file.path,
|
||||
bytes: null,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CrossFile> convertApplication(Application app) async {
|
||||
final file = File(app.apkFilePath);
|
||||
return CrossFile(
|
||||
name: '${app.appName.trim()} - v${app.versionName}.apk',
|
||||
fileType: FileType.apk,
|
||||
thumbnail: app is ApplicationWithIcon ? app.icon : null,
|
||||
size: await file.length(),
|
||||
asset: null,
|
||||
path: app.apkFilePath,
|
||||
bytes: null,
|
||||
);
|
||||
@override
|
||||
void after() {
|
||||
global.dispatch(ClearCacheAction());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,32 +5,36 @@ import 'dart:io';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:localsend_app/util/native/platform_check.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:refena_flutter/refena_flutter.dart';
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
final _logger = Logger('CacheHelper');
|
||||
|
||||
/// Clears the cache.
|
||||
/// It is written in a "fire-and-forget" way, so we don't need to wait until everything is cleared.
|
||||
void clearCache() {
|
||||
FilePicker.platform.clearTemporaryFiles().then((_) {}).catchError((error) {
|
||||
_logger.info('Failed to clear file picker cache', error);
|
||||
});
|
||||
class ClearCacheAction extends GlobalAction {
|
||||
@override
|
||||
void reduce() {}
|
||||
|
||||
PhotoManager.clearFileCache().catchError((error) => _logger.info('Failed to clear photo manager cache', error));
|
||||
|
||||
if (checkPlatform([TargetPlatform.iOS, TargetPlatform.android])) {
|
||||
getTemporaryDirectory().then((cacheDir) {
|
||||
cacheDir.list().listen((event) {
|
||||
if (event is File) {
|
||||
event.delete().then((_) {}).catchError((error) {
|
||||
_logger.info('Failed to delete file', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catchError((error) {
|
||||
_logger.info('Failed to get temporary directory', error);
|
||||
@override
|
||||
void after() {
|
||||
FilePicker.platform.clearTemporaryFiles().then((_) {}).catchError((error) {
|
||||
ref.message('Failed to clear file picker cache: $error');
|
||||
});
|
||||
|
||||
PhotoManager.clearFileCache().catchError((error) => ref.message('Failed to clear photo manager cache: $error'));
|
||||
|
||||
if (checkPlatform([TargetPlatform.iOS, TargetPlatform.android])) {
|
||||
getTemporaryDirectory().then((cacheDir) {
|
||||
cacheDir.list().listen((event) {
|
||||
if (event is File) {
|
||||
event.delete().then((_) {}).catchError((error) {
|
||||
ref.message('Failed to delete file: $error');
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catchError((error) {
|
||||
ref.message('Failed to get temporary directory: $error');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_apps/device_apps.dart';
|
||||
import 'package:file_picker/file_picker.dart' as file_picker;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:localsend_app/model/cross_file.dart';
|
||||
import 'package:localsend_app/model/file_type.dart';
|
||||
import 'package:localsend_app/util/file_path_helper.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
/// Utility functions to convert third party models to common [CrossFile] model.
|
||||
class CrossFileConverters {
|
||||
static Future<CrossFile> convertPlatformFile(file_picker.PlatformFile file) async {
|
||||
return CrossFile(
|
||||
name: file.name,
|
||||
fileType: file.name.guessFileType(),
|
||||
size: file.size,
|
||||
thumbnail: null,
|
||||
asset: null,
|
||||
path: kIsWeb ? null : file.path,
|
||||
bytes: kIsWeb ? file.bytes! : null,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CrossFile> convertAssetEntity(AssetEntity asset) async {
|
||||
final file = (await asset.originFile)!;
|
||||
return CrossFile(
|
||||
name: await asset.titleAsync,
|
||||
fileType: asset.type == AssetType.video ? FileType.video : FileType.image,
|
||||
size: await file.length(),
|
||||
thumbnail: null,
|
||||
asset: asset,
|
||||
path: file.path,
|
||||
bytes: null,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CrossFile> convertXFile(XFile file) async {
|
||||
return CrossFile(
|
||||
name: file.name,
|
||||
fileType: file.name.guessFileType(),
|
||||
size: await file.length(),
|
||||
thumbnail: null,
|
||||
asset: null,
|
||||
path: kIsWeb ? null : file.path,
|
||||
bytes: kIsWeb ? await file.readAsBytes() : null, // we can fetch it now because in Web it is already there
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CrossFile> convertSharedAttachment(SharedAttachment attachment) async {
|
||||
final file = File(attachment.path);
|
||||
final fileName = attachment.path.fileName;
|
||||
return CrossFile(
|
||||
name: fileName,
|
||||
fileType: fileName.guessFileType(),
|
||||
size: await file.length(),
|
||||
thumbnail: null,
|
||||
asset: null,
|
||||
path: file.path,
|
||||
bytes: null,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<CrossFile> convertApplication(Application app) async {
|
||||
final file = File(app.apkFilePath);
|
||||
return CrossFile(
|
||||
name: '${app.appName.trim()} - v${app.versionName}.apk',
|
||||
fileType: FileType.apk,
|
||||
thumbnail: app is ApplicationWithIcon ? app.icon : null,
|
||||
size: await file.length(),
|
||||
asset: null,
|
||||
path: app.apkFilePath,
|
||||
bytes: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:file_selector/file_selector.dart' as file_selector;
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:localsend_app/gen/strings.g.dart';
|
||||
import 'package:localsend_app/pages/apk_picker_page.dart';
|
||||
import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart';
|
||||
import 'package:localsend_app/theme.dart';
|
||||
import 'package:localsend_app/util/native/cross_file_converters.dart';
|
||||
import 'package:localsend_app/util/native/pick_directory_path.dart';
|
||||
import 'package:localsend_app/util/native/platform_check.dart';
|
||||
import 'package:localsend_app/util/sleep.dart';
|
||||
import 'package:localsend_app/util/ui/asset_picker_translated_text_delegate.dart';
|
||||
import 'package:localsend_app/widget/dialogs/loading_dialog.dart';
|
||||
import 'package:localsend_app/widget/dialogs/message_input_dialog.dart';
|
||||
import 'package:localsend_app/widget/dialogs/no_permission_dialog.dart';
|
||||
@@ -20,7 +20,6 @@ import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:refena_flutter/refena_flutter.dart';
|
||||
import 'package:routerino/routerino.dart';
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
|
||||
final _logger = Logger('FilePickerHelper');
|
||||
|
||||
@@ -66,9 +65,9 @@ enum FilePickerOption {
|
||||
];
|
||||
} else if (checkPlatform([TargetPlatform.android])) {
|
||||
// On android, the file app is most powerful.
|
||||
// It actually also allows to pick media files.
|
||||
return [
|
||||
FilePickerOption.file,
|
||||
FilePickerOption.media,
|
||||
FilePickerOption.text,
|
||||
FilePickerOption.folder,
|
||||
FilePickerOption.app,
|
||||
@@ -83,137 +82,167 @@ enum FilePickerOption {
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> select({
|
||||
required BuildContext context,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
switch (this) {
|
||||
class PickAction extends AsyncGlobalAction {
|
||||
final FilePickerOption option;
|
||||
final BuildContext context;
|
||||
|
||||
PickAction({
|
||||
required this.option,
|
||||
required this.context,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> reduce() async {
|
||||
switch (option) {
|
||||
case FilePickerOption.file:
|
||||
if (checkPlatform([TargetPlatform.android])) {
|
||||
// On android, the files are copied to the cache which takes some time.
|
||||
// ignore: unawaited_futures
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const LoadingDialog(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (checkPlatform([TargetPlatform.android])) {
|
||||
// We also need to use the file_picker package because file_selector does not expose the raw path.
|
||||
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
|
||||
if (result != null) {
|
||||
await ref.notifier(selectedSendingFilesProvider).addFiles(
|
||||
files: result.files,
|
||||
converter: CrossFileConverters.convertPlatformFile,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final result = await file_selector.openFiles();
|
||||
if (result.isNotEmpty) {
|
||||
await ref.notifier(selectedSendingFilesProvider).addFiles<file_selector.XFile>(
|
||||
files: result,
|
||||
converter: CrossFileConverters.convertXFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog(context: context, builder: (_) => const NoPermissionDialog());
|
||||
_logger.warning('Failed to pick files', e);
|
||||
} finally {
|
||||
// ignore: use_build_context_synchronously
|
||||
Routerino.context.popUntilRoot(); // remove loading dialog
|
||||
}
|
||||
await _pickFiles(context, ref);
|
||||
break;
|
||||
case FilePickerOption.folder:
|
||||
if (checkPlatform([TargetPlatform.android])) {
|
||||
try {
|
||||
await Permission.manageExternalStorage.request();
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to request manageExternalStorage permission', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore: unawaited_futures
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const LoadingDialog(),
|
||||
);
|
||||
await sleepAsync(200); // Wait for the dialog to be shown
|
||||
try {
|
||||
final directoryPath = await pickDirectoryPath();
|
||||
if (directoryPath != null) {
|
||||
await ref.notifier(selectedSendingFilesProvider).addDirectory(directoryPath);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to pick directory', e);
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog(context: context, builder: (_) => const NoPermissionDialog());
|
||||
} finally {
|
||||
// ignore: use_build_context_synchronously
|
||||
Routerino.context.popUntilRoot(); // remove loading dialog
|
||||
}
|
||||
await _pickFolder(context, ref);
|
||||
break;
|
||||
case FilePickerOption.media:
|
||||
// ignore: use_build_context_synchronously
|
||||
final oldBrightness = Theme.of(context).brightness;
|
||||
// ignore: use_build_context_synchronously
|
||||
final List<AssetEntity>? result = await AssetPicker.pickAssets(
|
||||
context,
|
||||
pickerConfig: const AssetPickerConfig(maxAssets: 999, textDelegate: TranslatedAssetPickerTextDelegate()),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// restore brightness for Android
|
||||
await sleepAsync(500);
|
||||
if (context.mounted) {
|
||||
await updateSystemOverlayStyleWithBrightness(oldBrightness);
|
||||
}
|
||||
});
|
||||
|
||||
if (result != null) {
|
||||
await ref.notifier(selectedSendingFilesProvider).addFiles(
|
||||
files: result,
|
||||
converter: CrossFileConverters.convertAssetEntity,
|
||||
);
|
||||
}
|
||||
await _pickMedia(context, ref);
|
||||
break;
|
||||
case FilePickerOption.text:
|
||||
// ignore: use_build_context_synchronously
|
||||
final result = await showDialog<String>(context: context, builder: (_) => const MessageInputDialog());
|
||||
if (result != null) {
|
||||
ref.notifier(selectedSendingFilesProvider).addMessage(result);
|
||||
}
|
||||
await _pickText(context, ref);
|
||||
break;
|
||||
case FilePickerOption.clipboard:
|
||||
// ignore: use_build_context_synchronously
|
||||
late List<String> files = [];
|
||||
await Pasteboard.files().then((value) => {for (final file in value) files.add(file)});
|
||||
if (files.isNotEmpty) {
|
||||
await ref.notifier(selectedSendingFilesProvider).addFiles<file_selector.XFile>(
|
||||
files: files.map((e) => XFile(e)).toList(),
|
||||
converter: CrossFileConverters.convertXFile,
|
||||
);
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(t.general.noItemInClipboard),
|
||||
));
|
||||
}
|
||||
await _pickClipboard(context, ref);
|
||||
break;
|
||||
case FilePickerOption.app:
|
||||
// Currently, only Android APK
|
||||
// ignore: use_build_context_synchronously
|
||||
await context.push(() => const ApkPickerPage());
|
||||
await _pickApp(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PickAction(option: $option)';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFiles(BuildContext context, Ref ref) async {
|
||||
if (checkPlatform([TargetPlatform.android])) {
|
||||
// On android, the files are copied to the cache which takes some time.
|
||||
// ignore: unawaited_futures
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const LoadingDialog(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (checkPlatform([TargetPlatform.android])) {
|
||||
// We also need to use the file_picker package because file_selector does not expose the raw path.
|
||||
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
|
||||
if (result != null) {
|
||||
await ref.redux(selectedSendingFilesProvider).dispatchAsync(AddFilesAction(
|
||||
files: result.files,
|
||||
converter: CrossFileConverters.convertPlatformFile,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
final result = await file_selector.openFiles();
|
||||
if (result.isNotEmpty) {
|
||||
await ref.redux(selectedSendingFilesProvider).dispatchAsync(AddFilesAction(
|
||||
files: result,
|
||||
converter: CrossFileConverters.convertXFile,
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog(context: context, builder: (_) => const NoPermissionDialog());
|
||||
_logger.warning('Failed to pick files', e);
|
||||
} finally {
|
||||
// ignore: use_build_context_synchronously
|
||||
Routerino.context.popUntilRoot(); // remove loading dialog
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFolder(BuildContext context, Ref ref) async {
|
||||
if (checkPlatform([TargetPlatform.android])) {
|
||||
try {
|
||||
await Permission.manageExternalStorage.request();
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to request manageExternalStorage permission', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ignore: unawaited_futures
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const LoadingDialog(),
|
||||
);
|
||||
await sleepAsync(200); // Wait for the dialog to be shown
|
||||
try {
|
||||
final directoryPath = await pickDirectoryPath();
|
||||
if (directoryPath != null) {
|
||||
await ref.redux(selectedSendingFilesProvider).dispatchAsync(AddDirectoryAction(directoryPath));
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to pick directory', e);
|
||||
// ignore: use_build_context_synchronously
|
||||
await showDialog(context: context, builder: (_) => const NoPermissionDialog());
|
||||
} finally {
|
||||
// ignore: use_build_context_synchronously
|
||||
Routerino.context.popUntilRoot(); // remove loading dialog
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickMedia(BuildContext context, Ref ref) async {
|
||||
final result = await ImagePicker().pickMultipleMedia(
|
||||
imageQuality: null,
|
||||
requestFullMetadata: true,
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
ref.message('No media selected');
|
||||
return;
|
||||
}
|
||||
|
||||
await ref.redux(selectedSendingFilesProvider).dispatchAsync(AddFilesAction(
|
||||
files: result,
|
||||
converter: CrossFileConverters.convertXFile,
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> _pickText(BuildContext context, Ref ref) async {
|
||||
final result = await showDialog<String>(context: context, builder: (_) => const MessageInputDialog());
|
||||
if (result != null) {
|
||||
ref.redux(selectedSendingFilesProvider).dispatch(AddMessageAction(message: result));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickClipboard(BuildContext context, Ref ref) async {
|
||||
late List<String> files = [];
|
||||
await Pasteboard.files().then((value) => {for (final file in value) files.add(file)});
|
||||
if (files.isNotEmpty) {
|
||||
await ref.redux(selectedSendingFilesProvider).dispatchAsync(AddFilesAction(
|
||||
files: files.map((e) => XFile(e)).toList(),
|
||||
converter: CrossFileConverters.convertXFile,
|
||||
));
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(t.general.noItemInClipboard),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickApp(BuildContext context) async {
|
||||
// Currently, only Android APK
|
||||
await context.push(() => const ApkPickerPage());
|
||||
}
|
||||
|
||||
@@ -62,10 +62,7 @@ class AddFileDialog extends StatelessWidget {
|
||||
filled: true,
|
||||
onTap: () async {
|
||||
context.popUntilRoot();
|
||||
await option.select(
|
||||
context: context,
|
||||
ref: RefenaScope.defaultRef,
|
||||
);
|
||||
await context.ref.dispatchAsync(PickAction(option: option, context: context));
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user