implemented internal file sharing and saving, added server-side file references, refactored share pickers for unified flows, and updated UI branding labels
This commit is contained in:
@@ -5,11 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nextcloud/nextcloud.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
|
||||
import '../../../api/errors/error_mapper.dart';
|
||||
import '../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../api/marianumcloud/talk/share_files_to_chat.dart';
|
||||
import '../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../share_intent/pending_share.dart';
|
||||
import '../../../share_intent/remote_file_ref.dart';
|
||||
import '../../../share_intent/share_intent_listener.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
@@ -21,17 +23,36 @@ import '../files/files_upload_dialog.dart';
|
||||
import '../talk/search_chat.dart';
|
||||
import '../talk/widgets/chat_tile.dart';
|
||||
|
||||
class ShareChatPicker extends StatelessWidget {
|
||||
final PendingShare share;
|
||||
typedef _ChatPickedCallback =
|
||||
Future<void> Function(BuildContext context, GetRoomResponseObject room);
|
||||
|
||||
const ShareChatPicker({super.key, required this.share});
|
||||
class ShareChatPicker extends StatelessWidget {
|
||||
final _ChatPickedCallback _onPicked;
|
||||
|
||||
const ShareChatPicker._({required _ChatPickedCallback onPicked})
|
||||
: _onPicked = onPicked;
|
||||
|
||||
/// External share-intent flow: uploads local files into the Talk share
|
||||
/// folder, then shares them in the chosen chat. Falls back to a draft-only
|
||||
/// flow when the pending share contains no files.
|
||||
factory ShareChatPicker.forExternalShare({required PendingShare share}) =>
|
||||
ShareChatPicker._(
|
||||
onPicked: (ctx, room) => _externalShareFlow(ctx, room, share),
|
||||
);
|
||||
|
||||
/// In-app share flow: links an already-uploaded server file into the chosen
|
||||
/// chat via FileSharingApi (no upload needed).
|
||||
factory ShareChatPicker.forInternalShare({required RemoteFileRef file}) =>
|
||||
ShareChatPicker._(
|
||||
onPicked: (ctx, room) => _internalShareFlow(ctx, room, file),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final talkSettings = context.watch<SettingsCubit>().val().talkSettings;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Chat auswählen'),
|
||||
title: const Text('Talk-Chat auswählen'),
|
||||
actions: [
|
||||
Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
@@ -45,7 +66,7 @@ class ShareChatPicker extends StatelessWidget {
|
||||
rooms.data.where((r) => r.readOnly == 0).toList(),
|
||||
onTapOverride: (room) {
|
||||
Navigator.of(ctx).pop();
|
||||
_onChatPicked(ctx, room);
|
||||
_onPicked(ctx, room);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -81,92 +102,128 @@ class ShareChatPicker extends StatelessWidget {
|
||||
itemBuilder: (context, i) => ChatTile(
|
||||
data: sorted[i],
|
||||
disableContextActions: true,
|
||||
onTapOverride: (room) => _onChatPicked(context, room),
|
||||
onTapOverride: (room) => _onPicked(context, room),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onChatPicked(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
) async {
|
||||
if (share.hasFiles) {
|
||||
try {
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.mkcol(PathUri.parse('/$talkShareFolder'));
|
||||
} catch (_) {
|
||||
// mkcol throws when the folder already exists; ignore.
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: share.filePaths,
|
||||
remotePath: talkShareFolder,
|
||||
uniqueNames: true,
|
||||
onUploadFinished: (uploaded) =>
|
||||
_afterFilesUploaded(context, room, uploaded),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (share.hasText) {
|
||||
_setDraftAndOpenChat(context, room);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _afterFilesUploaded(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
List<String> uploadedRemotePaths,
|
||||
) async {
|
||||
// Block the picker UI while the share-API roundtrips run, otherwise the
|
||||
// user can re-tap a chat and double-share the same files.
|
||||
unawaited(
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const PopScope(
|
||||
canPop: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _externalShareFlow(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
PendingShare share,
|
||||
) async {
|
||||
if (share.hasFiles) {
|
||||
try {
|
||||
await shareFilesToChat(
|
||||
token: room.token,
|
||||
remoteFilePaths: uploadedRemotePaths,
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Datei konnte nicht im Chat geteilt werden: $e',
|
||||
title: 'Fehler',
|
||||
copyable: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
final webdav = await WebdavApi.webdav;
|
||||
await webdav.mkcol(PathUri.parse('/$talkShareFolder'));
|
||||
} catch (_) {
|
||||
// mkcol throws when the folder already exists; ignore.
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
// The blocking dialog is popped together with the picker by
|
||||
// _setDraftAndOpenChat's popUntil(isFirst) below.
|
||||
_setDraftAndOpenChat(context, room);
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: share.filePaths,
|
||||
remotePath: talkShareFolder,
|
||||
uniqueNames: true,
|
||||
onUploadFinished: (uploaded) =>
|
||||
_afterExternalFilesUploaded(context, room, uploaded, share),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void _setDraftAndOpenChat(BuildContext context, GetRoomResponseObject room) {
|
||||
if (share.hasText) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
settings.val(write: true).talkSettings.drafts[room.token] = share.text!;
|
||||
}
|
||||
ShareIntentListener.instance.clear();
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
AppRoutes.openChatByToken(context, room.token);
|
||||
if (share.hasText) {
|
||||
_setExternalDraftAndOpenChat(context, room, share);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _afterExternalFilesUploaded(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
List<String> uploadedRemotePaths,
|
||||
PendingShare share,
|
||||
) async {
|
||||
unawaited(_showBlockingSpinner(context));
|
||||
try {
|
||||
await shareFilesToChat(
|
||||
token: room.token,
|
||||
remoteFilePaths: uploadedRemotePaths,
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
errorToUserMessage(e),
|
||||
title: 'Fehler',
|
||||
copyable: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
_setExternalDraftAndOpenChat(context, room, share);
|
||||
}
|
||||
|
||||
void _setExternalDraftAndOpenChat(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
PendingShare share,
|
||||
) {
|
||||
if (share.hasText) {
|
||||
final settings = context.read<SettingsCubit>();
|
||||
settings.val(write: true).talkSettings.drafts[room.token] = share.text!;
|
||||
}
|
||||
ShareIntentListener.instance.clear();
|
||||
_finishWithChat(context, room);
|
||||
}
|
||||
|
||||
/// Closes any picker/spinner pages stacked on top of the current tab and
|
||||
/// jumps to the chosen chat. Shared by external + internal share flows.
|
||||
void _finishWithChat(BuildContext context, GetRoomResponseObject room) {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
AppRoutes.openChatByToken(context, room.token);
|
||||
}
|
||||
|
||||
Future<void> _internalShareFlow(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
RemoteFileRef file,
|
||||
) async {
|
||||
unawaited(_showBlockingSpinner(context));
|
||||
try {
|
||||
await shareFilesToChat(
|
||||
token: room.token,
|
||||
remoteFilePaths: [file.path],
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
errorToUserMessage(e),
|
||||
title: 'Fehler',
|
||||
copyable: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
_finishWithChat(context, room);
|
||||
}
|
||||
|
||||
/// Modal progress overlay shown during share-API roundtrips. The dialog is
|
||||
/// popped together with the picker by the subsequent popUntil(isFirst).
|
||||
Future<void> _showBlockingSpinner(BuildContext context) => showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const PopScope(
|
||||
canPop: false,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
||||
|
||||
import '../../../api/errors/error_mapper.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../share_intent/internal_share_actions.dart';
|
||||
import '../../../share_intent/pending_share.dart';
|
||||
import '../../../share_intent/remote_file_ref.dart';
|
||||
import '../../../share_intent/share_intent_listener.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
@@ -11,28 +14,57 @@ import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_bloc.dart';
|
||||
import '../../../state/app/modules/files/bloc/files_state.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../widget/info_dialog.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import '../files/data/sort_options.dart';
|
||||
import '../files/files_upload_dialog.dart';
|
||||
import '../files/widgets/add_file_menu.dart';
|
||||
import '../files/widgets/files_sort_actions.dart';
|
||||
|
||||
class ShareFolderPicker extends StatelessWidget {
|
||||
final PendingShare share;
|
||||
typedef _FolderConfirmedCallback =
|
||||
Future<void> Function(BuildContext context, List<String> targetPath);
|
||||
|
||||
const ShareFolderPicker({super.key, required this.share});
|
||||
class ShareFolderPicker extends StatelessWidget {
|
||||
final String _fabLabel;
|
||||
final _FolderConfirmedCallback _onConfirm;
|
||||
|
||||
const ShareFolderPicker._({
|
||||
required String fabLabel,
|
||||
required _FolderConfirmedCallback onConfirm,
|
||||
}) : _fabLabel = fabLabel,
|
||||
_onConfirm = onConfirm;
|
||||
|
||||
/// External share-intent flow: upload local files into the chosen folder.
|
||||
factory ShareFolderPicker.forExternalShare({required PendingShare share}) =>
|
||||
ShareFolderPicker._(
|
||||
fabLabel: 'Hier hochladen',
|
||||
onConfirm: (ctx, target) => _externalUploadFlow(ctx, target, share),
|
||||
);
|
||||
|
||||
/// In-app save flow: server-to-server WebDAV-copy of an existing file into
|
||||
/// the chosen folder.
|
||||
factory ShareFolderPicker.forInternalSave({required RemoteFileRef file}) =>
|
||||
ShareFolderPicker._(
|
||||
fabLabel: 'Hierhin kopieren',
|
||||
onConfirm: (ctx, target) => _internalCopyFlow(ctx, target, file),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocModule<FilesBloc, LoadableState<FilesState>>(
|
||||
create: (_) => FilesBloc(),
|
||||
child: (context, _, _) => _ShareFolderPickerView(share: share),
|
||||
child: (context, _, _) =>
|
||||
_ShareFolderPickerView(fabLabel: _fabLabel, onConfirm: _onConfirm),
|
||||
);
|
||||
}
|
||||
|
||||
class _ShareFolderPickerView extends StatefulWidget {
|
||||
final PendingShare share;
|
||||
const _ShareFolderPickerView({required this.share});
|
||||
final String fabLabel;
|
||||
final _FolderConfirmedCallback onConfirm;
|
||||
const _ShareFolderPickerView({
|
||||
required this.fabLabel,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState();
|
||||
@@ -60,25 +92,6 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
bloc.setPath(currentPath.sublist(0, currentPath.length - 1));
|
||||
}
|
||||
|
||||
Future<void> _uploadHere(List<String> currentPath) async {
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: widget.share.filePaths,
|
||||
remotePath: currentPath.join('/'),
|
||||
onUploadFinished: (_) => _afterUploaded(currentPath),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _afterUploaded(List<String> targetPath) {
|
||||
ShareIntentListener.instance.clear();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
AppRoutes.openFolder(context, targetPath);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bloc = context.read<FilesBloc>();
|
||||
@@ -138,9 +151,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
heroTag: 'shareUploadHere',
|
||||
onPressed: () => _uploadHere(currentPath),
|
||||
onPressed: () => widget.onConfirm(context, currentPath),
|
||||
icon: const Icon(Icons.upload),
|
||||
label: const Text('Hier hochladen'),
|
||||
label: Text(widget.fabLabel),
|
||||
),
|
||||
body: LoadableStateConsumer<FilesBloc, FilesState>(
|
||||
isReady: (state) => state.listing != null,
|
||||
@@ -185,3 +198,59 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _externalUploadFlow(
|
||||
BuildContext context,
|
||||
List<String> targetPath,
|
||||
PendingShare share,
|
||||
) async {
|
||||
await pushScreen(
|
||||
context,
|
||||
withNavBar: false,
|
||||
screen: FilesUploadDialog(
|
||||
filePaths: share.filePaths,
|
||||
remotePath: targetPath.join('/'),
|
||||
onUploadFinished: (_) => _afterExternalUploaded(context, targetPath),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _afterExternalUploaded(BuildContext context, List<String> targetPath) {
|
||||
ShareIntentListener.instance.clear();
|
||||
if (!context.mounted) return;
|
||||
_finishWithFolder(context, targetPath);
|
||||
}
|
||||
|
||||
/// Closes any picker pages stacked on top of the current tab and jumps to
|
||||
/// the chosen folder. Shared by external upload + internal copy flows.
|
||||
void _finishWithFolder(BuildContext context, List<String> targetPath) {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
AppRoutes.openFolder(context, targetPath);
|
||||
}
|
||||
|
||||
Future<void> _internalCopyFlow(
|
||||
BuildContext context,
|
||||
List<String> targetPath,
|
||||
RemoteFileRef file,
|
||||
) async {
|
||||
final bool ok;
|
||||
try {
|
||||
ok = await copyRemoteFileTo(
|
||||
context: context,
|
||||
source: file,
|
||||
targetFolderPath: targetPath.join('/'),
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
errorToUserMessage(e),
|
||||
title: 'Kopieren fehlgeschlagen',
|
||||
copyable: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!ok || !context.mounted) return;
|
||||
_finishWithFolder(context, targetPath);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class ShareTargetPage extends StatelessWidget {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.chat_bubble_outline),
|
||||
title: const Text('An Chat senden'),
|
||||
title: const Text('An Talk-Chat senden'),
|
||||
subtitle: const Text(
|
||||
'Datei oder Text in einem Talk-Chat teilen',
|
||||
),
|
||||
@@ -98,11 +98,11 @@ class ShareTargetPage extends StatelessWidget {
|
||||
),
|
||||
ListTile(
|
||||
enabled: share.hasFiles,
|
||||
leading: const Icon(Icons.folder_outlined),
|
||||
title: const Text('In Dateien speichern'),
|
||||
leading: const Icon(Icons.cloud_outlined),
|
||||
title: const Text('In Cloud speichern'),
|
||||
subtitle: Text(
|
||||
share.hasFiles
|
||||
? 'In einen Nextcloud-Ordner hochladen'
|
||||
? 'In einen Cloud-Ordner hochladen'
|
||||
: 'Nur für Dateien verfügbar',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
|
||||
Reference in New Issue
Block a user