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:
2026-05-09 20:18:52 +02:00
parent cb2c38aaa1
commit 151678f0fe
15 changed files with 437 additions and 128 deletions
@@ -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()),
),
);