added native features like homescreen-widgets and share intents #97

Merged
MineTec merged 5 commits from develop-native into develop 2026-05-09 19:35:32 +00:00
15 changed files with 437 additions and 128 deletions
Showing only changes of commit 151678f0fe - Show all commits
+31 -3
View File
@@ -7,6 +7,7 @@ import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../main.dart';
import '../model/account_data.dart';
import '../share_intent/pending_share.dart';
import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
@@ -46,11 +47,16 @@ class AppRoutes {
BuildContext context,
String localPath, {
bool openExternal = false,
RemoteFileRef? remoteFile,
}) {
pushScreen(
context,
withNavBar: false,
screen: FileViewer(path: localPath, openExternal: openExternal),
screen: FileViewer(
path: localPath,
openExternal: openExternal,
remoteFile: remoteFile,
),
);
}
@@ -106,7 +112,7 @@ class AppRoutes {
pushScreen(
context,
withNavBar: false,
screen: ShareChatPicker(share: share),
screen: ShareChatPicker.forExternalShare(share: share),
);
}
@@ -114,7 +120,29 @@ class AppRoutes {
pushScreen(
context,
withNavBar: false,
screen: ShareFolderPicker(share: share),
screen: ShareFolderPicker.forExternalShare(share: share),
);
}
static void openInternalShareToChat(
BuildContext context,
RemoteFileRef file,
) {
pushScreen(
context,
withNavBar: false,
screen: ShareChatPicker.forInternalShare(file: file),
);
}
static void openInternalSaveToFolder(
BuildContext context,
RemoteFileRef file,
) {
pushScreen(
context,
withNavBar: false,
screen: ShareFolderPicker.forInternalSave(file: file),
);
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:nextcloud/nextcloud.dart';
import '../api/marianumcloud/webdav/webdav_api.dart';
import '../widget/confirm_dialog.dart';
import 'remote_file_ref.dart';
/// Server-side WebDAV copy of [source] into [targetFolderPath]. On a 412
/// conflict the user is asked whether to overwrite; on confirmation the call
/// is retried with `overwrite: true`. Returns true when the file ended up at
/// the target, false when the user cancelled.
Future<bool> copyRemoteFileTo({
required BuildContext context,
required RemoteFileRef source,
required String targetFolderPath,
}) async {
final webdav = await WebdavApi.webdav;
final dst = targetFolderPath.isEmpty
? source.name
: '${targetFolderPath.replaceAll(RegExp(r'/+$'), '')}/${source.name}';
final src = PathUri.parse(source.path);
final dstUri = PathUri.parse(dst);
try {
await webdav.copy(src, dstUri);
return true;
} on DynamiteApiException catch (e) {
if (e.statusCode != 412) rethrow;
if (!context.mounted) return false;
final overwrite = await showDialog<bool>(
context: context,
builder: (ctx) => ConfirmDialog(
title: 'Datei existiert bereits',
content:
'"${source.name}" existiert in /$targetFolderPath. Überschreiben?',
confirmButton: 'Überschreiben',
cancelButton: 'Abbrechen',
onConfirm: () => Navigator.of(ctx).pop(true),
),
);
if (overwrite != true) return false;
await webdav.copy(src, dstUri, overwrite: true);
return true;
}
}
+20
View File
@@ -0,0 +1,20 @@
import '../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
/// References a file that already lives on the Nextcloud server. Used by the
/// in-app share/save flows that operate on remote paths instead of local
/// cache files (no upload needed).
class RemoteFileRef {
final String path;
final String name;
const RemoteFileRef({required this.path, required this.name});
/// Caller must verify `file.path != null` first — Talk message parameters
/// without a path (system events, mentions, polls) are not file refs.
factory RemoteFileRef.fromTalk(RichObjectString file) =>
RemoteFileRef(path: file.path!, name: file.name);
factory RemoteFileRef.fromCacheable(CacheableFile file) =>
RemoteFileRef(path: file.path, name: file.name);
}
+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,18 +102,20 @@ 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(
Future<void> _externalShareFlow(
BuildContext context,
GetRoomResponseObject room,
) async {
PendingShare share,
) async {
if (share.hasFiles) {
try {
final webdav = await WebdavApi.webdav;
@@ -109,34 +132,23 @@ class ShareChatPicker extends StatelessWidget {
remotePath: talkShareFolder,
uniqueNames: true,
onUploadFinished: (uploaded) =>
_afterFilesUploaded(context, room, uploaded),
_afterExternalFilesUploaded(context, room, uploaded, share),
),
);
return;
}
if (share.hasText) {
_setDraftAndOpenChat(context, room);
}
_setExternalDraftAndOpenChat(context, room, share);
}
}
Future<void> _afterFilesUploaded(
Future<void> _afterExternalFilesUploaded(
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()),
),
),
);
PendingShare share,
) async {
unawaited(_showBlockingSpinner(context));
try {
await shareFilesToChat(
token: room.token,
@@ -147,7 +159,7 @@ class ShareChatPicker extends StatelessWidget {
if (context.mounted) {
InfoDialog.show(
context,
'Datei konnte nicht im Chat geteilt werden: $e',
errorToUserMessage(e),
title: 'Fehler',
copyable: true,
);
@@ -155,18 +167,63 @@ class ShareChatPicker extends StatelessWidget {
return;
}
if (!context.mounted) return;
// The blocking dialog is popped together with the picker by
// _setDraftAndOpenChat's popUntil(isFirst) below.
_setDraftAndOpenChat(context, room);
}
_setExternalDraftAndOpenChat(context, room, share);
}
void _setDraftAndOpenChat(BuildContext context, GetRoomResponseObject room) {
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',
+42 -2
View File
@@ -11,6 +11,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart';
import 'info_dialog.dart';
import 'placeholder_view.dart';
@@ -19,13 +20,24 @@ import 'share_position_origin.dart';
class FileViewer extends StatefulWidget {
final String path;
final bool openExternal;
const FileViewer({super.key, required this.path, this.openExternal = false});
/// When set, enables the in-app actions "An Chat senden" and "In Dateien
/// speichern" — these need a server-side reference, not the local cache
/// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer).
final RemoteFileRef? remoteFile;
const FileViewer({
super.key,
required this.path,
this.openExternal = false,
this.remoteFile,
});
@override
State<FileViewer> createState() => _FileViewerState();
}
enum FileViewingActions { openExternal, share, save }
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
@@ -110,6 +122,16 @@ class _FileViewerState extends State<FileViewer> {
context,
widget.path,
openExternal: true,
remoteFile: widget.remoteFile,
);
break;
case FileViewingActions.sendToChat:
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
break;
case FileViewingActions.saveToCloud:
AppRoutes.openInternalSaveToFolder(
context,
widget.remoteFile!,
);
break;
case FileViewingActions.share:
@@ -154,6 +176,24 @@ class _FileViewerState extends State<FileViewer> {
dense: true,
),
),
if (widget.remoteFile != null) ...[
const PopupMenuItem(
value: FileViewingActions.sendToChat,
child: ListTile(
leading: Icon(Icons.chat_bubble_outline),
title: Text('An Talk-Chat senden'),
dense: true,
),
),
const PopupMenuItem(
value: FileViewingActions.saveToCloud,
child: ListTile(
leading: Icon(Icons.cloud_outlined),
title: Text('In Cloud speichern'),
dense: true,
),
),
],
const PopupMenuItem(
value: FileViewingActions.share,
child: ListTile(