implemented native share intent support for android and ios with chat and folder pickers
This commit is contained in:
@@ -25,6 +25,21 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
<data android:mimeType="application/*" />
|
||||||
|
<data android:mimeType="text/*" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
<data android:mimeType="video/*" />
|
||||||
|
<data android:mimeType="application/*" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
|||||||
@@ -9,6 +9,29 @@ rootProject.buildDir = '../build'
|
|||||||
subprojects {
|
subprojects {
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
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 {
|
subprojects {
|
||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ target 'Runner' do
|
|||||||
pod 'PhoneNumberKit', '~> 3.7.6'
|
pod 'PhoneNumberKit', '~> 3.7.6'
|
||||||
|
|
||||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
|
|
||||||
|
target 'Share Extension' do
|
||||||
|
inherit! :search_paths
|
||||||
|
end
|
||||||
# target 'RunnerTests' do
|
# target 'RunnerTests' do
|
||||||
# inherit! :search_paths
|
# inherit! :search_paths
|
||||||
# end
|
# end
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>AppGroupId</key>
|
||||||
|
<string>$(CUSTOM_GROUP_ID)</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||||
|
<string>group.eu.mhsl.marianum.mobile.client.share</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>AppGroupId</key>
|
||||||
|
<string>$(CUSTOM_GROUP_ID)</string>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Marianum Fulda</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>PHSupportedMediaTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>Video</string>
|
||||||
|
<string>Image</string>
|
||||||
|
</array>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsText</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionMainStoryboard</key>
|
||||||
|
<string>MainInterface</string>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
|
||||||
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Share View Controller-->
|
||||||
|
<scene sceneID="ceB-am-kn3">
|
||||||
|
<objects>
|
||||||
|
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="wbc-yd-nQP">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<viewLayoutGuide key="safeArea" id="bcg-RR-FT9"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="CzN-xT-EUl" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.eu.mhsl.marianum.mobile.client.share</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import UIKit
|
||||||
|
import receive_sharing_intent
|
||||||
|
|
||||||
|
class ShareViewController: RSIShareViewController {
|
||||||
|
override func shouldAutoRedirect() -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> shareFilesToChat({
|
||||||
|
required String token,
|
||||||
|
required List<String> remoteFilePaths,
|
||||||
|
}) => Future.wait(
|
||||||
|
remoteFilePaths.map(
|
||||||
|
(path) => FileSharingApi().share(
|
||||||
|
FileSharingApiParams(shareType: 10, shareWith: token, path: path),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -14,6 +14,7 @@ import 'notification/notification_controller.dart';
|
|||||||
import 'notification/notification_tasks.dart';
|
import 'notification/notification_tasks.dart';
|
||||||
import 'notification/notify_updater.dart';
|
import 'notification/notify_updater.dart';
|
||||||
import 'routing/app_routes.dart';
|
import 'routing/app_routes.dart';
|
||||||
|
import 'share_intent/share_intent_listener.dart';
|
||||||
import 'state/app/modules/app_modules.dart';
|
import 'state/app/modules/app_modules.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||||
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
@@ -67,6 +68,20 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
AppRoutes.goToTab(context, Modules.timetable);
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -112,6 +127,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
unawaited(_handlePendingWidgetNavigation());
|
unawaited(_handlePendingWidgetNavigation());
|
||||||
|
ShareIntentListener.instance.attach();
|
||||||
|
ShareIntentListener.pending.addListener(_handlePendingShare);
|
||||||
|
_handlePendingShare();
|
||||||
});
|
});
|
||||||
|
|
||||||
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
|
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
@@ -158,6 +176,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
_refetchChats.cancel();
|
_refetchChats.cancel();
|
||||||
_updateTimings.cancel();
|
_updateTimings.cancel();
|
||||||
_timetableWidgetSync?.cancel();
|
_timetableWidgetSync?.cancel();
|
||||||
|
ShareIntentListener.pending.removeListener(_handlePendingShare);
|
||||||
|
ShareIntentListener.instance.detach();
|
||||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import 'app.dart';
|
|||||||
import 'background/widget_background_task.dart';
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'model/account_data.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_bloc.dart';
|
||||||
import 'state/app/modules/account/bloc/account_state.dart';
|
import 'state/app/modules/account/bloc/account_state.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
|
||||||
@@ -68,6 +69,7 @@ Future<void> main() async {
|
|||||||
HydratedBloc.storage = storage;
|
HydratedBloc.storage = storage;
|
||||||
}),
|
}),
|
||||||
AccountData().waitForPopulation(),
|
AccountData().waitForPopulation(),
|
||||||
|
ShareIntentListener.instance.initialize(),
|
||||||
];
|
];
|
||||||
|
|
||||||
log('starting app initialisation...');
|
log('starting app initialisation...');
|
||||||
@@ -209,6 +211,10 @@ class _MainState extends State<Main> {
|
|||||||
previous.status != current.status,
|
previous.status != current.status,
|
||||||
listener: (context, accountState) {
|
listener: (context, accountState) {
|
||||||
if (accountState.status != AccountStatus.loggedOut) return;
|
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
|
// Routes pushed via AppRoutes (e.g. Settings) live on the
|
||||||
// root navigator and survive the home swap below, so they
|
// root navigator and survive the home swap below, so they
|
||||||
// would still cover the Login screen after logout. Pop
|
// would still cover the Login screen after logout. Pop
|
||||||
|
|||||||
@@ -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 '../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 '../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';
|
||||||
@@ -17,6 +18,9 @@ import '../view/pages/more/roomplan/roomplan.dart';
|
|||||||
import '../view/pages/more/share/qr_share_view.dart';
|
import '../view/pages/more/share/qr_share_view.dart';
|
||||||
import '../view/pages/settings/modules_settings_page.dart';
|
import '../view/pages/settings/modules_settings_page.dart';
|
||||||
import '../view/pages/settings/settings.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/chat_view.dart';
|
||||||
import '../view/pages/talk/details/message_reactions.dart';
|
import '../view/pages/talk/details/message_reactions.dart';
|
||||||
import '../view/pages/talk/talk_navigator.dart';
|
import '../view/pages/talk/talk_navigator.dart';
|
||||||
@@ -90,6 +94,30 @@ class AppRoutes {
|
|||||||
pushScreen(context, withNavBar: false, screen: const Roomplan());
|
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(
|
static void openMessageReactions(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String token,
|
String token,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
class PendingShare {
|
||||||
|
final List<String> 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;
|
||||||
|
}
|
||||||
@@ -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<PendingShare?> pending = ValueNotifier(null);
|
||||||
|
|
||||||
|
StreamSubscription<List<SharedMediaFile>>? _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<void> 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<SharedMediaFile> items) {
|
||||||
|
if (items.isEmpty) return null;
|
||||||
|
final files = <String>[];
|
||||||
|
final texts = <String>[];
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
import '../../../../../api/errors/error_mapper.dart';
|
import '../../../../../api/errors/error_mapper.dart';
|
||||||
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||||
import '../../../infrastructure/loadable_state/loading_error.dart';
|
import '../../../infrastructure/loadable_state/loading_error.dart';
|
||||||
@@ -53,12 +55,24 @@ class FilesBloc
|
|||||||
Future<void> _query(List<String> path) async {
|
Future<void> _query(List<String> path) async {
|
||||||
final pathString = path.isEmpty ? '/' : path.join('/');
|
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<String>();
|
||||||
|
bool isStale() {
|
||||||
|
if (isClosed) return true;
|
||||||
|
final inner = innerState;
|
||||||
|
if (inner == null) return false;
|
||||||
|
return !pathEquality.equals(inner.currentPath, path);
|
||||||
|
}
|
||||||
|
|
||||||
Object? capturedError;
|
Object? capturedError;
|
||||||
ListFilesResponse? listing;
|
ListFilesResponse? listing;
|
||||||
try {
|
try {
|
||||||
listing = await repo.data.listFiles(
|
listing = await repo.data.listFiles(
|
||||||
pathString,
|
pathString,
|
||||||
onCacheData: (cached) {
|
onCacheData: (cached) {
|
||||||
|
if (isStale()) return;
|
||||||
// Cached payload arrives before the network call settles. Surface it
|
// Cached payload arrives before the network call settles. Surface it
|
||||||
// immediately via Emit so the listing is visible while isLoading
|
// immediately via Emit so the listing is visible while isLoading
|
||||||
// stays true and the top loading bar keeps spinning.
|
// stays true and the top loading bar keeps spinning.
|
||||||
@@ -73,6 +87,8 @@ class FilesBloc
|
|||||||
capturedError = e;
|
capturedError = e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStale()) return;
|
||||||
|
|
||||||
if (listing != null) {
|
if (listing != null) {
|
||||||
listing.files.removeWhere(
|
listing.files.removeWhere(
|
||||||
(file) => file.name.isEmpty || file.name == path.lastOrNull,
|
(file) => file.name.isEmpty || file.name == path.lastOrNull,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ void showAddFileSheet(
|
|||||||
title: const Text('Ordner erstellen'),
|
title: const Text('Ordner erstellen'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(sheetCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
_showCreateFolderDialog(context, bloc);
|
showCreateFolderDialog(context, bloc);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -56,7 +56,7 @@ void showAddFileSheet(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
|
void showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
|
||||||
final inputController = TextEditingController();
|
final inputController = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -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<SettingsCubit>().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<ChatListBloc>().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<ChatListBloc, ChatListState>(
|
||||||
|
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<void> _onChatPicked(
|
||||||
|
BuildContext context,
|
||||||
|
GetRoomResponseObject room,
|
||||||
|
) async {
|
||||||
|
if (share.hasFiles) {
|
||||||
|
try {
|
||||||
|
final webdav = await WebdavApi.webdav;
|
||||||
|
await webdav.mkcol(PathUri.parse('/$talkShareFolder'));
|
||||||
|
} catch (_) {
|
||||||
|
// mkcol throws when the folder already exists; ignore.
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await pushScreen(
|
||||||
|
context,
|
||||||
|
withNavBar: false,
|
||||||
|
screen: FilesUploadDialog(
|
||||||
|
filePaths: share.filePaths,
|
||||||
|
remotePath: talkShareFolder,
|
||||||
|
uniqueNames: true,
|
||||||
|
onUploadFinished: (uploaded) =>
|
||||||
|
_afterFilesUploaded(context, room, uploaded),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (share.hasText) {
|
||||||
|
_setDraftAndOpenChat(context, room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _afterFilesUploaded(
|
||||||
|
BuildContext context,
|
||||||
|
GetRoomResponseObject room,
|
||||||
|
List<String> uploadedRemotePaths,
|
||||||
|
) async {
|
||||||
|
// Block the picker UI while the share-API roundtrips run, otherwise the
|
||||||
|
// user can re-tap a chat and double-share the same files.
|
||||||
|
unawaited(
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => const PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FilesBloc, LoadableState<FilesState>>(
|
||||||
|
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<SettingsCubit>();
|
||||||
|
_currentSort = _settings.val().fileSettings.sortBy;
|
||||||
|
_ascending = _settings.val().fileSettings.ascending;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _enter(FilesBloc bloc, List<String> currentPath, String folderName) {
|
||||||
|
bloc.setPath([...currentPath, folderName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goUp(FilesBloc bloc, List<String> currentPath) {
|
||||||
|
if (currentPath.isEmpty) return;
|
||||||
|
bloc.setPath(currentPath.sublist(0, currentPath.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadHere(List<String> currentPath) async {
|
||||||
|
await pushScreen(
|
||||||
|
context,
|
||||||
|
withNavBar: false,
|
||||||
|
screen: FilesUploadDialog(
|
||||||
|
filePaths: widget.share.filePaths,
|
||||||
|
remotePath: currentPath.join('/'),
|
||||||
|
onUploadFinished: (_) => _afterUploaded(currentPath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _afterUploaded(List<String> targetPath) {
|
||||||
|
ShareIntentListener.instance.clear();
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
AppRoutes.openFolder(context, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bloc = context.read<FilesBloc>();
|
||||||
|
return BlocBuilder<FilesBloc, LoadableState<FilesState>>(
|
||||||
|
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<String> 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<FilesBloc, FilesState>(
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import 'widgets/chat_tile.dart';
|
|||||||
|
|
||||||
class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
||||||
List<GetRoomResponseObject> chats;
|
List<GetRoomResponseObject> chats;
|
||||||
|
final void Function(GetRoomResponseObject room)? onTapOverride;
|
||||||
|
|
||||||
SearchChat(this.chats);
|
SearchChat(this.chats, {this.onTapOverride});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
@@ -34,7 +35,11 @@ class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
|||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
var item = items.elementAt(index);
|
var item = items.elementAt(index);
|
||||||
return ChatTile(data: item, disableContextActions: true);
|
return ChatTile(
|
||||||
|
data: item,
|
||||||
|
disableContextActions: true,
|
||||||
|
onTapOverride: onTapOverride,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
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/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.dart';
|
||||||
import '../../../../api/marianumcloud/talk/send_message/send_message_params.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 '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
@@ -36,30 +34,21 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
final AsyncActionController _sendController = AsyncActionController();
|
final AsyncActionController _sendController = AsyncActionController();
|
||||||
String? _sendError;
|
String? _sendError;
|
||||||
|
|
||||||
void share(String shareFolder, List<String> filePaths) {
|
void share(List<String> uploadedRemotePaths) {
|
||||||
for (final element in filePaths) {
|
shareFilesToChat(
|
||||||
final fileName = element.split(Platform.pathSeparator).last;
|
token: widget.sendToToken,
|
||||||
FileSharingApi()
|
remoteFilePaths: uploadedRemotePaths,
|
||||||
.share(
|
).then((_) {
|
||||||
FileSharingApiParams(
|
|
||||||
shareType: 10,
|
|
||||||
shareWith: widget.sendToToken,
|
|
||||||
path: '$shareFolder/$fileName',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((_) {
|
|
||||||
if (mounted) context.read<ChatBloc>().refresh();
|
if (mounted) context.read<ChatBloc>().refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> mediaUpload(List<String>? paths) async {
|
Future<void> mediaUpload(List<String>? paths) async {
|
||||||
if (paths == null) return;
|
if (paths == null) return;
|
||||||
|
|
||||||
const shareFolder = 'MarianumMobile';
|
|
||||||
unawaited(
|
unawaited(
|
||||||
WebdavApi.webdav.then(
|
WebdavApi.webdav.then(
|
||||||
(webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')),
|
(webdav) => webdav.mkcol(PathUri.parse('/$talkShareFolder')),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,8 +59,8 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
withNavBar: false,
|
withNavBar: false,
|
||||||
screen: FilesUploadDialog(
|
screen: FilesUploadDialog(
|
||||||
filePaths: paths,
|
filePaths: paths,
|
||||||
remotePath: shareFolder,
|
remotePath: talkShareFolder,
|
||||||
onUploadFinished: (uploaded) => share(shareFolder, uploaded),
|
onUploadFinished: share,
|
||||||
uniqueNames: true,
|
uniqueNames: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ class ChatTile extends StatefulWidget {
|
|||||||
final bool disableContextActions;
|
final bool disableContextActions;
|
||||||
final bool hasDraft;
|
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({
|
const ChatTile({
|
||||||
super.key,
|
super.key,
|
||||||
required this.data,
|
required this.data,
|
||||||
this.disableContextActions = false,
|
this.disableContextActions = false,
|
||||||
this.hasDraft = false,
|
this.hasDraft = false,
|
||||||
|
this.onTapOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -143,6 +149,10 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (widget.onTapOverride != null) {
|
||||||
|
widget.onTapOverride!(widget.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (selfUsername == null) return;
|
if (selfUsername == null) return;
|
||||||
unawaited(_setCurrentAsRead());
|
unawaited(_setCurrentAsRead());
|
||||||
final view = ChatView(
|
final view = ChatView(
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ dependencies:
|
|||||||
time_range_picker: ^2.3.0
|
time_range_picker: ^2.3.0
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
enough_icalendar: ^0.17.0
|
enough_icalendar: ^0.17.0
|
||||||
|
receive_sharing_intent: ^1.8.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user