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: