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:
@@ -7,6 +7,7 @@ import '../api/marianumcloud/talk/room/get_room_response.dart';
|
|||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../model/account_data.dart';
|
import '../model/account_data.dart';
|
||||||
import '../share_intent/pending_share.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/app_modules.dart';
|
||||||
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
@@ -46,11 +47,16 @@ class AppRoutes {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
String localPath, {
|
String localPath, {
|
||||||
bool openExternal = false,
|
bool openExternal = false,
|
||||||
|
RemoteFileRef? remoteFile,
|
||||||
}) {
|
}) {
|
||||||
pushScreen(
|
pushScreen(
|
||||||
context,
|
context,
|
||||||
withNavBar: false,
|
withNavBar: false,
|
||||||
screen: FileViewer(path: localPath, openExternal: openExternal),
|
screen: FileViewer(
|
||||||
|
path: localPath,
|
||||||
|
openExternal: openExternal,
|
||||||
|
remoteFile: remoteFile,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +112,7 @@ class AppRoutes {
|
|||||||
pushScreen(
|
pushScreen(
|
||||||
context,
|
context,
|
||||||
withNavBar: false,
|
withNavBar: false,
|
||||||
screen: ShareChatPicker(share: share),
|
screen: ShareChatPicker.forExternalShare(share: share),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +120,29 @@ class AppRoutes {
|
|||||||
pushScreen(
|
pushScreen(
|
||||||
context,
|
context,
|
||||||
withNavBar: false,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ class LoginDisclaimer extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text(
|
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,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withValues(alpha: 0.75),
|
color: Colors.white.withValues(alpha: 0.75),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
|||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../model/endpoint_data.dart';
|
import '../../../../model/endpoint_data.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../utils/download_manager.dart';
|
import '../../../../utils/download_manager.dart';
|
||||||
import '../../../../utils/file_clipboard.dart';
|
import '../../../../utils/file_clipboard.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
@@ -71,7 +72,11 @@ class _FileElementState extends State<FileElement> {
|
|||||||
if (status is DownloadDone) {
|
if (status is DownloadDone) {
|
||||||
DownloadManager.instance.clear(widget.file.path);
|
DownloadManager.instance.clear(widget.file.path);
|
||||||
_detachJob();
|
_detachJob();
|
||||||
AppRoutes.openFileViewer(context, status.localPath);
|
AppRoutes.openFileViewer(
|
||||||
|
context,
|
||||||
|
status.localPath,
|
||||||
|
remoteFile: RemoteFileRef.fromCacheable(widget.file),
|
||||||
|
);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
} else if (status is DownloadFailed) {
|
} else if (status is DownloadFailed) {
|
||||||
final message = status.message;
|
final message = status.message;
|
||||||
@@ -299,6 +304,18 @@ class _FileElementState extends State<FileElement> {
|
|||||||
_putOnClipboard(copy: true);
|
_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(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.delete_outline)),
|
leading: const CenteredLeading(Icon(Icons.delete_outline)),
|
||||||
title: const Text('Löschen'),
|
title: const Text('Löschen'),
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class AboutSection extends StatelessWidget {
|
|||||||
applicationVersion:
|
applicationVersion:
|
||||||
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
|
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
|
||||||
applicationLegalese:
|
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'
|
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
|
||||||
"${kReleaseMode ? "Production" : "Development"} build\n"
|
"${kReleaseMode ? "Production" : "Development"} build\n"
|
||||||
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
|
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
|
||||||
@@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.school_outlined)),
|
leading: const CenteredLeading(Icon(Icons.school_outlined)),
|
||||||
title: const Text('Infos zum Marianum Fulda'),
|
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),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () => PrivacyInfo(
|
onTap: () => PrivacyInfo(
|
||||||
providerText: 'Marianum',
|
providerText: 'Marianum',
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class TalkSection extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
|
"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'
|
'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'
|
'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!',
|
'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',
|
title: 'Info über Push',
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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/room/get_room_response.dart';
|
||||||
import '../../../api/marianumcloud/talk/share_files_to_chat.dart';
|
import '../../../api/marianumcloud/talk/share_files_to_chat.dart';
|
||||||
import '../../../api/marianumcloud/webdav/webdav_api.dart';
|
import '../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
import '../../../share_intent/pending_share.dart';
|
import '../../../share_intent/pending_share.dart';
|
||||||
|
import '../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../share_intent/share_intent_listener.dart';
|
import '../../../share_intent/share_intent_listener.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||||
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.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/search_chat.dart';
|
||||||
import '../talk/widgets/chat_tile.dart';
|
import '../talk/widgets/chat_tile.dart';
|
||||||
|
|
||||||
class ShareChatPicker extends StatelessWidget {
|
typedef _ChatPickedCallback =
|
||||||
final PendingShare share;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final talkSettings = context.watch<SettingsCubit>().val().talkSettings;
|
final talkSettings = context.watch<SettingsCubit>().val().talkSettings;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Chat auswählen'),
|
title: const Text('Talk-Chat auswählen'),
|
||||||
actions: [
|
actions: [
|
||||||
Builder(
|
Builder(
|
||||||
builder: (ctx) => IconButton(
|
builder: (ctx) => IconButton(
|
||||||
@@ -45,7 +66,7 @@ class ShareChatPicker extends StatelessWidget {
|
|||||||
rooms.data.where((r) => r.readOnly == 0).toList(),
|
rooms.data.where((r) => r.readOnly == 0).toList(),
|
||||||
onTapOverride: (room) {
|
onTapOverride: (room) {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
_onChatPicked(ctx, room);
|
_onPicked(ctx, room);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -81,92 +102,128 @@ class ShareChatPicker extends StatelessWidget {
|
|||||||
itemBuilder: (context, i) => ChatTile(
|
itemBuilder: (context, i) => ChatTile(
|
||||||
data: sorted[i],
|
data: sorted[i],
|
||||||
disableContextActions: true,
|
disableContextActions: true,
|
||||||
onTapOverride: (room) => _onChatPicked(context, room),
|
onTapOverride: (room) => _onPicked(context, room),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onChatPicked(
|
Future<void> _externalShareFlow(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
GetRoomResponseObject room,
|
GetRoomResponseObject room,
|
||||||
) async {
|
PendingShare share,
|
||||||
if (share.hasFiles) {
|
) async {
|
||||||
try {
|
if (share.hasFiles) {
|
||||||
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()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await shareFilesToChat(
|
final webdav = await WebdavApi.webdav;
|
||||||
token: room.token,
|
await webdav.mkcol(PathUri.parse('/$talkShareFolder'));
|
||||||
remoteFilePaths: uploadedRemotePaths,
|
} catch (_) {
|
||||||
);
|
// mkcol throws when the folder already exists; ignore.
|
||||||
} 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;
|
|
||||||
}
|
}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
// The blocking dialog is popped together with the picker by
|
await pushScreen(
|
||||||
// _setDraftAndOpenChat's popUntil(isFirst) below.
|
context,
|
||||||
_setDraftAndOpenChat(context, room);
|
withNavBar: false,
|
||||||
|
screen: FilesUploadDialog(
|
||||||
|
filePaths: share.filePaths,
|
||||||
|
remotePath: talkShareFolder,
|
||||||
|
uniqueNames: true,
|
||||||
|
onUploadFinished: (uploaded) =>
|
||||||
|
_afterExternalFilesUploaded(context, room, uploaded, share),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (share.hasText) {
|
||||||
void _setDraftAndOpenChat(BuildContext context, GetRoomResponseObject room) {
|
_setExternalDraftAndOpenChat(context, room, share);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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 '../../../routing/app_routes.dart';
|
||||||
|
import '../../../share_intent/internal_share_actions.dart';
|
||||||
import '../../../share_intent/pending_share.dart';
|
import '../../../share_intent/pending_share.dart';
|
||||||
|
import '../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../share_intent/share_intent_listener.dart';
|
import '../../../share_intent/share_intent_listener.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
|
||||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.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_bloc.dart';
|
||||||
import '../../../state/app/modules/files/bloc/files_state.dart';
|
import '../../../state/app/modules/files/bloc/files_state.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import '../../../widget/info_dialog.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import '../files/data/sort_options.dart';
|
import '../files/data/sort_options.dart';
|
||||||
import '../files/files_upload_dialog.dart';
|
import '../files/files_upload_dialog.dart';
|
||||||
import '../files/widgets/add_file_menu.dart';
|
import '../files/widgets/add_file_menu.dart';
|
||||||
import '../files/widgets/files_sort_actions.dart';
|
import '../files/widgets/files_sort_actions.dart';
|
||||||
|
|
||||||
class ShareFolderPicker extends StatelessWidget {
|
typedef _FolderConfirmedCallback =
|
||||||
final PendingShare share;
|
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
|
@override
|
||||||
Widget build(BuildContext context) =>
|
Widget build(BuildContext context) =>
|
||||||
BlocModule<FilesBloc, LoadableState<FilesState>>(
|
BlocModule<FilesBloc, LoadableState<FilesState>>(
|
||||||
create: (_) => FilesBloc(),
|
create: (_) => FilesBloc(),
|
||||||
child: (context, _, _) => _ShareFolderPickerView(share: share),
|
child: (context, _, _) =>
|
||||||
|
_ShareFolderPickerView(fabLabel: _fabLabel, onConfirm: _onConfirm),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ShareFolderPickerView extends StatefulWidget {
|
class _ShareFolderPickerView extends StatefulWidget {
|
||||||
final PendingShare share;
|
final String fabLabel;
|
||||||
const _ShareFolderPickerView({required this.share});
|
final _FolderConfirmedCallback onConfirm;
|
||||||
|
const _ShareFolderPickerView({
|
||||||
|
required this.fabLabel,
|
||||||
|
required this.onConfirm,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState();
|
State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState();
|
||||||
@@ -60,25 +92,6 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
|||||||
bloc.setPath(currentPath.sublist(0, currentPath.length - 1));
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bloc = context.read<FilesBloc>();
|
final bloc = context.read<FilesBloc>();
|
||||||
@@ -138,9 +151,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
|
|||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
heroTag: 'shareUploadHere',
|
heroTag: 'shareUploadHere',
|
||||||
onPressed: () => _uploadHere(currentPath),
|
onPressed: () => widget.onConfirm(context, currentPath),
|
||||||
icon: const Icon(Icons.upload),
|
icon: const Icon(Icons.upload),
|
||||||
label: const Text('Hier hochladen'),
|
label: Text(widget.fabLabel),
|
||||||
),
|
),
|
||||||
body: LoadableStateConsumer<FilesBloc, FilesState>(
|
body: LoadableStateConsumer<FilesBloc, FilesState>(
|
||||||
isReady: (state) => state.listing != null,
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.chat_bubble_outline),
|
leading: const Icon(Icons.chat_bubble_outline),
|
||||||
title: const Text('An Chat senden'),
|
title: const Text('An Talk-Chat senden'),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Datei oder Text in einem Talk-Chat teilen',
|
'Datei oder Text in einem Talk-Chat teilen',
|
||||||
),
|
),
|
||||||
@@ -98,11 +98,11 @@ class ShareTargetPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
enabled: share.hasFiles,
|
enabled: share.hasFiles,
|
||||||
leading: const Icon(Icons.folder_outlined),
|
leading: const Icon(Icons.cloud_outlined),
|
||||||
title: const Text('In Dateien speichern'),
|
title: const Text('In Cloud speichern'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
share.hasFiles
|
share.hasFiles
|
||||||
? 'In einen Nextcloud-Ordner hochladen'
|
? 'In einen Cloud-Ordner hochladen'
|
||||||
: 'Nur für Dateien verfügbar',
|
: 'Nur für Dateien verfügbar',
|
||||||
),
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
|||||||
@@ -153,10 +153,10 @@ class _ChatListViewState extends State<_ChatListView> {
|
|||||||
) {
|
) {
|
||||||
if (username == null || !context.mounted) return;
|
if (username == null || !context.mounted) return;
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
title: 'Chat starten',
|
title: 'Talk-Chat starten',
|
||||||
content:
|
content:
|
||||||
"Möchtest du einen Chat mit Nutzer '$username' starten?",
|
"Möchtest du einen Talk-Chat mit Nutzer '$username' starten?",
|
||||||
confirmButton: 'Chat starten',
|
confirmButton: 'Talk-Chat starten',
|
||||||
onConfirmAsync: () => bloc.createDirectChat(username),
|
onConfirmAsync: () => bloc.createDirectChat(username),
|
||||||
).asDialog(context);
|
).asDialog(context);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
|||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../extensions/text.dart';
|
import '../../../../extensions/text.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../utils/download_manager.dart';
|
import '../../../../utils/download_manager.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
@@ -90,7 +91,14 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
if (status is DownloadDone) {
|
if (status is DownloadDone) {
|
||||||
DownloadManager.instance.clear(job.remotePath);
|
DownloadManager.instance.clear(job.remotePath);
|
||||||
_detachJob();
|
_detachJob();
|
||||||
AppRoutes.openFileViewer(context, status.localPath);
|
final talkFile = message.file;
|
||||||
|
AppRoutes.openFileViewer(
|
||||||
|
context,
|
||||||
|
status.localPath,
|
||||||
|
remoteFile: talkFile != null
|
||||||
|
? RemoteFileRef.fromTalk(talkFile)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
} else if (status is DownloadFailed) {
|
} else if (status is DownloadFailed) {
|
||||||
final message = status.message;
|
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/react_message/react_message_params.dart';
|
||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../utils/clipboard_helper.dart';
|
import '../../../../utils/clipboard_helper.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
@@ -18,6 +19,16 @@ import '../../../../widget/details_bottom_sheet.dart';
|
|||||||
|
|
||||||
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
|
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.
|
/// Long-press / double-tap options dialog for a single chat message bubble.
|
||||||
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
|
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
|
||||||
/// this file owns the modal interactions (react, reply, copy, delete, ...).
|
/// this file owns the modal interactions (react, reply, copy, delete, ...).
|
||||||
@@ -36,6 +47,7 @@ void showChatMessageOptionsDialog(
|
|||||||
DateTime.fromMillisecondsSinceEpoch(
|
DateTime.fromMillisecondsSinceEpoch(
|
||||||
bubbleData.timestamp * 1000,
|
bubbleData.timestamp * 1000,
|
||||||
).add(const Duration(hours: 6)).isAfter(DateTime.now());
|
).add(const Duration(hours: 6)).isAfter(DateTime.now());
|
||||||
|
final attachedFile = _attachedFile(bubbleData);
|
||||||
|
|
||||||
showDetailsBottomSheet(
|
showDetailsBottomSheet(
|
||||||
context,
|
context,
|
||||||
@@ -79,6 +91,19 @@ void showChatMessageOptionsDialog(
|
|||||||
Navigator.of(sheetCtx).pop();
|
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 &&
|
if (!kReleaseMode &&
|
||||||
!isSender &&
|
!isSender &&
|
||||||
chatData.type != GetRoomResponseObjectConversationType.oneToOne)
|
chatData.type != GetRoomResponseObjectConversationType.oneToOne)
|
||||||
|
|||||||
@@ -207,11 +207,11 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline),
|
leading: const Icon(Icons.delete_outline),
|
||||||
title: const Text('Konversation verlassen'),
|
title: const Text('Talk-Chat verlassen'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(sheetCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
title: 'Chat verlassen',
|
title: 'Talk-Chat verlassen',
|
||||||
content:
|
content:
|
||||||
'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||||
confirmButton: 'Verlassen',
|
confirmButton: 'Verlassen',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:share_plus/share_plus.dart';
|
|||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
|
import '../share_intent/remote_file_ref.dart';
|
||||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import 'info_dialog.dart';
|
import 'info_dialog.dart';
|
||||||
import 'placeholder_view.dart';
|
import 'placeholder_view.dart';
|
||||||
@@ -19,13 +20,24 @@ import 'share_position_origin.dart';
|
|||||||
class FileViewer extends StatefulWidget {
|
class FileViewer extends StatefulWidget {
|
||||||
final String path;
|
final String path;
|
||||||
final bool openExternal;
|
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
|
@override
|
||||||
State<FileViewer> createState() => _FileViewerState();
|
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
|
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
|
||||||
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
|
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
|
||||||
@@ -110,6 +122,16 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
context,
|
context,
|
||||||
widget.path,
|
widget.path,
|
||||||
openExternal: true,
|
openExternal: true,
|
||||||
|
remoteFile: widget.remoteFile,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case FileViewingActions.sendToChat:
|
||||||
|
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
|
||||||
|
break;
|
||||||
|
case FileViewingActions.saveToCloud:
|
||||||
|
AppRoutes.openInternalSaveToFolder(
|
||||||
|
context,
|
||||||
|
widget.remoteFile!,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case FileViewingActions.share:
|
case FileViewingActions.share:
|
||||||
@@ -154,6 +176,24 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
dense: true,
|
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(
|
const PopupMenuItem(
|
||||||
value: FileViewingActions.share,
|
value: FileViewingActions.share,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
|||||||
Reference in New Issue
Block a user