From cb2c38aaa124cdabbf1f6105fe3262832b8d161d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sat, 9 May 2026 19:42:51 +0200 Subject: [PATCH] implemented native share intent support for android and ios with chat and folder pickers --- android/app/src/main/AndroidManifest.xml | 15 ++ android/build.gradle | 23 ++ ios/Podfile | 4 + ios/Runner/Info.plist | 13 ++ ios/Runner/Runner.entitlements | 1 + ios/Share Extension/Info.plist | 54 +++++ ios/Share Extension/MainInterface.storyboard | 25 +++ ios/Share Extension/SETUP.md | 93 ++++++++ .../Share Extension.entitlements | 10 + ios/Share Extension/ShareViewController.swift | 8 + .../talk/share_files_to_chat.dart | 21 ++ lib/app.dart | 20 ++ lib/main.dart | 6 + lib/routing/app_routes.dart | 28 +++ lib/share_intent/pending_share.dart | 15 ++ lib/share_intent/share_intent_listener.dart | 94 ++++++++ .../app/modules/files/bloc/files_bloc.dart | 16 ++ .../pages/files/widgets/add_file_menu.dart | 4 +- .../pages/share_intent/share_chat_picker.dart | 172 ++++++++++++++ .../share_intent/share_folder_picker.dart | 187 ++++++++++++++++ .../pages/share_intent/share_target_page.dart | 210 ++++++++++++++++++ lib/view/pages/talk/search_chat.dart | 9 +- .../pages/talk/widgets/chat_textfield.dart | 33 +-- lib/view/pages/talk/widgets/chat_tile.dart | 10 + pubspec.yaml | 1 + 25 files changed, 1046 insertions(+), 26 deletions(-) create mode 100644 ios/Share Extension/Info.plist create mode 100644 ios/Share Extension/MainInterface.storyboard create mode 100644 ios/Share Extension/SETUP.md create mode 100644 ios/Share Extension/Share Extension.entitlements create mode 100644 ios/Share Extension/ShareViewController.swift create mode 100644 lib/api/marianumcloud/talk/share_files_to_chat.dart create mode 100644 lib/share_intent/pending_share.dart create mode 100644 lib/share_intent/share_intent_listener.dart create mode 100644 lib/view/pages/share_intent/share_chat_picker.dart create mode 100644 lib/view/pages/share_intent/share_folder_picker.dart create mode 100644 lib/view/pages/share_intent/share_target_page.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index abe802a..3b12681 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,21 @@ + + + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle index bc157bd..17b32f3 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -9,6 +9,29 @@ rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } +// Pin every Android subproject to JVM 17 so plugins that ship Kotlin sources +// compiled with a higher target (e.g. receive_sharing_intent at 21) or stale +// Java compatibility (e.g. home_widget at 1.8) don't break the build under +// newer Gradle/Kotlin tooling. Registered before evaluationDependsOn so the +// afterEvaluate fires at the right point in the lifecycle. +subprojects { sub -> + sub.afterEvaluate { + if (sub.hasProperty('android')) { + sub.android { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + } + sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = '17' + } + } + } +} + subprojects { project.evaluationDependsOn(':app') } diff --git a/ios/Podfile b/ios/Podfile index b34a8eb..a603498 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -34,6 +34,10 @@ target 'Runner' do pod 'PhoneNumberKit', '~> 3.7.6' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + target 'Share Extension' do + inherit! :search_paths + end # target 'RunnerTests' do # inherit! :search_paths # end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ccbfe9c..ad33a62 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,19 @@ + AppGroupId + $(CUSTOM_GROUP_ID) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 3691a2f..711c2c5 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -7,6 +7,7 @@ com.apple.security.application-groups group.eu.mhsl.marianum.mobile.client.widget + group.eu.mhsl.marianum.mobile.client.share diff --git a/ios/Share Extension/Info.plist b/ios/Share Extension/Info.plist new file mode 100644 index 0000000..627a72d --- /dev/null +++ b/ios/Share Extension/Info.plist @@ -0,0 +1,54 @@ + + + + + AppGroupId + $(CUSTOM_GROUP_ID) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Marianum Fulda + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionAttributes + + PHSupportedMediaTypes + + Video + Image + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsImageWithMaxCount + 10 + NSExtensionActivationSupportsMovieWithMaxCount + 10 + NSExtensionActivationSupportsFileWithMaxCount + 10 + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/ios/Share Extension/MainInterface.storyboard b/ios/Share Extension/MainInterface.storyboard new file mode 100644 index 0000000..1746985 --- /dev/null +++ b/ios/Share Extension/MainInterface.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Share Extension/SETUP.md b/ios/Share Extension/SETUP.md new file mode 100644 index 0000000..6e9031e --- /dev/null +++ b/ios/Share Extension/SETUP.md @@ -0,0 +1,93 @@ +# iOS Share Extension — Xcode Setup + +Die Quellen unter `ios/Share Extension/` müssen einmalig in Xcode als **Share Extension Target** verdrahtet werden — analog zur `TimetableWidgetExtension`. Erst danach taucht „Marianum Fulda" im System-Share-Sheet auf. + +## Schritt 1 — Share-Extension-Target anlegen + +1. `ios/Runner.xcworkspace` in Xcode öffnen. +2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links. +3. **iOS → Share Extension** wählen. +4. Eigenschaften: + - Product Name: `Share Extension` (mit Leerzeichen, exakt so — der Ordnername und Podfile-Eintrag matchen). + - Bundle Identifier: `eu.mhsl.marianum.mobile.client.Share-Extension`. + - Language: Swift. + - Embed in: Runner. +5. Beim Activate-Scheme-Dialog auf **Cancel** klicken. +6. Deployment Target = mind. iOS 12.0 (Plugin-Mindestanforderung). + +## Schritt 2 — Vorhandene Quelldateien ins Target ziehen + +Xcode legt Dummy-Dateien an. Diese **löschen** (Move to Trash). Dann: + +1. Sidebar → Rechtsklick auf den Ordner `Share Extension` → **Add Files to "Runner"…** +2. Im File-Picker zu `ios/Share Extension/` navigieren und folgende Dateien selektieren: + - `ShareViewController.swift` + - `Info.plist` + - `MainInterface.storyboard` + - `Share Extension.entitlements` +3. **Wichtig**: bei „Add to targets" nur `Share Extension` ankreuzen, **nicht** Runner. + +## Schritt 3 — App Group aktivieren + +Beide Targets brauchen die App-Group-Berechtigung, damit die Extension geteilte Dateien für die Hauptapp im gemeinsamen Container ablegen kann. + +1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**. + - Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.share` (zusätzlich zur bereits existierenden Widget-Group). +2. Dasselbe für **Share Extension**-Target — mit derselben Group-ID `group.eu.mhsl.marianum.mobile.client.share`. + +Im Apple-Developer-Portal muss diese App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl. + +## Schritt 4 — User-Defined Build Setting `CUSTOM_GROUP_ID` + +Beide Targets brauchen das User-Defined Setting, das in `Runner/Info.plist` und `Share Extension/Info.plist` als `$(CUSTOM_GROUP_ID)` referenziert wird. + +1. **Runner** → Build Settings → `+` (oben links) → **Add User-Defined Setting**. + - Name: `CUSTOM_GROUP_ID` + - Wert: `group.eu.mhsl.marianum.mobile.client.share` +2. Dasselbe für **Share Extension**-Target. + +## Schritt 5 — Entitlements verlinken + +1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` zeigt bereits auf `Runner/Runner.entitlements` (jetzt mit beiden Groups). +2. **Share Extension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `Share Extension/Share Extension.entitlements` setzen. + +## Schritt 6 — Info.plist-Pfad + +**Share Extension** → Build Settings → `INFOPLIST_FILE` → auf `Share Extension/Info.plist` setzen. + +## Schritt 7 — Build Phases reorder + +Damit das Plugin-Modul vom Extension-Target gefunden wird: + +1. **Runner**-Target → **Build Phases**. +2. `Embed Foundation Extensions` per Drag-and-Drop **vor** `Thin Binary` ziehen. + +## Schritt 8 — Pods installieren + +```bash +cd ios && pod install +``` + +Der Podfile-Eintrag (`target 'Share Extension' do inherit! :search_paths end`) ist bereits vorhanden. + +## Schritt 9 — Build & Run + +1. Scheme `Runner` wählen → Run auf Device oder Simulator (≥ iOS 12). +2. Foto in der Fotos-App auswählen → Teilen → „Marianum Fulda" sollte erscheinen. +3. Auswahl → App öffnet sich, ShareTargetPage erscheint. + +## Troubleshooting + +- **Error: No such module 'receive_sharing_intent'** + → Schritt 7 (Build Phases reorder) wurde übersprungen. +- **Error: ‚Frameworks' not allowed in extension** + → In Build Settings der Share Extension `Other Linker Flags` und `Framework Search Paths` leeren (nur die geerbten Pod-Pfade behalten). +- **Share-Sheet zeigt App nicht an** + → `NSExtensionActivationRule`-Limits in `Share Extension/Info.plist` zu klein? Werte testweise erhöhen. Außerdem: App muss **mindestens einmal nach Install** geöffnet worden sein, sonst wird die Extension von iOS nicht registriert. +- **Files kommen mit `nil` Pfad an** + → App-Group nicht konsistent. Prüfen, dass `CUSTOM_GROUP_ID` in beiden Targets identisch ist und die Entitlement-Files dieselbe Group enthalten. + +## Was am Mac noch zu tun ist + +- Schritte 1–8 oben (~15 Min). +- Auf physischem iPhone testen — Simulator-Share-Sheet ist eingeschränkt. diff --git a/ios/Share Extension/Share Extension.entitlements b/ios/Share Extension/Share Extension.entitlements new file mode 100644 index 0000000..80e2a27 --- /dev/null +++ b/ios/Share Extension/Share Extension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.eu.mhsl.marianum.mobile.client.share + + + diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift new file mode 100644 index 0000000..74b3416 --- /dev/null +++ b/ios/Share Extension/ShareViewController.swift @@ -0,0 +1,8 @@ +import UIKit +import receive_sharing_intent + +class ShareViewController: RSIShareViewController { + override func shouldAutoRedirect() -> Bool { + return true + } +} diff --git a/lib/api/marianumcloud/talk/share_files_to_chat.dart b/lib/api/marianumcloud/talk/share_files_to_chat.dart new file mode 100644 index 0000000..9409e39 --- /dev/null +++ b/lib/api/marianumcloud/talk/share_files_to_chat.dart @@ -0,0 +1,21 @@ +import '../files_sharing/file_sharing_api.dart'; +import '../files_sharing/file_sharing_api_params.dart'; + +/// WebDAV folder under which Talk-shared files are uploaded before being +/// linked into a chat. +const String talkShareFolder = 'MarianumMobile'; + +/// Posts each already-uploaded WebDAV path as a Talk share (ShareType 10) to +/// the given conversation token. Calls run concurrently — the server accepts +/// parallel posts and the picker UI is blocked anyway, so we shouldn't pay +/// O(n*RTT) latency per share. +Future shareFilesToChat({ + required String token, + required List remoteFilePaths, +}) => Future.wait( + remoteFilePaths.map( + (path) => FileSharingApi().share( + FileSharingApiParams(shareType: 10, shareWith: token, path: path), + ), + ), +); diff --git a/lib/app.dart b/lib/app.dart index 85cea47..045db68 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -14,6 +14,7 @@ import 'notification/notification_controller.dart'; import 'notification/notification_tasks.dart'; import 'notification/notify_updater.dart'; import 'routing/app_routes.dart'; +import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; @@ -67,6 +68,20 @@ class _AppState extends State with WidgetsBindingObserver { AppRoutes.goToTab(context, Modules.timetable); } + void _handlePendingShare() { + if (!mounted) return; + final share = ShareIntentListener.pending.value; + if (share == null) return; + // A second share arriving while a previous share-flow page is still on + // the stack would otherwise leave the old page sitting on top with stale + // (already-cleared) file paths. Reset to the tab root before pushing. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + AppRoutes.openShareTarget(context, share); + } + @override void initState() { super.initState(); @@ -112,6 +127,9 @@ class _AppState extends State with WidgetsBindingObserver { ); } unawaited(_handlePendingWidgetNavigation()); + ShareIntentListener.instance.attach(); + ShareIntentListener.pending.addListener(_handlePendingShare); + _handlePendingShare(); }); _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { @@ -158,6 +176,8 @@ class _AppState extends State with WidgetsBindingObserver { _refetchChats.cancel(); _updateTimings.cancel(); _timetableWidgetSync?.cancel(); + ShareIntentListener.pending.removeListener(_handlePendingShare); + ShareIntentListener.instance.detach(); Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); diff --git a/lib/main.dart b/lib/main.dart index 6fb6d0a..ad7199e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'app.dart'; import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; +import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; @@ -68,6 +69,7 @@ Future main() async { HydratedBloc.storage = storage; }), AccountData().waitForPopulation(), + ShareIntentListener.instance.initialize(), ]; log('starting app initialisation...'); @@ -209,6 +211,10 @@ class _MainState extends State
{ previous.status != current.status, listener: (context, accountState) { if (accountState.status != AccountStatus.loggedOut) return; + // A pending share would otherwise survive logout and be + // re-applied after re-login with file paths the OS may + // already have evicted from the cache. + ShareIntentListener.instance.clear(); // Routes pushed via AppRoutes (e.g. Settings) live on the // root navigator and survive the home swap below, so they // would still cover the Login screen after logout. Pop diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 6f79929..3f46286 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../main.dart'; import '../model/account_data.dart'; +import '../share_intent/pending_share.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'; @@ -17,6 +18,9 @@ import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/settings.dart'; +import '../view/pages/share_intent/share_chat_picker.dart'; +import '../view/pages/share_intent/share_folder_picker.dart'; +import '../view/pages/share_intent/share_target_page.dart'; import '../view/pages/talk/chat_view.dart'; import '../view/pages/talk/details/message_reactions.dart'; import '../view/pages/talk/talk_navigator.dart'; @@ -90,6 +94,30 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const Roomplan()); } + static void openShareTarget(BuildContext context, PendingShare share) { + pushScreen( + context, + withNavBar: false, + screen: ShareTargetPage(share: share), + ); + } + + static void openShareChatPicker(BuildContext context, PendingShare share) { + pushScreen( + context, + withNavBar: false, + screen: ShareChatPicker(share: share), + ); + } + + static void openShareFolderPicker(BuildContext context, PendingShare share) { + pushScreen( + context, + withNavBar: false, + screen: ShareFolderPicker(share: share), + ); + } + static void openMessageReactions( BuildContext context, String token, diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart new file mode 100644 index 0000000..226b078 --- /dev/null +++ b/lib/share_intent/pending_share.dart @@ -0,0 +1,15 @@ +class PendingShare { + final List filePaths; + final String? text; + final DateTime receivedAt; + + const PendingShare({ + required this.filePaths, + required this.text, + required this.receivedAt, + }); + + bool get hasFiles => filePaths.isNotEmpty; + bool get hasText => text != null && text!.isNotEmpty; + bool get isEmpty => !hasFiles && !hasText; +} diff --git a/lib/share_intent/share_intent_listener.dart b/lib/share_intent/share_intent_listener.dart new file mode 100644 index 0000000..855ec18 --- /dev/null +++ b/lib/share_intent/share_intent_listener.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +import 'pending_share.dart'; + +/// Bridges native share intents (Android ACTION_SEND, iOS Share Extension) +/// into a single [ValueNotifier] that the app routes off of. +class ShareIntentListener { + ShareIntentListener._(); + static final ShareIntentListener instance = ShareIntentListener._(); + + static final ValueNotifier pending = ValueNotifier(null); + + StreamSubscription>? _streamSub; + bool _initialized = false; + + /// Reads the cold-start payload exactly once. Call from `main()` before + /// `runApp` so the share is queued before the UI mounts. + Future initialize() async { + if (_initialized) return; + _initialized = true; + try { + final initial = await ReceiveSharingIntent.instance.getInitialMedia(); + final share = _toPendingShare(initial); + if (share != null) pending.value = share; + await ReceiveSharingIntent.instance.reset(); + } catch (e) { + debugPrint('ShareIntentListener.initialize failed: $e'); + } + } + + /// Subscribes to warm-share stream events. Safe to call multiple times. + void attach() { + _streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen( + (items) { + final share = _toPendingShare(items); + if (share != null) pending.value = share; + }, + onError: (Object e) => + debugPrint('ShareIntentListener stream error: $e'), + ); + } + + /// Cancels the warm-share subscription. The singleton survives, so a + /// subsequent [attach] re-subscribes. + void detach() { + _streamSub?.cancel(); + _streamSub = null; + } + + /// Discards the current share and removes any temp files the plugin copied + /// into the app cache. Idempotent. + void clear() { + final current = pending.value; + pending.value = null; + if (current != null) { + for (final path in current.filePaths) { + try { + final f = File(path); + if (f.existsSync()) f.deleteSync(); + } catch (_) { + // best-effort cleanup; OS will reclaim cache eventually + } + } + } + unawaited(ReceiveSharingIntent.instance.reset()); + } + + PendingShare? _toPendingShare(List items) { + if (items.isEmpty) return null; + final files = []; + final texts = []; + for (final item in items) { + switch (item.type) { + case SharedMediaType.image: + case SharedMediaType.video: + case SharedMediaType.file: + files.add(item.path); + case SharedMediaType.text: + case SharedMediaType.url: + texts.add(item.path); + } + } + if (files.isEmpty && texts.isEmpty) return null; + return PendingShare( + filePaths: files, + text: texts.isEmpty ? null : texts.join('\n'), + receivedAt: DateTime.now(), + ); + } +} diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index 27e5f5e..e8753c9 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -1,3 +1,5 @@ +import 'package:collection/collection.dart'; + import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../infrastructure/loadable_state/loading_error.dart'; @@ -53,12 +55,24 @@ class FilesBloc Future _query(List path) async { final pathString = path.isEmpty ? '/' : path.join('/'); + // Drop late results when [setPath] has navigated elsewhere or when the + // bloc has been disposed (e.g. share-flow picker closed mid-fetch). Both + // would otherwise corrupt state or hit "add after close" on the stream. + const pathEquality = ListEquality(); + bool isStale() { + if (isClosed) return true; + final inner = innerState; + if (inner == null) return false; + return !pathEquality.equals(inner.currentPath, path); + } + Object? capturedError; ListFilesResponse? listing; try { listing = await repo.data.listFiles( pathString, onCacheData: (cached) { + if (isStale()) return; // Cached payload arrives before the network call settles. Surface it // immediately via Emit so the listing is visible while isLoading // stays true and the top loading bar keeps spinning. @@ -73,6 +87,8 @@ class FilesBloc capturedError = e; } + if (isStale()) return; + if (listing != null) { listing.files.removeWhere( (file) => file.name.isEmpty || file.name == path.lastOrNull, diff --git a/lib/view/pages/files/widgets/add_file_menu.dart b/lib/view/pages/files/widgets/add_file_menu.dart index 508fe7d..9f8565d 100644 --- a/lib/view/pages/files/widgets/add_file_menu.dart +++ b/lib/view/pages/files/widgets/add_file_menu.dart @@ -21,7 +21,7 @@ void showAddFileSheet( title: const Text('Ordner erstellen'), onTap: () { Navigator.of(sheetCtx).pop(); - _showCreateFolderDialog(context, bloc); + showCreateFolderDialog(context, bloc); }, ), ListTile( @@ -56,7 +56,7 @@ void showAddFileSheet( ); } -void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { +void showCreateFolderDialog(BuildContext context, FilesBloc bloc) { final inputController = TextEditingController(); showDialog( context: context, diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart new file mode 100644 index 0000000..b9fb76d --- /dev/null +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +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/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/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'; +import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../widget/info_dialog.dart'; +import '../../../widget/placeholder_view.dart'; +import '../files/files_upload_dialog.dart'; +import '../talk/search_chat.dart'; +import '../talk/widgets/chat_tile.dart'; + +class ShareChatPicker extends StatelessWidget { + final PendingShare share; + + const ShareChatPicker({super.key, required this.share}); + + @override + Widget build(BuildContext context) { + final talkSettings = context.watch().val().talkSettings; + return Scaffold( + appBar: AppBar( + title: const Text('Chat auswählen'), + actions: [ + Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.search), + onPressed: () { + final rooms = ctx.read().state.data?.rooms; + if (rooms == null) return; + showSearch( + context: ctx, + delegate: SearchChat( + rooms.data.where((r) => r.readOnly == 0).toList(), + onTapOverride: (room) { + Navigator.of(ctx).pop(); + _onChatPicked(ctx, room); + }, + ), + ); + }, + ), + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) { + final rooms = state.rooms; + if (rooms == null) return const SizedBox.shrink(); + final sorted = rooms + .sortBy( + lastActivity: true, + favoritesToTop: talkSettings.sortFavoritesToTop, + unreadToTop: talkSettings.sortUnreadToTop, + ) + // Hide chats the user can't write to (announcement channels, + // archived rooms, …) — uploading there would only fail at the + // share-API call with 403. + .where((r) => r.readOnly == 0) + .toList(); + if (sorted.isEmpty) { + return const PlaceholderView( + icon: Icons.chat_bubble_outline, + text: 'Keine schreibbaren Chats verfügbar', + ); + } + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: sorted.length, + itemBuilder: (context, i) => ChatTile( + data: sorted[i], + disableContextActions: true, + onTapOverride: (room) => _onChatPicked(context, room), + ), + ); + }, + ), + ); + } + + Future _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 _afterFilesUploaded( + BuildContext context, + GetRoomResponseObject room, + List 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( + context: context, + barrierDismissible: false, + builder: (_) => const PopScope( + canPop: false, + child: Center(child: CircularProgressIndicator()), + ), + ), + ); + + 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; + } + if (!context.mounted) return; + // The blocking dialog is popped together with the picker by + // _setDraftAndOpenChat's popUntil(isFirst) below. + _setDraftAndOpenChat(context, room); + } + + void _setDraftAndOpenChat(BuildContext context, GetRoomResponseObject room) { + if (share.hasText) { + final settings = context.read(); + 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); + } +} diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart new file mode 100644 index 0000000..2d2a9dc --- /dev/null +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -0,0 +1,187 @@ +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 '../../../routing/app_routes.dart'; +import '../../../share_intent/pending_share.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'; +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/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; + + const ShareFolderPicker({super.key, required this.share}); + + @override + Widget build(BuildContext context) => + BlocModule>( + create: (_) => FilesBloc(), + child: (context, _, _) => _ShareFolderPickerView(share: share), + ); +} + +class _ShareFolderPickerView extends StatefulWidget { + final PendingShare share; + const _ShareFolderPickerView({required this.share}); + + @override + State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState(); +} + +class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { + late final SettingsCubit _settings; + late SortOption _currentSort; + late bool _ascending; + + @override + void initState() { + super.initState(); + _settings = context.read(); + _currentSort = _settings.val().fileSettings.sortBy; + _ascending = _settings.val().fileSettings.ascending; + } + + void _enter(FilesBloc bloc, List currentPath, String folderName) { + bloc.setPath([...currentPath, folderName]); + } + + void _goUp(FilesBloc bloc, List currentPath) { + if (currentPath.isEmpty) return; + bloc.setPath(currentPath.sublist(0, currentPath.length - 1)); + } + + Future _uploadHere(List currentPath) async { + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: widget.share.filePaths, + remotePath: currentPath.join('/'), + onUploadFinished: (_) => _afterUploaded(currentPath), + ), + ); + } + + void _afterUploaded(List 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(); + return BlocBuilder>( + buildWhen: (a, b) => a.data?.currentPath != b.data?.currentPath, + builder: (_, outerState) { + final currentPath = outerState.data?.currentPath ?? const []; + return PopScope( + // Back navigates one level up while inside a sub-folder; only the + // root level actually closes the picker. Matches the standard + // files-app pattern and keeps the AppBar back-arrow consistent + // with the chat picker. + canPop: currentPath.isEmpty, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (currentPath.isNotEmpty) _goUp(bloc, currentPath); + }, + child: _buildScaffold(context, bloc, currentPath), + ); + }, + ); + } + + Widget _buildScaffold( + BuildContext context, + FilesBloc bloc, + List currentPath, + ) => Scaffold( + appBar: AppBar( + title: Text( + currentPath.isEmpty ? 'Ordner wählen' : '/${currentPath.join('/')}', + overflow: TextOverflow.ellipsis, + ), + actions: [ + IconButton( + icon: const Icon(Icons.create_new_folder_outlined), + tooltip: 'Ordner erstellen', + onPressed: () => showCreateFolderDialog(context, bloc), + ), + FilesSortActions( + currentSort: _currentSort, + ascending: _ascending, + onDirectionChanged: (e) { + setState(() { + _ascending = e; + _settings.val(write: true).fileSettings.ascending = e; + }); + }, + onSortChanged: (e) { + setState(() { + _currentSort = e; + _settings.val(write: true).fileSettings.sortBy = e; + }); + }, + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + heroTag: 'shareUploadHere', + onPressed: () => _uploadHere(currentPath), + icon: const Icon(Icons.upload), + label: const Text('Hier hochladen'), + ), + body: LoadableStateConsumer( + isReady: (state) => state.listing != null, + child: (state, _) { + final listing = state.listing!; + final entries = listing.sortBy( + sortOption: _currentSort, + foldersToTop: _settings.val().fileSettings.sortFoldersToTop, + reversed: _ascending, + ); + + if (entries.isEmpty) { + return PlaceholderView( + icon: Icons.folder_off_rounded, + text: state.currentPath.isEmpty + ? 'Leer. Du kannst hier direkt hochladen.' + : 'Ordner ist leer. Du kannst hier hochladen.', + ); + } + + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: entries.length, + itemBuilder: (context, i) { + final entry = entries[i]; + if (entry.isDirectory) { + return ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(entry.name), + trailing: const Icon(Icons.chevron_right), + onTap: () => _enter(bloc, state.currentPath, entry.name), + ); + } + return ListTile( + enabled: false, + leading: const Icon(Icons.description_outlined), + title: Text(entry.name), + ); + }, + ); + }, + ), + ); +} diff --git a/lib/view/pages/share_intent/share_target_page.dart b/lib/view/pages/share_intent/share_target_page.dart new file mode 100644 index 0000000..57227d3 --- /dev/null +++ b/lib/view/pages/share_intent/share_target_page.dart @@ -0,0 +1,210 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../../routing/app_routes.dart'; +import '../../../share_intent/pending_share.dart'; +import '../../../share_intent/share_intent_listener.dart'; + +class ShareTargetPage extends StatelessWidget { + final PendingShare share; + + const ShareTargetPage({super.key, required this.share}); + + static const _imageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.heic', + '.heif', + '.bmp', + }; + + bool _isImagePath(String path) { + final lower = path.toLowerCase(); + return _imageExtensions.any(lower.endsWith); + } + + String _appBarTitle() { + if (share.hasFiles && share.hasText) return 'Inhalte teilen'; + if (share.hasFiles) { + return share.filePaths.length == 1 + ? '1 Datei teilen' + : '${share.filePaths.length} Dateien teilen'; + } + return 'Inhalt teilen'; + } + + @override + Widget build(BuildContext context) => PopScope( + onPopInvokedWithResult: (didPop, _) { + if (didPop) ShareIntentListener.instance.clear(); + }, + child: Scaffold( + appBar: AppBar(title: Text(_appBarTitle())), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (share.hasFiles) _buildFilePreview(context), + if (share.hasFiles && share.hasText) + const SizedBox(height: 12), + if (share.hasText) _buildTextPreview(context), + ], + ), + ), + ), + const Divider(height: 1), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Column( + children: [ + Icon( + Icons.ios_share, + size: 44, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + Text( + 'Wo möchtest du teilen?', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.chat_bubble_outline), + title: const Text('An Chat senden'), + subtitle: const Text( + 'Datei oder Text in einem Talk-Chat teilen', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => + AppRoutes.openShareChatPicker(context, share), + ), + ListTile( + enabled: share.hasFiles, + leading: const Icon(Icons.folder_outlined), + title: const Text('In Dateien speichern'), + subtitle: Text( + share.hasFiles + ? 'In einen Nextcloud-Ordner hochladen' + : 'Nur für Dateien verfügbar', + ), + trailing: const Icon(Icons.chevron_right), + onTap: share.hasFiles + ? () => AppRoutes.openShareFolderPicker(context, share) + : null, + ), + ], + ), + ), + ), + ], + ), + ), + ); + + Widget _buildFilePreview(BuildContext context) { + if (share.filePaths.length == 1) { + final path = share.filePaths.first; + final name = path.split(Platform.pathSeparator).last; + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: _isImagePath(path) + ? Image.file( + File(path), + fit: BoxFit.contain, + // Decode at most ~1080px so 50-MP gallery photos don't + // balloon the decode buffer just to render at <320px high. + cacheWidth: 1080, + errorBuilder: (_, _, _) => _fileFallbackLarge(name), + ) + : _fileFallbackLarge(name), + ), + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: share.filePaths.length, + itemBuilder: (context, i) { + final path = share.filePaths[i]; + final name = path.split(Platform.pathSeparator).last; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: _isImagePath(path) + ? Image.file( + File(path), + fit: BoxFit.cover, + // Grid tiles are ~half-screen wide; 480px decode is + // sharp on 3x displays without blowing up memory when + // many files are shared at once. + cacheWidth: 480, + errorBuilder: (_, _, _) => _fileFallbackLarge(name), + ) + : _fileFallbackLarge(name), + ); + }, + ); + } + + Widget _buildTextPreview(BuildContext context) => Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + share.text!, + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), + ), + ); + + Widget _fileFallbackLarge(String name) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.insert_drive_file_outlined, size: 64), + const SizedBox(height: 8), + Text( + name, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ); +} diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart index 9f36bf7..768daba 100644 --- a/lib/view/pages/talk/search_chat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -5,8 +5,9 @@ import 'widgets/chat_tile.dart'; class SearchChat extends SearchDelegate { List chats; + final void Function(GetRoomResponseObject room)? onTapOverride; - SearchChat(this.chats); + SearchChat(this.chats, {this.onTapOverride}); @override List? buildActions(BuildContext context) => [ @@ -34,7 +35,11 @@ class SearchChat extends SearchDelegate { itemCount: items.length, itemBuilder: (context, index) { var item = items.elementAt(index); - return ChatTile(data: item, disableContextActions: true); + return ChatTile( + data: item, + disableContextActions: true, + onTapOverride: onTapOverride, + ); }, ); } diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index f4067b8..26dd9db 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -1,15 +1,13 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; 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/marianumcloud/files_sharing/file_sharing_api.dart'; -import '../../../../api/marianumcloud/files_sharing/file_sharing_api_params.dart'; import '../../../../api/marianumcloud/talk/send_message/send_message.dart'; import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart'; +import '../../../../api/marianumcloud/talk/share_files_to_chat.dart'; import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -36,30 +34,21 @@ class _ChatTextfieldState extends State { final AsyncActionController _sendController = AsyncActionController(); String? _sendError; - void share(String shareFolder, List filePaths) { - for (final element in filePaths) { - final fileName = element.split(Platform.pathSeparator).last; - FileSharingApi() - .share( - FileSharingApiParams( - shareType: 10, - shareWith: widget.sendToToken, - path: '$shareFolder/$fileName', - ), - ) - .then((_) { - if (mounted) context.read().refresh(); - }); - } + void share(List uploadedRemotePaths) { + shareFilesToChat( + token: widget.sendToToken, + remoteFilePaths: uploadedRemotePaths, + ).then((_) { + if (mounted) context.read().refresh(); + }); } Future mediaUpload(List? paths) async { if (paths == null) return; - const shareFolder = 'MarianumMobile'; unawaited( WebdavApi.webdav.then( - (webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')), + (webdav) => webdav.mkcol(PathUri.parse('/$talkShareFolder')), ), ); @@ -70,8 +59,8 @@ class _ChatTextfieldState extends State { withNavBar: false, screen: FilesUploadDialog( filePaths: paths, - remotePath: shareFolder, - onUploadFinished: (uploaded) => share(shareFolder, uploaded), + remotePath: talkShareFolder, + onUploadFinished: share, uniqueNames: true, ), ), diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index ddb0127..4c2c998 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -25,11 +25,17 @@ class ChatTile extends StatefulWidget { final bool disableContextActions; final bool hasDraft; + /// When set, replaces the default tap-into-chat behaviour. Used by the + /// share-intent picker to surface the room selection without opening the + /// chat view itself. + final void Function(GetRoomResponseObject room)? onTapOverride; + const ChatTile({ super.key, required this.data, this.disableContextActions = false, this.hasDraft = false, + this.onTapOverride, }); @override @@ -143,6 +149,10 @@ class _ChatTileState extends State { ), ), onTap: () { + if (widget.onTapOverride != null) { + widget.onTapOverride!(widget.data); + return; + } if (selfUsername == null) return; unawaited(_setCurrentAsRead()); final view = ChatView( diff --git a/pubspec.yaml b/pubspec.yaml index 24ef739..98ddb1e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,7 @@ dependencies: time_range_picker: ^2.3.0 url_launcher: ^6.3.1 enough_icalendar: ^0.17.0 + receive_sharing_intent: ^1.8.1 dev_dependencies: flutter_test: