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
+1 -1
View File
@@ -45,7 +45,7 @@ class LoginDisclaimer extends StatelessWidget {
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.',
'Inoffizieller Marianum-Cloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75),
+18 -1
View File
@@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/endpoint_data.dart';
import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../utils/download_manager.dart';
import '../../../../utils/file_clipboard.dart';
import '../../../../widget/centered_leading.dart';
@@ -71,7 +72,11 @@ class _FileElementState extends State<FileElement> {
if (status is DownloadDone) {
DownloadManager.instance.clear(widget.file.path);
_detachJob();
AppRoutes.openFileViewer(context, status.localPath);
AppRoutes.openFileViewer(
context,
status.localPath,
remoteFile: RemoteFileRef.fromCacheable(widget.file),
);
setState(() {});
} else if (status is DownloadFailed) {
final message = status.message;
@@ -299,6 +304,18 @@ class _FileElementState extends State<FileElement> {
_putOnClipboard(copy: true);
},
),
if (!widget.file.isDirectory)
ListTile(
leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)),
title: const Text('Im Talk-Chat teilen'),
onTap: () {
Navigator.of(sheetCtx).pop();
AppRoutes.openInternalShareToChat(
context,
RemoteFileRef.fromCacheable(widget.file),
);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.delete_outline)),
title: const Text('Löschen'),
@@ -69,7 +69,7 @@ class AboutSection extends StatelessWidget {
applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
applicationLegalese:
'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
"${kReleaseMode ? "Production" : "Development"} build\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
@@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget {
ListTile(
leading: const CenteredLeading(Icon(Icons.school_outlined)),
title: const Text('Infos zum Marianum Fulda'),
subtitle: const Text('Für Talk-Chats und Dateien'),
subtitle: const Text('Für Talk-Chats und Cloud-Dateien'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Marianum',
@@ -60,7 +60,7 @@ class TalkSection extends StatelessWidget {
context,
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Der extene Server verwendet die Zugangsdaten um sich maschinell in Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
title: 'Info über Push',
@@ -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),
+3 -3
View File
@@ -153,10 +153,10 @@ class _ChatListViewState extends State<_ChatListView> {
) {
if (username == null || !context.mounted) return;
ConfirmDialog(
title: 'Chat starten',
title: 'Talk-Chat starten',
content:
"Möchtest du einen Chat mit Nutzer '$username' starten?",
confirmButton: 'Chat starten',
"Möchtest du einen Talk-Chat mit Nutzer '$username' starten?",
confirmButton: 'Talk-Chat starten',
onConfirmAsync: () => bloc.createDirectChat(username),
).asDialog(context);
});
+9 -1
View File
@@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/download_manager.dart';
import '../../../../widget/confirm_dialog.dart';
@@ -90,7 +91,14 @@ class _ChatBubbleState extends State<ChatBubble>
if (status is DownloadDone) {
DownloadManager.instance.clear(job.remotePath);
_detachJob();
AppRoutes.openFileViewer(context, status.localPath);
final talkFile = message.file;
AppRoutes.openFileViewer(
context,
status.localPath,
remoteFile: talkFile != null
? RemoteFileRef.fromTalk(talkFile)
: null,
);
setState(() {});
} else if (status is DownloadFailed) {
final message = status.message;
@@ -9,6 +9,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/app_progress_indicator.dart';
@@ -18,6 +19,16 @@ import '../../../../widget/details_bottom_sheet.dart';
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
RichObjectString? _attachedFile(GetChatResponseObject bubbleData) {
final file = bubbleData.messageParameters?['file'];
if (file == null ||
file.path == null ||
file.type != RichObjectStringObjectType.file) {
return null;
}
return file;
}
/// Long-press / double-tap options dialog for a single chat message bubble.
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
/// this file owns the modal interactions (react, reply, copy, delete, ...).
@@ -36,6 +47,7 @@ void showChatMessageOptionsDialog(
DateTime.fromMillisecondsSinceEpoch(
bubbleData.timestamp * 1000,
).add(const Duration(hours: 6)).isAfter(DateTime.now());
final attachedFile = _attachedFile(bubbleData);
showDetailsBottomSheet(
context,
@@ -79,6 +91,19 @@ void showChatMessageOptionsDialog(
Navigator.of(sheetCtx).pop();
},
),
if (attachedFile != null)
ListTile(
leading: const Icon(Icons.cloud_outlined),
title: const Text('In Cloud speichern'),
onTap: () {
Navigator.of(sheetCtx).pop();
if (!parentContext.mounted) return;
AppRoutes.openInternalSaveToFolder(
parentContext,
RemoteFileRef.fromTalk(attachedFile),
);
},
),
if (!kReleaseMode &&
!isSender &&
chatData.type != GetRoomResponseObjectConversationType.oneToOne)
+2 -2
View File
@@ -207,11 +207,11 @@ class _ChatTileState extends State<ChatTile> {
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Konversation verlassen'),
title: const Text('Talk-Chat verlassen'),
onTap: () {
Navigator.of(sheetCtx).pop();
ConfirmDialog(
title: 'Chat verlassen',
title: 'Talk-Chat verlassen',
content:
'Du benötigst ggf. eine Einladung um erneut beizutreten.',
confirmButton: 'Verlassen',