Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9cb6a8af1 | ||
|
|
fcfd21bb4d | ||
|
|
aa176c90e3 | ||
|
|
952f916231 | ||
|
|
9ea0eae53c | ||
|
|
4a4e101532 | ||
|
|
23e4510bf8 | ||
|
|
c5592f117b |
No files matched your search
@@ -362,3 +362,9 @@ android/fastlane/README.md.bak
|
|||||||
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
|
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
|
||||||
# bleiben eingecheckt, damit supply die Struktur vorfindet.
|
# bleiben eingecheckt, damit supply die Struktur vorfindet.
|
||||||
android/fastlane/metadata/android/**/images/**/*.png
|
android/fastlane/metadata/android/**/images/**/*.png
|
||||||
|
|
||||||
|
# Fastlane (App-Store-Upload)
|
||||||
|
ios/fastlane/report.xml
|
||||||
|
ios/fastlane/Preview.html
|
||||||
|
ios/fastlane/app-store-connect-api-key.json
|
||||||
|
ios/fastlane/screenshots/**/*.jpg
|
||||||
@@ -17,7 +17,8 @@ import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
|
|||||||
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
|
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
|
||||||
/// und Einstellungen. Ausgeführt über `flutter drive` mit
|
/// und Einstellungen. Ausgeführt über `flutter drive` mit
|
||||||
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
|
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
|
||||||
/// (Phone / 7″ / 10″).
|
/// (Android: Phone / 7″ / 10″) bzw. tool/screenshots_ios.sh (iPhone 6,5″ /
|
||||||
|
/// iPad 13″).
|
||||||
///
|
///
|
||||||
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
|
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
|
||||||
/// Fixtures, daher ist der Lauf deterministisch.
|
/// Fixtures, daher ist der Lauf deterministisch.
|
||||||
@@ -42,11 +43,13 @@ Future<void> main() async {
|
|||||||
_log('Login');
|
_log('Login');
|
||||||
await _login(tester);
|
await _login(tester);
|
||||||
|
|
||||||
|
// Großzügig: auf einem frisch angelegten iOS-Simulator dauert der erste
|
||||||
|
// Post-Login-Aufbau deutlich länger als 20s.
|
||||||
_log('warte auf App-Shell');
|
_log('warte auf App-Shell');
|
||||||
await _pumpUntil(
|
await _pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
find.byType(App),
|
find.byType(App),
|
||||||
timeout: const Duration(seconds: 20),
|
timeout: const Duration(seconds: 60),
|
||||||
);
|
);
|
||||||
_log('warte auf Post-Login-Splash-Ende');
|
_log('warte auf Post-Login-Splash-Ende');
|
||||||
await _pumpUntilGone(
|
await _pumpUntilGone(
|
||||||
@@ -64,6 +67,12 @@ Future<void> main() async {
|
|||||||
await binding.convertFlutterSurfaceToImage();
|
await binding.convertFlutterSurfaceToImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Den einmaligen Benachrichtigungs-Dialog beim ersten Talk-Besuch
|
||||||
|
// unterdrücken. Auf Android erteilt tool/screenshots.sh die Permission
|
||||||
|
// vorab, auf iOS lässt sie sich im Simulator nicht vorab erteilen – der
|
||||||
|
// Dialog würde sonst über der Talk-Liste liegen.
|
||||||
|
_markTalkPermissionPromptShown(tester);
|
||||||
|
|
||||||
// 1 + 2: Stundenplan in beiden Themes.
|
// 1 + 2: Stundenplan in beiden Themes.
|
||||||
await _goTo(tester, Modules.timetable);
|
await _goTo(tester, Modules.timetable);
|
||||||
_setTheme(tester, ThemeMode.light);
|
_setTheme(tester, ThemeMode.light);
|
||||||
@@ -129,6 +138,16 @@ void _setTheme(WidgetTester tester, ThemeMode mode) {
|
|||||||
context.read<SettingsCubit>().val(write: true).appTheme = mode;
|
context.read<SettingsCubit>().val(write: true).appTheme = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markTalkPermissionPromptShown(WidgetTester tester) {
|
||||||
|
final context = tester.element(find.byType(App));
|
||||||
|
context
|
||||||
|
.read<SettingsCubit>()
|
||||||
|
.val(write: true)
|
||||||
|
.notificationSettings
|
||||||
|
.talkPermissionPromptShown =
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
|
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
|
||||||
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
|
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
|
||||||
Future<void> _goTo(WidgetTester tester, Modules module) async {
|
Future<void> _goTo(WidgetTester tester, Modules module) async {
|
||||||
|
|||||||
@@ -107,20 +107,58 @@ final class ShareViewController: UIViewController {
|
|||||||
(UTType.url.identifier, .url),
|
(UTType.url.identifier, .url),
|
||||||
(UTType.data.identifier, .file),
|
(UTType.data.identifier, .file),
|
||||||
]
|
]
|
||||||
for (typeId, kind) in order {
|
for (typeId, kind) in order where provider.hasItemConformingToTypeIdentifier(typeId) {
|
||||||
if provider.hasItemConformingToTypeIdentifier(typeId) {
|
// Konkreter Typ (z. B. public.png statt public.image) für Dateiendung
|
||||||
|
// und MIME-Type, falls die Quelle nur rohe Daten liefert.
|
||||||
|
let concrete = concreteType(of: provider, conformingTo: typeId)
|
||||||
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
|
provider.loadItem(forTypeIdentifier: typeId) { [weak self] data, error in
|
||||||
guard let self else { completion(nil); return }
|
guard let self else { completion(nil); return }
|
||||||
if error != nil { completion(nil); return }
|
if error == nil,
|
||||||
completion(self.toSharedFile(data: data, kind: kind))
|
let file = self.toSharedFile(data: data, kind: kind,
|
||||||
}
|
concrete: concrete, provider: provider) {
|
||||||
|
completion(file)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Manche Quellen (z. B. der Screenshot-/Markup-Editor) liefern über
|
||||||
|
// loadItem nichts Verwertbares oder eine URL außerhalb unserer
|
||||||
|
// Sandbox. loadFileRepresentation stellt eine lesbare Kopie bereit.
|
||||||
|
self.loadFileFallback(provider: provider, kind: kind,
|
||||||
|
concrete: concrete, completion: completion)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toSharedFile(data: Any?, kind: SharedMediaType) -> SharedMediaFile? {
|
private func concreteType(of provider: NSItemProvider, conformingTo typeId: String) -> UTType? {
|
||||||
|
guard let base = UTType(typeId) else { return nil }
|
||||||
|
return provider.registeredTypeIdentifiers
|
||||||
|
.compactMap { UTType($0) }
|
||||||
|
.first { $0.conforms(to: base) } ?? base
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFileFallback(provider: NSItemProvider, kind: SharedMediaType,
|
||||||
|
concrete: UTType?,
|
||||||
|
completion: @escaping (SharedMediaFile?) -> Void) {
|
||||||
|
guard kind == .image || kind == .video || kind == .file, let concrete else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: concrete.identifier) { [weak self] url, _ in
|
||||||
|
// Die URL ist nur innerhalb dieses Callbacks gültig — sofort kopieren.
|
||||||
|
guard let self, let url, let dst = self.copyIntoAppGroup(src: url) else {
|
||||||
|
completion(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let mime = self.mime(for: url) ?? concrete.preferredMIMEType
|
||||||
|
let duration = kind == .video ? self.videoDurationMs(url: dst) : nil
|
||||||
|
completion(SharedMediaFile(path: self.pathString(for: dst), mimeType: mime,
|
||||||
|
thumbnail: nil, duration: duration, message: nil, type: kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toSharedFile(data: Any?, kind: SharedMediaType, concrete: UTType?,
|
||||||
|
provider: NSItemProvider) -> SharedMediaFile? {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .text:
|
case .text:
|
||||||
guard let s = data as? String else { return nil }
|
guard let s = data as? String else { return nil }
|
||||||
@@ -139,6 +177,10 @@ final class ShareViewController: UIViewController {
|
|||||||
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
|
return SharedMediaFile(path: pathString(for: dst), mimeType: "image/png",
|
||||||
thumbnail: nil, duration: nil, message: nil, type: .image)
|
thumbnail: nil, duration: nil, message: nil, type: .image)
|
||||||
}
|
}
|
||||||
|
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
|
||||||
|
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
|
||||||
|
thumbnail: nil, duration: nil, message: nil, type: .image)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
case .video:
|
case .video:
|
||||||
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
||||||
@@ -146,10 +188,16 @@ final class ShareViewController: UIViewController {
|
|||||||
thumbnail: nil, duration: videoDurationMs(url: u),
|
thumbnail: nil, duration: videoDurationMs(url: u),
|
||||||
message: nil, type: .video)
|
message: nil, type: .video)
|
||||||
case .file:
|
case .file:
|
||||||
guard let u = data as? URL, let dst = copyIntoAppGroup(src: u) else { return nil }
|
if let u = data as? URL, let dst = copyIntoAppGroup(src: u) {
|
||||||
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
return SharedMediaFile(path: pathString(for: dst), mimeType: mime(for: u),
|
||||||
thumbnail: nil, duration: nil, message: nil, type: .file)
|
thumbnail: nil, duration: nil, message: nil, type: .file)
|
||||||
}
|
}
|
||||||
|
if let d = data as? Data, let dst = writeData(d, type: concrete, provider: provider) {
|
||||||
|
return SharedMediaFile(path: pathString(for: dst), mimeType: concrete?.preferredMIMEType,
|
||||||
|
thumbnail: nil, duration: nil, message: nil, type: .file)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
|
// loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
|
||||||
@@ -192,13 +240,11 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func copyIntoShareDir(src: URL) -> URL? {
|
private func copyIntoShareDir(src: URL) -> URL? {
|
||||||
guard let dir = shareDir else { return nil }
|
|
||||||
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
|
||||||
var dst = dir.appendingPathComponent(name)
|
guard let dst = uniqueDestination(name: name) else { return nil }
|
||||||
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
|
// Security-scoped URLs (z. B. aus "Dateien") sind sonst nicht lesbar.
|
||||||
if FileManager.default.fileExists(atPath: dst.path) {
|
let scoped = src.startAccessingSecurityScopedResource()
|
||||||
dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
|
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||||
}
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.copyItem(at: src, to: dst)
|
try FileManager.default.copyItem(at: src, to: dst)
|
||||||
return dst
|
return dst
|
||||||
@@ -207,6 +253,33 @@ final class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nur auf fileQueue aufrufen.
|
||||||
|
private func uniqueDestination(name: String) -> URL? {
|
||||||
|
guard let dir = shareDir else { return nil }
|
||||||
|
let dst = dir.appendingPathComponent(name)
|
||||||
|
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
|
||||||
|
if FileManager.default.fileExists(atPath: dst.path) {
|
||||||
|
return dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeData(_ data: Data, type: UTType?, provider: NSItemProvider) -> URL? {
|
||||||
|
let ext = type?.preferredFilenameExtension ?? "bin"
|
||||||
|
var base = (provider.suggestedName ?? UUID().uuidString)
|
||||||
|
.replacingOccurrences(of: "/", with: "-")
|
||||||
|
if (base as NSString).pathExtension.lowercased() == ext.lowercased() {
|
||||||
|
base = (base as NSString).deletingPathExtension
|
||||||
|
}
|
||||||
|
// Namenswahl und Schreiben zusammen seriell, sonst können zwei Anhänge
|
||||||
|
// mit gleichem suggestedName denselben Pfad bekommen.
|
||||||
|
return fileQueue.sync {
|
||||||
|
guard let dst = uniqueDestination(name: "\(base).\(ext)"),
|
||||||
|
(try? data.write(to: dst)) != nil else { return nil }
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func writePng(image: UIImage) -> URL? {
|
private func writePng(image: UIImage) -> URL? {
|
||||||
guard let data = image.pngData(),
|
guard let data = image.pngData(),
|
||||||
let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
|
let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
app_identifier("eu.mhsl.marianum.mobile.client")
|
||||||
|
team_id("MY55VF3KPG")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
# App-Store-Auslieferung über fastlane deliver. Die Screenshots erzeugt
|
||||||
|
# ../tool/screenshots_ios.sh (Demo-Login + integration_test) direkt in
|
||||||
|
# screenshots/de-DE/; deliver ordnet sie anhand der Pixelgröße iPhone 6,5″ bzw.
|
||||||
|
# iPad 13″ zu.
|
||||||
|
#
|
||||||
|
# Authentifizierung über einen App-Store-Connect-API-Key (JSON im fastlane-
|
||||||
|
# Format, siehe README.md). Pfad per APP_STORE_CONNECT_API_KEY setzen; die Datei
|
||||||
|
# selbst wird per .gitignore nicht eingecheckt.
|
||||||
|
platform :ios do
|
||||||
|
desc "Nur Screenshots hochladen (ersetzt die vorhandenen, verändert keine Texte/Binaries)"
|
||||||
|
lane :upload_screenshots do
|
||||||
|
upload_to_app_store(
|
||||||
|
api_key_path: ENV["APP_STORE_CONNECT_API_KEY"] || "fastlane/app-store-connect-api-key.json",
|
||||||
|
skip_binary_upload: true,
|
||||||
|
skip_metadata: true,
|
||||||
|
skip_app_version_update: true,
|
||||||
|
skip_screenshots: false,
|
||||||
|
overwrite_screenshots: true,
|
||||||
|
screenshots_path: "./fastlane/screenshots",
|
||||||
|
precheck_include_in_app_purchases: false,
|
||||||
|
run_precheck_before_submit: false,
|
||||||
|
force: true, # kein HTML-Vorschau-Bestätigungsschritt
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Fastlane – App-Store-Screenshots & Upload
|
||||||
|
|
||||||
|
iOS-Gegenstück zu `android/fastlane`. Die App muss dafür **nicht** neu gebaut
|
||||||
|
werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte aus
|
||||||
|
Fixtures.
|
||||||
|
|
||||||
|
## 1. Screenshots aufnehmen
|
||||||
|
|
||||||
|
Aus dem **Client**-Wurzelverzeichnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tool/screenshots_ios.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Fährt „iPhone 14 Plus“ (6,5″, 1284×2778) und „iPad Pro 13-inch (M5)“
|
||||||
|
(13″, 2064×2752) nacheinander durch und legt die PNGs in
|
||||||
|
`materials/screenshots/{ios/6-5-inch,ipados/13-inch}/` sowie als JPEG-Upload-Kopie
|
||||||
|
(App Store Connect lehnt Alpha-Kanäle ab) in `screenshots/de-DE/` ab. Fehlende
|
||||||
|
Simulatoren legt das Skript selbst an.
|
||||||
|
|
||||||
|
- Nur eine Größe: `PROFILES="iphone" tool/screenshots_ios.sh`
|
||||||
|
- Andere Simulatoren: `IPHONE_SIM="iPhone 11 Pro Max" IPAD_SIM="…" tool/screenshots_ios.sh`
|
||||||
|
|
||||||
|
App und Schlüsselbund der verwendeten Simulatoren werden dabei zurückgesetzt.
|
||||||
|
|
||||||
|
## 2. Hochladen
|
||||||
|
|
||||||
|
fastlane installieren (`brew install fastlane`), in App Store Connect unter
|
||||||
|
*Benutzer und Zugriff → Integrationen → App Store Connect API* einen Key mit
|
||||||
|
Rolle *App-Manager* anlegen und als JSON ablegen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key_id": "ABC123XYZ",
|
||||||
|
"issuer_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
|
||||||
|
"in_house": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export APP_STORE_CONNECT_API_KEY=/pfad/zu/app-store-connect-api-key.json
|
||||||
|
cd ios
|
||||||
|
fastlane upload_screenshots
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Screenshots landen in der aktuell bearbeitbaren App-Store-Version
|
||||||
|
(„Vorbereitung für Einreichung“); vorhandene Screenshots werden ersetzt. Der
|
||||||
|
API-Key wird **nicht** eingecheckt (siehe `.gitignore`).
|
||||||
@@ -52,10 +52,16 @@ class CacheStore {
|
|||||||
Future<Directory> _directory() {
|
Future<Directory> _directory() {
|
||||||
final name = directoryName(SessionManager().activeAccount?.namespace);
|
final name = directoryName(SessionManager().activeAccount?.namespace);
|
||||||
return _dirs[name] ??= () async {
|
return _dirs[name] ??= () async {
|
||||||
|
try {
|
||||||
final base = await getApplicationCacheDirectory();
|
final base = await getApplicationCacheDirectory();
|
||||||
final dir = Directory('${base.path}/$name');
|
final dir = Directory('${base.path}/$name');
|
||||||
await dir.create(recursive: true);
|
await dir.create(recursive: true);
|
||||||
return dir;
|
return dir;
|
||||||
|
} on Object {
|
||||||
|
// Not memoized (disk full at launch): the next call tries again.
|
||||||
|
unawaited(_dirs.remove(name));
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}();
|
}();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class DemoMarianumConnect {
|
|||||||
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
|
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
|
||||||
case 'timetable/holidays':
|
case 'timetable/holidays':
|
||||||
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
|
||||||
|
case 'timetable/custom-events':
|
||||||
|
return DemoTimetable.customEvents().toJson();
|
||||||
case 'holidays':
|
case 'holidays':
|
||||||
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
|
||||||
case 'users/search':
|
case 'users/search':
|
||||||
|
|||||||
@@ -54,11 +54,61 @@ AppException? _dioToAppException(DioException error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final RegExp _davException = RegExp(
|
||||||
|
r'<s:exception>\s*([^<]*?)\s*</s:exception>',
|
||||||
|
);
|
||||||
|
final RegExp _davMessage = RegExp(r'<s:message>\s*([^<]*?)\s*</s:message>');
|
||||||
|
|
||||||
|
/// The Sabre DAV error body of a failed WebDAV request: the exception class
|
||||||
|
/// (without its `Sabre\DAV\Exception\` namespace) and the server's message.
|
||||||
|
/// The class says what actually went wrong where the status is ambiguous —
|
||||||
|
/// MKCOL on an existing folder is a plain 405.
|
||||||
|
({String exception, String message})? parseDavError(String body) {
|
||||||
|
final exception = _davException.firstMatch(body)?.group(1);
|
||||||
|
if (exception == null || exception.isEmpty) return null;
|
||||||
|
return (
|
||||||
|
exception: exception.split('\\').last,
|
||||||
|
message: _davMessage.firstMatch(body)?.group(1) ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AppException? _davToAppException(
|
||||||
|
int status,
|
||||||
|
({String exception, String message}) dav,
|
||||||
|
String detail,
|
||||||
|
) {
|
||||||
|
final userMessage = switch (dav.exception) {
|
||||||
|
'MethodNotAllowed' when status == 405 =>
|
||||||
|
'Ein Ordner oder eine Datei mit diesem Namen existiert bereits.',
|
||||||
|
'PreconditionFailed' =>
|
||||||
|
'Am Zielort existiert bereits ein Eintrag mit diesem Namen.',
|
||||||
|
'Conflict' => 'Der übergeordnete Ordner existiert nicht mehr.',
|
||||||
|
'Locked' =>
|
||||||
|
'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.',
|
||||||
|
'InsufficientStorage' =>
|
||||||
|
'Der verfügbare Speicherplatz reicht dafür nicht aus.',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (userMessage == null) return null;
|
||||||
|
return ServerException(
|
||||||
|
statusCode: status,
|
||||||
|
userMessage: userMessage,
|
||||||
|
technicalDetails: detail,
|
||||||
|
allowRetry: dav.exception == 'Locked',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
|
/// The nextcloud/dynamite clients throw [DynamiteApiException] on non-2xx.
|
||||||
/// Its toString() dumps every response header, so the details keep only the
|
/// Its toString() dumps every response header, so the details keep only the
|
||||||
/// status plus a trimmed body preview (same format as the Talk API errors).
|
/// status plus a trimmed body preview (same format as the Talk API errors).
|
||||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||||
final status = error.statusCode;
|
final status = error.statusCode;
|
||||||
|
final dav = parseDavError(error.body);
|
||||||
|
if (dav != null) {
|
||||||
|
final detail = 'HTTP $status ${dav.exception}: ${dav.message}';
|
||||||
|
return _davToAppException(status, dav, detail) ??
|
||||||
|
exceptionForStatus(status, detail);
|
||||||
|
}
|
||||||
final preview = previewBody(error.body);
|
final preview = previewBody(error.body);
|
||||||
final detail = preview.isEmpty
|
final detail = preview.isEmpty
|
||||||
? 'HTTP $status'
|
? 'HTTP $status'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ServerException extends AppException {
|
|||||||
required this.statusCode,
|
required this.statusCode,
|
||||||
String? userMessage,
|
String? userMessage,
|
||||||
super.technicalDetails,
|
super.technicalDetails,
|
||||||
|
super.allowRetry,
|
||||||
}) : super(
|
}) : super(
|
||||||
// Status code intentionally not in the user message — it lives in
|
// Status code intentionally not in the user message — it lives in
|
||||||
// technicalDetails behind "Details anzeigen".
|
// technicalDetails behind "Details anzeigen".
|
||||||
@@ -14,6 +15,5 @@ class ServerException extends AppException {
|
|||||||
userMessage ??
|
userMessage ??
|
||||||
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
|
'Der Server konnte die Anfrage gerade nicht verarbeiten. '
|
||||||
'Bitte versuche es in einem Moment erneut.',
|
'Bitte versuche es in einem Moment erneut.',
|
||||||
allowRetry: true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../nextcloud_ocs.dart';
|
||||||
|
import 'search_chat_messages_response.dart';
|
||||||
|
|
||||||
|
/// Wraps Talk's `talk-message-current` search provider, which searches the
|
||||||
|
/// whole server-side history of one conversation. The conversation is picked
|
||||||
|
/// via `from`, the route the provider would be opened from in the web UI.
|
||||||
|
class SearchChatMessages {
|
||||||
|
Future<SearchChatMessagesResponse> run({
|
||||||
|
required String chatToken,
|
||||||
|
required String term,
|
||||||
|
int limit = 25,
|
||||||
|
Object? cursor,
|
||||||
|
}) async {
|
||||||
|
final endpoint = NextcloudOcs.uri(
|
||||||
|
'search/providers/talk-message-current/search',
|
||||||
|
queryParameters: {
|
||||||
|
'term': term,
|
||||||
|
'from': '/call/$chatToken',
|
||||||
|
'limit': limit.toString(),
|
||||||
|
if (cursor != null) 'cursor': cursor.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||||
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
|
throw Exception(
|
||||||
|
'Chat search failed with ${response.statusCode}: ${response.body}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final data =
|
||||||
|
NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
|
||||||
|
return SearchChatMessagesResponse.fromJson(data, term: term);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/// Parsed page of Talk's `talk-message-current` search provider.
|
||||||
|
class SearchChatMessagesResponse {
|
||||||
|
final List<SearchChatMessagesEntry> entries;
|
||||||
|
|
||||||
|
/// Opaque pagination cursor; `null` when the provider has no further page.
|
||||||
|
final Object? cursor;
|
||||||
|
|
||||||
|
const SearchChatMessagesResponse({required this.entries, this.cursor});
|
||||||
|
|
||||||
|
factory SearchChatMessagesResponse.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
required String term,
|
||||||
|
}) {
|
||||||
|
final rawEntries = json['entries'];
|
||||||
|
final entries = rawEntries is List
|
||||||
|
? rawEntries
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => SearchChatMessagesEntry.fromJson(e, term: term))
|
||||||
|
.whereType<SearchChatMessagesEntry>()
|
||||||
|
.toList()
|
||||||
|
: const <SearchChatMessagesEntry>[];
|
||||||
|
final paginated = json['isPaginated'] == true;
|
||||||
|
final cursor = json['cursor'];
|
||||||
|
return SearchChatMessagesResponse(
|
||||||
|
entries: entries,
|
||||||
|
cursor: paginated && entries.isNotEmpty && cursor != null ? cursor : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchChatMessagesEntry {
|
||||||
|
final int messageId;
|
||||||
|
final String? actorId;
|
||||||
|
final String? actorDisplayName;
|
||||||
|
|
||||||
|
/// Server-built excerpt of the message, placeholders already resolved.
|
||||||
|
final String excerpt;
|
||||||
|
final DateTime? sentAt;
|
||||||
|
|
||||||
|
const SearchChatMessagesEntry({
|
||||||
|
required this.messageId,
|
||||||
|
required this.excerpt,
|
||||||
|
this.actorId,
|
||||||
|
this.actorDisplayName,
|
||||||
|
this.sentAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Returns `null` for entries without a recognisable message id.
|
||||||
|
static SearchChatMessagesEntry? fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
required String term,
|
||||||
|
}) {
|
||||||
|
final attributes = json['attributes'];
|
||||||
|
final attrs = attributes is Map ? attributes : const {};
|
||||||
|
final messageId =
|
||||||
|
_parseInt(attrs['messageId']) ??
|
||||||
|
_parseInt(
|
||||||
|
RegExp(
|
||||||
|
r'#message_(\d+)',
|
||||||
|
).firstMatch(json['resourceUrl']?.toString() ?? '')?.group(1),
|
||||||
|
);
|
||||||
|
if (messageId == null) return null;
|
||||||
|
|
||||||
|
// Title and subline have swapped roles across Talk versions (author vs.
|
||||||
|
// excerpt); the one containing the search term is the excerpt.
|
||||||
|
final title = json['title']?.toString() ?? '';
|
||||||
|
final subline = json['subline']?.toString() ?? '';
|
||||||
|
final q = term.trim().toLowerCase();
|
||||||
|
final titleIsExcerpt =
|
||||||
|
q.isNotEmpty &&
|
||||||
|
title.toLowerCase().contains(q) &&
|
||||||
|
!subline.toLowerCase().contains(q);
|
||||||
|
final excerpt = titleIsExcerpt ? title : subline;
|
||||||
|
final author = titleIsExcerpt ? subline : title;
|
||||||
|
|
||||||
|
final timestamp = _parseInt(attrs['timestamp']);
|
||||||
|
final actorId = attrs['actorId']?.toString();
|
||||||
|
return SearchChatMessagesEntry(
|
||||||
|
messageId: messageId,
|
||||||
|
excerpt: excerpt,
|
||||||
|
actorId: actorId == null || actorId.isEmpty ? null : actorId,
|
||||||
|
actorDisplayName: author.isEmpty ? null : author,
|
||||||
|
sentAt: timestamp == null
|
||||||
|
? null
|
||||||
|
: DateTime.fromMillisecondsSinceEpoch(timestamp * 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _parseInt(Object? raw) => switch (raw) {
|
||||||
|
final int v => v,
|
||||||
|
final String v => int.tryParse(v),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -165,7 +165,17 @@ class RichObjectString {
|
|||||||
String? path;
|
String? path;
|
||||||
String? link;
|
String? link;
|
||||||
|
|
||||||
RichObjectString(this.type, this.id, this.name, this.path, this.link);
|
/// Only set for `file` objects.
|
||||||
|
String? mimetype;
|
||||||
|
|
||||||
|
RichObjectString(
|
||||||
|
this.type,
|
||||||
|
this.id,
|
||||||
|
this.name,
|
||||||
|
this.path,
|
||||||
|
this.link, [
|
||||||
|
this.mimetype,
|
||||||
|
]);
|
||||||
|
|
||||||
factory RichObjectString.fromJson(Map<String, dynamic> json) =>
|
factory RichObjectString.fromJson(Map<String, dynamic> json) =>
|
||||||
_$RichObjectStringFromJson(json);
|
_$RichObjectStringFromJson(json);
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ RichObjectString _$RichObjectStringFromJson(Map<String, dynamic> json) =>
|
|||||||
$checkedConvert('name', (v) => v as String),
|
$checkedConvert('name', (v) => v as String),
|
||||||
$checkedConvert('path', (v) => v as String?),
|
$checkedConvert('path', (v) => v as String?),
|
||||||
$checkedConvert('link', (v) => v as String?),
|
$checkedConvert('link', (v) => v as String?),
|
||||||
|
$checkedConvert('mimetype', (v) => v as String?),
|
||||||
);
|
);
|
||||||
return val;
|
return val;
|
||||||
});
|
});
|
||||||
@@ -146,6 +147,7 @@ Map<String, dynamic> _$RichObjectStringToJson(RichObjectString instance) =>
|
|||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'path': instance.path,
|
'path': instance.path,
|
||||||
'link': instance.link,
|
'link': instance.link,
|
||||||
|
'mimetype': instance.mimetype,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$RichObjectStringObjectTypeEnumMap = {
|
const _$RichObjectStringObjectTypeEnumMap = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../../storage/keychain_write.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
import '../../errors/auth_exception.dart';
|
||||||
|
|
||||||
/// `first_unlock` accessibility so the token can be read during background
|
/// `first_unlock` accessibility so the token can be read during background
|
||||||
@@ -52,9 +53,9 @@ class MarianumConnectTokenStorage {
|
|||||||
required String tokenId,
|
required String tokenId,
|
||||||
required DateTime? expiresAt,
|
required DateTime? expiresAt,
|
||||||
}) async {
|
}) async {
|
||||||
await _storage.write(key: _tokenKey, value: token);
|
await _storage.writeRetrying(key: _tokenKey, value: token);
|
||||||
await _storage.write(key: _tokenIdKey, value: tokenId);
|
await _storage.writeRetrying(key: _tokenIdKey, value: tokenId);
|
||||||
await _storage.write(
|
await _storage.writeRetrying(
|
||||||
key: _expiresAtKey,
|
key: _expiresAtKey,
|
||||||
value: expiresAt?.toIso8601String() ?? '',
|
value: expiresAt?.toIso8601String() ?? '',
|
||||||
);
|
);
|
||||||
@@ -75,7 +76,7 @@ class MarianumConnectTokenStorage {
|
|||||||
if (value == null) {
|
if (value == null) {
|
||||||
await _storage.delete(key: key);
|
await _storage.delete(key: key);
|
||||||
} else {
|
} else {
|
||||||
await _storage.write(key: key, value: value);
|
await _storage.writeRetrying(key: key, value: value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../../../storage/keychain_write.dart';
|
||||||
import '../../../../utils/random_id.dart';
|
import '../../../../utils/random_id.dart';
|
||||||
|
|
||||||
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
||||||
@@ -22,7 +23,7 @@ class TelemetryDeviceId {
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
final generated = randomHexId();
|
final generated = randomHexId();
|
||||||
await _storage.write(key: _key, value: generated);
|
await _storage.writeRetrying(key: _key, value: generated);
|
||||||
_cached = generated;
|
_cached = generated;
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../share_intent/share_intent_listener.dart';
|
|||||||
import '../utils/app_paths.dart';
|
import '../utils/app_paths.dart';
|
||||||
import '../utils/downloads/download_manager.dart';
|
import '../utils/downloads/download_manager.dart';
|
||||||
import '../utils/file_clipboard.dart';
|
import '../utils/file_clipboard.dart';
|
||||||
|
import '../widget/media_gallery/gallery_cache.dart';
|
||||||
import '../widget_data/widget_sync.dart';
|
import '../widget_data/widget_sync.dart';
|
||||||
|
|
||||||
/// Removes what an account leaves on the device outside its own storage (see
|
/// Removes what an account leaves on the device outside its own storage (see
|
||||||
@@ -55,6 +56,7 @@ abstract final class SessionWipe {
|
|||||||
final dir = await DownloadManager.downloadsDirectory();
|
final dir = await DownloadManager.downloadsDirectory();
|
||||||
if (dir.existsSync()) await dir.delete(recursive: true);
|
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||||
});
|
});
|
||||||
|
await _stepAsync('gallery originals', GalleryOriginals.instance.clear);
|
||||||
await _stepAsync(
|
await _stepAsync(
|
||||||
'notification tray',
|
'notification tray',
|
||||||
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
|
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ class NotificationService {
|
|||||||
Future<void> initializeNotifications() async {
|
Future<void> initializeNotifications() async {
|
||||||
// Dedicated monochrome status-bar icon: launcher mipmaps are unusable as
|
// Dedicated monochrome status-bar icon: launcher mipmaps are unusable as
|
||||||
// small icons because Android renders only their alpha silhouette (a
|
// small icons because Android renders only their alpha silhouette (a
|
||||||
// solid circle in the status bar).
|
// solid circle in the status bar). Bare resource name: the plugin resolves
|
||||||
|
// it via getIdentifier(name, "drawable", ...), and not every ROM strips a
|
||||||
|
// leading "@drawable/" there (reported as invalid_icon on some devices).
|
||||||
const androidSettings = AndroidInitializationSettings(
|
const androidSettings = AndroidInitializationSettings(
|
||||||
'@drawable/ic_stat_notification',
|
'ic_stat_notification',
|
||||||
);
|
);
|
||||||
|
|
||||||
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:localstore/localstore.dart';
|
import 'package:localstore/localstore.dart';
|
||||||
|
|
||||||
/// Maximum messages kept per chat thread — enough for a messenger-style
|
/// Maximum messages kept per chat thread — enough for a messenger-style
|
||||||
@@ -190,8 +191,15 @@ class ChatThreadStore {
|
|||||||
DocumentRef _doc(String chatToken) =>
|
DocumentRef _doc(String chatToken) =>
|
||||||
_db.collection(_collection).doc(docIdForToken(chatToken));
|
_db.collection(_collection).doc(docIdForToken(chatToken));
|
||||||
|
|
||||||
Future<void> _write(String chatToken, List<ThreadMessage> messages) =>
|
/// Best effort: a full disk must not keep the notification from being
|
||||||
_doc(chatToken).set({
|
/// shown; the thread just restarts from the next push.
|
||||||
|
Future<void> _write(String chatToken, List<ThreadMessage> messages) async {
|
||||||
|
try {
|
||||||
|
await _doc(chatToken).set({
|
||||||
'messages': [for (final m in messages) m.toJson()],
|
'messages': [for (final m in messages) m.toJson()],
|
||||||
});
|
});
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('ChatThreadStore.write($chatToken) failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:localstore/localstore.dart';
|
import 'package:localstore/localstore.dart';
|
||||||
|
|
||||||
/// A rendered notification's bookkeeping, keyed by the Nextcloud notification
|
/// A rendered notification's bookkeeping, keyed by the Nextcloud notification
|
||||||
@@ -40,8 +41,15 @@ class NidStore {
|
|||||||
|
|
||||||
NidStore({Localstore? db}) : _db = db ?? Localstore.instance;
|
NidStore({Localstore? db}) : _db = db ?? Localstore.instance;
|
||||||
|
|
||||||
Future<void> put(NidEntry entry) =>
|
/// Best effort: a full disk must not keep the notification itself from
|
||||||
_db.collection(_collection).doc('${entry.nid}').set(entry.toJson());
|
/// being shown — only a later delete-push would then miss the mapping.
|
||||||
|
Future<void> put(NidEntry entry) async {
|
||||||
|
try {
|
||||||
|
await _db.collection(_collection).doc('${entry.nid}').set(entry.toJson());
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('NidStore.put(${entry.nid}) failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<NidEntry?> get(int nid) async {
|
Future<NidEntry?> get(int nid) async {
|
||||||
final data = await _db.collection(_collection).doc('$nid').get();
|
final data = await _db.collection(_collection).doc('$nid').get();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import '../storage/keychain_write.dart';
|
||||||
|
|
||||||
/// Keychain access group shared between the Runner and the (Phase 3) iOS
|
/// Keychain access group shared between the Runner and the (Phase 3) iOS
|
||||||
/// Notification Service Extension so the NSE can read the RSA private key,
|
/// Notification Service Extension so the NSE can read the RSA private key,
|
||||||
@@ -48,7 +49,9 @@ class PushSecureStorage implements FlutterSecureStorageLike {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> write({required String key, required String? value}) =>
|
Future<void> write({required String key, required String? value}) =>
|
||||||
pushSecureStorage.write(key: key, value: value);
|
value == null
|
||||||
|
? pushSecureStorage.delete(key: key)
|
||||||
|
: pushSecureStorage.writeRetrying(key: key, value: value);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> delete({required String key}) =>
|
Future<void> delete({required String key}) =>
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import '../widget/debug/cache_view.dart';
|
|||||||
import '../widget/debug/json_viewer.dart';
|
import '../widget/debug/json_viewer.dart';
|
||||||
import '../widget/file_viewer.dart';
|
import '../widget/file_viewer.dart';
|
||||||
import '../widget/large_profile_picture_view.dart';
|
import '../widget/large_profile_picture_view.dart';
|
||||||
|
import '../widget/media_gallery/gallery_source.dart';
|
||||||
|
import '../widget/media_gallery/media_gallery_viewer.dart';
|
||||||
import '../widget/user_avatar.dart';
|
import '../widget/user_avatar.dart';
|
||||||
|
|
||||||
/// Single entry point for full-page navigations. Dialogs and bottom sheets
|
/// Single entry point for full-page navigations. Dialogs and bottom sheets
|
||||||
@@ -97,6 +99,19 @@ class AppRoutes {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Swipeable full-screen gallery; the viewer takes ownership of [source].
|
||||||
|
static void openMediaGallery(
|
||||||
|
BuildContext context, {
|
||||||
|
required GallerySource source,
|
||||||
|
required String initialKey,
|
||||||
|
}) {
|
||||||
|
pushScreen(
|
||||||
|
context,
|
||||||
|
withNavBar: false,
|
||||||
|
screen: MediaGalleryViewer(source: source, initialKey: initialKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static void openCustomEvents(BuildContext context) {
|
static void openCustomEvents(BuildContext context) {
|
||||||
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import '../api/marianumconnect/auth/token_storage.dart';
|
import '../api/marianumconnect/auth/token_storage.dart';
|
||||||
import '../push/push_secure_storage.dart';
|
import '../push/push_secure_storage.dart';
|
||||||
|
import '../storage/keychain_write.dart';
|
||||||
import '../utils/exponential_backoff.dart';
|
import '../utils/exponential_backoff.dart';
|
||||||
import '../utils/random_id.dart';
|
import '../utils/random_id.dart';
|
||||||
import 'account_codec.dart';
|
import 'account_codec.dart';
|
||||||
@@ -165,7 +166,7 @@ class SessionManager {
|
|||||||
...sessionVaultFields(session),
|
...sessionVaultFields(session),
|
||||||
...await _tokenStorage.readAll(),
|
...await _tokenStorage.readAll(),
|
||||||
};
|
};
|
||||||
await _secureStorage.write(
|
await _secureStorage.writeRetrying(
|
||||||
key: _vaultKey(entry.id),
|
key: _vaultKey(entry.id),
|
||||||
value: encodeVault(fields),
|
value: encodeVault(fields),
|
||||||
);
|
);
|
||||||
@@ -209,7 +210,7 @@ class SessionManager {
|
|||||||
|
|
||||||
Future<void> _saveIndex(AccountIndex index) async {
|
Future<void> _saveIndex(AccountIndex index) async {
|
||||||
accounts.value = index;
|
accounts.value = index;
|
||||||
await _secureStorage.write(key: _indexKey, value: index.encode());
|
await _secureStorage.writeRetrying(key: _indexKey, value: index.encode());
|
||||||
}
|
}
|
||||||
|
|
||||||
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
static int _now() => DateTime.now().millisecondsSinceEpoch;
|
||||||
@@ -234,7 +235,10 @@ class SessionManager {
|
|||||||
await setAppPassword(appPassword);
|
await setAppPassword(appPassword);
|
||||||
await clearAppPasswordTalk();
|
await clearAppPasswordTalk();
|
||||||
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
|
_updateNextcloud((nc) => nc.copyWith(usesLoginFlow: true));
|
||||||
await _secureStorage.write(key: SessionKeys.loginFlow, value: 'true');
|
await _secureStorage.writeRetrying(
|
||||||
|
key: SessionKeys.loginFlow,
|
||||||
|
value: 'true',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setAppPasswordTalk(String appPassword) async {
|
Future<void> setAppPasswordTalk(String appPassword) async {
|
||||||
@@ -258,7 +262,7 @@ class SessionManager {
|
|||||||
|
|
||||||
Future<void> _writeSecret(String key, String? value) => value == null
|
Future<void> _writeSecret(String key, String? value) => value == null
|
||||||
? _secureStorage.delete(key: key)
|
? _secureStorage.delete(key: key)
|
||||||
: _secureStorage.write(key: key, value: value);
|
: _secureStorage.writeRetrying(key: key, value: value);
|
||||||
|
|
||||||
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
// App passwords live in the push-shared (group-scoped) keystore so the iOS
|
||||||
// Notification Service Extension can authenticate Nextcloud calls too. That
|
// Notification Service Extension can authenticate Nextcloud calls too. That
|
||||||
@@ -269,7 +273,7 @@ class SessionManager {
|
|||||||
if (value == null) {
|
if (value == null) {
|
||||||
await pushSecureStorage.delete(key: key);
|
await pushSecureStorage.delete(key: key);
|
||||||
} else {
|
} else {
|
||||||
await pushSecureStorage.write(key: key, value: value);
|
await pushSecureStorage.writeRetrying(key: key, value: value);
|
||||||
}
|
}
|
||||||
} on Object {
|
} on Object {
|
||||||
// ignore — see above
|
// ignore — see above
|
||||||
@@ -362,7 +366,10 @@ class SessionManager {
|
|||||||
namespace: index.accounts.isEmpty ? '' : null,
|
namespace: index.accounts.isEmpty ? '' : null,
|
||||||
now: _now(),
|
now: _now(),
|
||||||
);
|
);
|
||||||
await _secureStorage.write(key: _indexKey, value: index.encode());
|
await _secureStorage.writeRetrying(
|
||||||
|
key: _indexKey,
|
||||||
|
value: index.encode(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
accounts.value = index;
|
accounts.value = index;
|
||||||
return;
|
return;
|
||||||
@@ -385,11 +392,11 @@ class SessionManager {
|
|||||||
final hasSecure =
|
final hasSecure =
|
||||||
(await _secureStorage.read(key: SessionKeys.username)) != null;
|
(await _secureStorage.read(key: SessionKeys.username)) != null;
|
||||||
if (!hasSecure) {
|
if (!hasSecure) {
|
||||||
await _secureStorage.write(
|
await _secureStorage.writeRetrying(
|
||||||
key: SessionKeys.username,
|
key: SessionKeys.username,
|
||||||
value: legacyUsername,
|
value: legacyUsername,
|
||||||
);
|
);
|
||||||
await _secureStorage.write(
|
await _secureStorage.writeRetrying(
|
||||||
key: SessionKeys.password,
|
key: SessionKeys.password,
|
||||||
value: legacyPassword,
|
value: legacyPassword,
|
||||||
);
|
);
|
||||||
@@ -408,7 +415,7 @@ class SessionManager {
|
|||||||
if (value == null) continue;
|
if (value == null) continue;
|
||||||
// Same account+service: the legacy item has to go before the re-add.
|
// Same account+service: the legacy item has to go before the re-add.
|
||||||
await _legacySecureStorage.delete(key: field);
|
await _legacySecureStorage.delete(key: field);
|
||||||
await _secureStorage.write(key: field, value: value);
|
await _secureStorage.writeRetrying(key: field, value: value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +180,48 @@ class ChatBloc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pages in older history until [messageId] is held. Returns false when the
|
||||||
|
/// history ends (or a page brings no progress) before reaching it, or when
|
||||||
|
/// [isCancelled] or a chat switch aborts the walk. [onProgress] reports the
|
||||||
|
/// send time of the oldest held message after every page.
|
||||||
|
Future<bool> loadOlderUntil(
|
||||||
|
int messageId, {
|
||||||
|
required bool Function() isCancelled,
|
||||||
|
ValueChanged<DateTime>? onProgress,
|
||||||
|
}) async {
|
||||||
|
final token = innerState?.currentToken ?? '';
|
||||||
|
while (true) {
|
||||||
|
final state = innerState;
|
||||||
|
final response = state?.chatResponse;
|
||||||
|
if (isClosed || isCancelled() || state == null || response == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (state.currentToken != token) return false;
|
||||||
|
if (response.data.any((m) => m.id == messageId)) return true;
|
||||||
|
final oldestId = _minMessageId(response);
|
||||||
|
if (oldestId > 0 && oldestId < messageId) return false;
|
||||||
|
if (!state.hasMoreOld) return false;
|
||||||
|
|
||||||
|
await loadOlder();
|
||||||
|
// A load started by the scroll listener makes loadOlder() a no-op, and
|
||||||
|
// its result is merged by a queued event — wait until it has landed.
|
||||||
|
if (innerState?.isLoadingOlder ?? false) {
|
||||||
|
try {
|
||||||
|
await stream.firstWhere((s) => !(s.data?.isLoadingOlder ?? false));
|
||||||
|
} on StateError {
|
||||||
|
return false; // bloc closed meanwhile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final after = innerState?.chatResponse;
|
||||||
|
if (after == null || _minMessageId(after) == oldestId) {
|
||||||
|
if (innerState?.hasMoreOld ?? false) return false;
|
||||||
|
} else {
|
||||||
|
final oldest = after.data.where((m) => m.id == _minMessageId(after));
|
||||||
|
if (oldest.isNotEmpty) onProgress?.call(oldest.first.sentAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> sendServerReadMarker(String token, int messageId) async {
|
Future<void> sendServerReadMarker(String token, int messageId) async {
|
||||||
try {
|
try {
|
||||||
await SetReadMarker(
|
await SetReadMarker(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
/// `errSecDuplicateItem`: the iOS keychain refused to add an item that is
|
||||||
|
/// already there.
|
||||||
|
const int kErrSecDuplicateItem = -25299;
|
||||||
|
|
||||||
|
/// Whether [error] is the iOS keychain's "item already exists" failure.
|
||||||
|
/// flutter_secure_storage surfaces it as a PlatformException whose details
|
||||||
|
/// (and message) carry the OSStatus.
|
||||||
|
bool isKeychainDuplicateItem(Object error) {
|
||||||
|
if (error is! PlatformException) return false;
|
||||||
|
if (error.details == kErrSecDuplicateItem) return true;
|
||||||
|
return error.message?.contains('$kErrSecDuplicateItem') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension KeychainWrite on FlutterSecureStorage {
|
||||||
|
/// [write] that survives `errSecDuplicateItem`. The plugin's iOS write is
|
||||||
|
/// check-then-add/update and not atomic: a concurrent write to the same key
|
||||||
|
/// — or an existence check that itself failed (seen with the disk full) —
|
||||||
|
/// ends in SecItemAdd on an existing item. The second attempt finds the
|
||||||
|
/// item and updates it.
|
||||||
|
Future<void> writeRetrying({
|
||||||
|
required String key,
|
||||||
|
required String value,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await write(key: key, value: value);
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
if (!isKeychainDuplicateItem(e)) rethrow;
|
||||||
|
await write(key: key, value: value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,8 +140,12 @@ class _FilesViewState extends State<_FilesView> {
|
|||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
itemCount: files.length,
|
itemCount: files.length,
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) => FileElement(
|
||||||
FileElement(files[index], widget.path, bloc.refresh),
|
files[index],
|
||||||
|
widget.path,
|
||||||
|
bloc.refresh,
|
||||||
|
siblings: files,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import '../../../../widget/confirm_dialog.dart';
|
|||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/downloads/download_trigger.dart';
|
import '../../../../widget/downloads/download_trigger.dart';
|
||||||
|
import '../../../../widget/media_gallery/gallery_item.dart';
|
||||||
|
import '../../../../widget/media_gallery/gallery_source.dart';
|
||||||
import '../../../../widget/prompt_dialog.dart';
|
import '../../../../widget/prompt_dialog.dart';
|
||||||
import '../../talk/widgets/highlighted_linkify.dart';
|
import '../../talk/widgets/highlighted_linkify.dart';
|
||||||
import '../data/webdav_path.dart';
|
import '../data/webdav_path.dart';
|
||||||
@@ -33,11 +35,16 @@ class FileElement extends StatefulWidget {
|
|||||||
/// highlighted in the tile title. Used by the Files search delegate.
|
/// highlighted in the tile title. Used by the Files search delegate.
|
||||||
final String? highlight;
|
final String? highlight;
|
||||||
|
|
||||||
|
/// The listing [file] belongs to, in display order; the media gallery pages
|
||||||
|
/// through its images and videos. Null opens the gallery on [file] alone.
|
||||||
|
final List<CacheableFile>? siblings;
|
||||||
|
|
||||||
const FileElement(
|
const FileElement(
|
||||||
this.file,
|
this.file,
|
||||||
this.path,
|
this.path,
|
||||||
this.refetch, {
|
this.refetch, {
|
||||||
this.highlight,
|
this.highlight,
|
||||||
|
this.siblings,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,10 +107,7 @@ class _FileElementState extends State<FileElement>
|
|||||||
|
|
||||||
void _onTap() {
|
void _onTap() {
|
||||||
if (widget.file.isDirectory) {
|
if (widget.file.isDirectory) {
|
||||||
AppRoutes.openFolder(
|
AppRoutes.openFolder(context, [...widget.path, widget.file.name]);
|
||||||
context,
|
|
||||||
[...widget.path, widget.file.name],
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (guardDemoAction(context)) return;
|
if (guardDemoAction(context)) return;
|
||||||
@@ -111,12 +115,45 @@ class _FileElementState extends State<FileElement>
|
|||||||
confirmCancelDownload();
|
confirmCancelDownload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (galleryMediaKindOf(widget.file.name, mimeType: widget.file.mimeType) !=
|
||||||
|
null) {
|
||||||
|
_openGallery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
startDownload(
|
startDownload(
|
||||||
name: widget.file.name,
|
name: widget.file.name,
|
||||||
remoteFile: RemoteFileRef.fromCacheable(widget.file),
|
remoteFile: RemoteFileRef.fromCacheable(widget.file),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openGallery() {
|
||||||
|
final items = [
|
||||||
|
for (final file in widget.siblings ?? [widget.file])
|
||||||
|
if (!file.isDirectory) ?_galleryItem(file),
|
||||||
|
];
|
||||||
|
AppRoutes.openMediaGallery(
|
||||||
|
context,
|
||||||
|
source: StaticGallerySource(items),
|
||||||
|
initialKey: widget.file.path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GalleryItem? _galleryItem(CacheableFile file) {
|
||||||
|
final kind = galleryMediaKindOf(file.name, mimeType: file.mimeType);
|
||||||
|
if (kind == null) return null;
|
||||||
|
final size = file.size;
|
||||||
|
final modified = file.modifiedAt;
|
||||||
|
return GalleryItem(
|
||||||
|
file: RemoteFileRef.fromCacheable(file),
|
||||||
|
kind: kind,
|
||||||
|
eTag: file.eTag,
|
||||||
|
subtitle: [
|
||||||
|
if (size != null) filesize(size),
|
||||||
|
if (modified != null) modified.formatDateTime(),
|
||||||
|
].join(' · '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _rename() {
|
void _rename() {
|
||||||
if (guardDemoAction(context)) return;
|
if (guardDemoAction(context)) return;
|
||||||
showPromptDialog(
|
showPromptDialog(
|
||||||
|
|||||||
@@ -14,16 +14,17 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
|||||||
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../state/app/modules/chat/bloc/chat_state.dart';
|
import '../../../state/app/modules/chat/bloc/chat_state.dart';
|
||||||
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import '../../../utils/debouncer.dart';
|
|
||||||
import '../../../widget/chat_background.dart';
|
import '../../../widget/chat_background.dart';
|
||||||
import '../../../widget/clickable_app_bar.dart';
|
import '../../../widget/clickable_app_bar.dart';
|
||||||
import '../../../widget/user_avatar.dart';
|
import '../../../widget/user_avatar.dart';
|
||||||
import 'data/chat_search_controller.dart';
|
import 'data/chat_message_matcher.dart';
|
||||||
import 'details/chat_info.dart';
|
import 'details/chat_info.dart';
|
||||||
|
import 'search/chat_search_controller.dart';
|
||||||
|
import 'search/chat_search_delegate.dart';
|
||||||
import 'talk_navigator.dart';
|
import 'talk_navigator.dart';
|
||||||
import 'widgets/chat_bubble.dart';
|
import 'widgets/chat_bubble.dart';
|
||||||
import 'widgets/chat_search_app_bar.dart';
|
|
||||||
import 'widgets/chat_textfield.dart';
|
import 'widgets/chat_textfield.dart';
|
||||||
|
import 'widgets/history_jump_banner.dart';
|
||||||
|
|
||||||
class ChatView extends StatefulWidget {
|
class ChatView extends StatefulWidget {
|
||||||
final GetRoomResponseObject room;
|
final GetRoomResponseObject room;
|
||||||
@@ -45,19 +46,21 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||||
final ItemPositionsListener _positionsListener =
|
final ItemPositionsListener _positionsListener =
|
||||||
ItemPositionsListener.create();
|
ItemPositionsListener.create();
|
||||||
final TextEditingController _searchTextController = TextEditingController();
|
|
||||||
final Map<int, int> _matchIndices = {};
|
|
||||||
|
|
||||||
// Number of rows currently rendered; kept in sync in build so the scroll
|
// Number of rows currently rendered; kept in sync in build so the scroll
|
||||||
// listener can tell when the oldest row (highest index, reverse list) nears.
|
// listener can tell when the oldest row (highest index, reverse list) nears.
|
||||||
int _itemCount = 0;
|
int _itemCount = 0;
|
||||||
|
|
||||||
bool _searchActive = false;
|
// Search hit the chat jumped to; stays marked until the next search.
|
||||||
String _searchQuery = '';
|
int? _highlightId;
|
||||||
List<ChatSearchMatch> _matches = const [];
|
String? _highlightQuery;
|
||||||
int _activeMatchIndex = 0;
|
int? _highlightIndex;
|
||||||
GetChatResponse? _matchesComputedFor;
|
|
||||||
String? _matchesComputedQuery;
|
// Running jump towards a search hit that is not loaded yet.
|
||||||
|
ChatSearchHit? _jumpTarget;
|
||||||
|
DateTime _jumpStartedAt = DateTime.now();
|
||||||
|
DateTime? _jumpLoadedUntil;
|
||||||
|
int _jumpEpoch = 0;
|
||||||
|
|
||||||
// Captured in initState because the framework has unmounted us by the
|
// Captured in initState because the framework has unmounted us by the
|
||||||
// time dispose runs.
|
// time dispose runs.
|
||||||
@@ -116,8 +119,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
_positionsListener.itemPositions.removeListener(_onScrollPositions);
|
_positionsListener.itemPositions.removeListener(_onScrollPositions);
|
||||||
_markAsReadFinal();
|
_markAsReadFinal();
|
||||||
_chatBloc.leaveChat(widget.room.token);
|
_chatBloc.leaveChat(widget.room.token);
|
||||||
_searchTextController.dispose();
|
|
||||||
Debouncer.cancel(_searchDebounceTag);
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +142,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant ChatView oldWidget) {
|
void didUpdateWidget(covariant ChatView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (widget.room.token != oldWidget.room.token && _searchActive) {
|
if (widget.room.token != oldWidget.room.token) _clearHighlight();
|
||||||
_exitSearchMode();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _refetch() => _chatBloc.setToken(widget.room.token);
|
void _refetch() => _chatBloc.setToken(widget.room.token);
|
||||||
@@ -161,9 +160,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
List<Widget> _itemsFor(ChatState state) {
|
List<Widget> _itemsFor(ChatState state) {
|
||||||
final messagesKey = (
|
final messagesKey = (
|
||||||
state.chatResponse,
|
state.chatResponse,
|
||||||
_searchActive,
|
_highlightId,
|
||||||
_searchQuery,
|
_highlightQuery,
|
||||||
_activeMatchIndex,
|
|
||||||
widget.room,
|
widget.room,
|
||||||
);
|
);
|
||||||
if (messagesKey != _messagesKey) {
|
if (messagesKey != _messagesKey) {
|
||||||
@@ -192,117 +190,93 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
return _items = items;
|
return _items = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _enterSearchMode() => _resetSearch(active: true);
|
Future<void> _openSearch() async {
|
||||||
|
final delegate = ChatSearchDelegate(
|
||||||
void _exitSearchMode() {
|
ChatSearchController(
|
||||||
Debouncer.cancel(_searchDebounceTag);
|
chatToken: widget.room.token,
|
||||||
_matchIndices.clear();
|
loadedMessages: () {
|
||||||
_resetSearch(active: false);
|
final state = _chatBloc.state.data;
|
||||||
|
return state?.currentToken == widget.room.token
|
||||||
|
? state?.chatResponse
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final hit = await showSearch<ChatSearchHit?>(
|
||||||
|
context: context,
|
||||||
|
delegate: delegate,
|
||||||
|
);
|
||||||
|
final query = delegate.query;
|
||||||
|
delegate.disposeController();
|
||||||
|
if (hit == null || !mounted) return;
|
||||||
|
await _jumpToMessage(hit, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resetSearch({required bool active}) {
|
/// Older hits from the server search are usually not loaded yet; the
|
||||||
|
/// history is paged in until the message is reached.
|
||||||
|
Future<void> _jumpToMessage(ChatSearchHit hit, String query) async {
|
||||||
|
final epoch = ++_jumpEpoch;
|
||||||
|
_clearHighlight();
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchActive = active;
|
_jumpTarget = hit;
|
||||||
_searchQuery = '';
|
_jumpStartedAt = DateTime.now();
|
||||||
_matches = const [];
|
_jumpLoadedUntil = null;
|
||||||
_activeMatchIndex = 0;
|
|
||||||
_matchesComputedFor = null;
|
|
||||||
_matchesComputedQuery = null;
|
|
||||||
_searchTextController.clear();
|
|
||||||
});
|
});
|
||||||
|
final found = await _chatBloc.loadOlderUntil(
|
||||||
|
hit.messageId,
|
||||||
|
isCancelled: () => !mounted || epoch != _jumpEpoch,
|
||||||
|
onProgress: (oldest) {
|
||||||
|
if (mounted && epoch == _jumpEpoch) {
|
||||||
|
setState(() => _jumpLoadedUntil = oldest);
|
||||||
}
|
}
|
||||||
|
|
||||||
late final String _searchDebounceTag =
|
|
||||||
'chat-search-${identityHashCode(this)}';
|
|
||||||
|
|
||||||
/// Matching re-scans the whole history and rebuilds every row, so it runs
|
|
||||||
/// once typing pauses instead of per keystroke.
|
|
||||||
void _onSearchChanged(String q) {
|
|
||||||
Debouncer.debounce(
|
|
||||||
_searchDebounceTag,
|
|
||||||
const Duration(milliseconds: 200),
|
|
||||||
() {
|
|
||||||
if (mounted) _applySearch(q);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
if (!mounted || epoch != _jumpEpoch) return;
|
||||||
|
|
||||||
void _applySearch(String q) {
|
|
||||||
final chatResponse = _chatBloc.state.data?.chatResponse;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = q;
|
_jumpTarget = null;
|
||||||
_activeMatchIndex = 0;
|
if (found) {
|
||||||
if (chatResponse != null) {
|
_highlightId = hit.messageId;
|
||||||
_recomputeMatches(chatResponse);
|
_highlightQuery = query;
|
||||||
} else {
|
|
||||||
_matches = const [];
|
|
||||||
_matchesComputedFor = null;
|
|
||||||
_matchesComputedQuery = null;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
if (!found) {
|
||||||
if (mounted && _matches.isNotEmpty) _scrollToActiveMatch();
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
});
|
const SnackBar(content: Text('Nachricht nicht gefunden.')),
|
||||||
}
|
|
||||||
|
|
||||||
void _recomputeMatches(GetChatResponse response) {
|
|
||||||
_matches = ChatSearchController.findMatches(response, _searchQuery);
|
|
||||||
_activeMatchIndex = _activeMatchIndex.clamp(
|
|
||||||
0,
|
|
||||||
math.max(0, _matches.length - 1),
|
|
||||||
);
|
);
|
||||||
_matchesComputedFor = response;
|
return;
|
||||||
_matchesComputedQuery = _searchQuery;
|
|
||||||
}
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Matches are newest-first, so "previous" (older) steps forward.
|
final idx = _highlightIndex;
|
||||||
void _stepMatch(int delta) {
|
if (!mounted || idx == null || !_itemScrollController.isAttached) return;
|
||||||
if (_matches.isEmpty) return;
|
|
||||||
setState(() {
|
|
||||||
_activeMatchIndex =
|
|
||||||
(_activeMatchIndex + delta + _matches.length) % _matches.length;
|
|
||||||
});
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scrollToActiveMatch() {
|
|
||||||
if (_matches.isEmpty) return;
|
|
||||||
if (!_itemScrollController.isAttached) return;
|
|
||||||
final id = _matches[_activeMatchIndex].messageId;
|
|
||||||
final idx = _matchIndices[id];
|
|
||||||
if (idx == null) return;
|
|
||||||
_itemScrollController.scrollTo(
|
_itemScrollController.scrollTo(
|
||||||
index: idx,
|
index: idx,
|
||||||
alignment: 0.4,
|
alignment: 0.4,
|
||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelJump() {
|
||||||
|
_jumpEpoch++;
|
||||||
|
setState(() => _jumpTarget = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearHighlight() {
|
||||||
|
_highlightId = null;
|
||||||
|
_highlightQuery = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildMessages(GetChatResponse response) {
|
List<Widget> _buildMessages(GetChatResponse response) {
|
||||||
if (_searchActive &&
|
|
||||||
(response != _matchesComputedFor ||
|
|
||||||
_searchQuery != _matchesComputedQuery)) {
|
|
||||||
_recomputeMatches(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
final matchIds = _matches.map((m) => m.messageId).toSet();
|
|
||||||
final activeId = _matches.isNotEmpty
|
|
||||||
? _matches[_activeMatchIndex].messageId
|
|
||||||
: null;
|
|
||||||
final highlightQuery = _searchActive && _searchQuery.trim().isNotEmpty
|
|
||||||
? _searchQuery
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final commonRead = int.parse(
|
final commonRead = int.parse(
|
||||||
response.headers?['x-chat-last-common-read'] ?? '0',
|
response.headers?['x-chat-last-common-read'] ?? '0',
|
||||||
);
|
);
|
||||||
|
|
||||||
final messages = <Widget>[];
|
final messages = <Widget>[];
|
||||||
final chronologicalMatchIndex = <int, int>{};
|
int? highlightPosition;
|
||||||
var lastDate = DateTime.now();
|
var lastDate = DateTime.now();
|
||||||
for (final element in response.sortByTimestamp()) {
|
for (final element in response.sortByTimestamp()) {
|
||||||
if (ChatSearchController.isHiddenSystemMessage(element)) continue;
|
if (ChatMessageMatcher.isHiddenSystemMessage(element)) continue;
|
||||||
|
|
||||||
final elementDate = element.sentAt;
|
final elementDate = element.sentAt;
|
||||||
|
|
||||||
@@ -322,14 +296,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isMatch = matchIds.contains(element.id);
|
final isHighlighted = element.id == _highlightId;
|
||||||
final highlight = isMatch
|
if (isHighlighted) highlightPosition = messages.length;
|
||||||
? (element.id == activeId
|
|
||||||
? SearchHighlight.active
|
|
||||||
: SearchHighlight.secondary)
|
|
||||||
: SearchHighlight.none;
|
|
||||||
|
|
||||||
if (isMatch) chronologicalMatchIndex[element.id] = messages.length;
|
|
||||||
|
|
||||||
messages.add(
|
messages.add(
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
@@ -345,20 +313,18 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
refetch: _refetch,
|
refetch: _refetch,
|
||||||
isRead: element.id <= commonRead,
|
isRead: element.id <= commonRead,
|
||||||
selfId: widget.selfId,
|
selfId: widget.selfId,
|
||||||
highlightQuery: highlightQuery,
|
highlightQuery: isHighlighted ? _highlightQuery : null,
|
||||||
matchHighlight: highlight,
|
matchHighlight: isHighlighted
|
||||||
|
? SearchHighlight.active
|
||||||
|
: SearchHighlight.none,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final total = messages.length;
|
// The list is rendered reversed (newest at index 0).
|
||||||
_matchIndices
|
_highlightIndex = highlightPosition == null
|
||||||
..clear()
|
? null
|
||||||
..addEntries(
|
: messages.length - 1 - highlightPosition;
|
||||||
chronologicalMatchIndex.entries.map(
|
|
||||||
(e) => MapEntry(e.key, (total - 1) - e.value),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
@@ -373,17 +339,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
return _KeyboardDismissPopScope(
|
return _KeyboardDismissPopScope(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: const Color(0xffefeae2),
|
backgroundColor: const Color(0xffefeae2),
|
||||||
appBar: _searchActive
|
appBar: ClickableAppBar(
|
||||||
? ChatSearchAppBar(
|
|
||||||
controller: _searchTextController,
|
|
||||||
matchCount: _matches.length,
|
|
||||||
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
|
|
||||||
onChanged: _onSearchChanged,
|
|
||||||
onClose: _exitSearchMode,
|
|
||||||
onPrevious: _matches.isEmpty ? null : () => _stepMatch(1),
|
|
||||||
onNext: _matches.isEmpty ? null : () => _stepMatch(-1),
|
|
||||||
)
|
|
||||||
: ClickableAppBar(
|
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
|
TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -404,7 +360,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
tooltip: 'In Chat suchen',
|
tooltip: 'In Chat suchen',
|
||||||
onPressed: _enterSearchMode,
|
onPressed: _openSearch,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -412,6 +368,13 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
body: ChatBackground(
|
body: ChatBackground(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (_jumpTarget case final target?)
|
||||||
|
HistoryJumpBanner(
|
||||||
|
startedAt: _jumpStartedAt,
|
||||||
|
target: target.sentAt,
|
||||||
|
loadedUntil: _jumpLoadedUntil,
|
||||||
|
onCancel: _cancelJump,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LoadableStateConsumer<ChatBloc, ChatState>(
|
child: LoadableStateConsumer<ChatBloc, ChatState>(
|
||||||
isReady: (state) =>
|
isReady: (state) =>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||||
import '../../../../model/endpoint_data.dart';
|
|
||||||
import '../../../../session/session_manager.dart';
|
|
||||||
import '../../../../utils/emoji_detection.dart';
|
import '../../../../utils/emoji_detection.dart';
|
||||||
import '../../../../utils/url_opener.dart';
|
import '../../../../utils/url_opener.dart';
|
||||||
|
import '../widgets/chat_file_attachment.dart';
|
||||||
import '../widgets/highlighted_linkify.dart';
|
import '../widgets/highlighted_linkify.dart';
|
||||||
import '../widgets/message_markdown.dart';
|
import '../widgets/message_markdown.dart';
|
||||||
|
|
||||||
@@ -78,36 +76,7 @@ class ChatMessage {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
CachedNetworkImage(
|
ChatFileAttachment(file: file),
|
||||||
errorWidget: (context, url, error) => Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.file_open_outlined, size: 35),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
file.name,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
placeholder: (context, url) => const Padding(
|
|
||||||
padding: EdgeInsets.all(15),
|
|
||||||
child: SizedBox(width: 50, child: LinearProgressIndicator()),
|
|
||||||
),
|
|
||||||
fadeInDuration: Duration.zero,
|
|
||||||
fadeOutDuration: Duration.zero,
|
|
||||||
errorListener: (value) {},
|
|
||||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
|
||||||
imageUrl:
|
|
||||||
'${EndpointData().nextcloud().origin()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1',
|
|
||||||
),
|
|
||||||
if (originalMessage != '{file}') ...[
|
if (originalMessage != '{file}') ...[
|
||||||
SizedBox(height: 5),
|
SizedBox(height: 5),
|
||||||
contentWidget,
|
contentWidget,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import '../../../../api/marianumcloud/search/search_chat_messages_response.dart';
|
||||||
|
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
|
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||||
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
|
|
||||||
|
/// One message matching a chat search, either from the loaded history or
|
||||||
|
/// from the server-side search.
|
||||||
|
class ChatSearchHit {
|
||||||
|
final int messageId;
|
||||||
|
final String? actorId;
|
||||||
|
final String? actorDisplayName;
|
||||||
|
final String text;
|
||||||
|
final DateTime? sentAt;
|
||||||
|
final bool isSystem;
|
||||||
|
|
||||||
|
const ChatSearchHit({
|
||||||
|
required this.messageId,
|
||||||
|
required this.text,
|
||||||
|
this.actorId,
|
||||||
|
this.actorDisplayName,
|
||||||
|
this.sentAt,
|
||||||
|
this.isSystem = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ChatSearchHit.fromServer(SearchChatMessagesEntry entry) =>
|
||||||
|
ChatSearchHit(
|
||||||
|
messageId: entry.messageId,
|
||||||
|
text: entry.excerpt,
|
||||||
|
actorId: entry.actorId,
|
||||||
|
actorDisplayName: entry.actorDisplayName,
|
||||||
|
sentAt: entry.sentAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatMessageMatcher {
|
||||||
|
/// System messages that are folded into other bubbles (reactions, poll
|
||||||
|
/// votes, deletions) and therefore never rendered nor searched as their own
|
||||||
|
/// entry.
|
||||||
|
static bool isHiddenSystemMessage(GetChatResponseObject element) =>
|
||||||
|
element.systemMessage.contains('reaction') ||
|
||||||
|
element.systemMessage.contains('poll_voted') ||
|
||||||
|
element.systemMessage.contains('message_deleted');
|
||||||
|
|
||||||
|
/// Newest first.
|
||||||
|
static List<ChatSearchHit> findMatches(
|
||||||
|
GetChatResponse response,
|
||||||
|
String query,
|
||||||
|
) {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
if (q.isEmpty) return const [];
|
||||||
|
|
||||||
|
final matches = <ChatSearchHit>[];
|
||||||
|
for (final element in response.data) {
|
||||||
|
if (element.id <= 0 || isHiddenSystemMessage(element)) continue;
|
||||||
|
|
||||||
|
final text = RichObjectStringProcessor.parseToString(
|
||||||
|
element.message,
|
||||||
|
element.messageParameters,
|
||||||
|
);
|
||||||
|
final isSystem =
|
||||||
|
element.messageType == GetRoomResponseObjectMessageType.system;
|
||||||
|
|
||||||
|
final matched =
|
||||||
|
text.toLowerCase().contains(q) ||
|
||||||
|
(!isSystem && element.actorDisplayName.toLowerCase().contains(q));
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
matches.add(
|
||||||
|
ChatSearchHit(
|
||||||
|
messageId: element.id,
|
||||||
|
text: text,
|
||||||
|
actorId: isSystem ? null : element.actorId,
|
||||||
|
actorDisplayName: isSystem ? null : element.actorDisplayName,
|
||||||
|
sentAt: element.sentAt,
|
||||||
|
isSystem: isSystem,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => b.sentAt!.compareTo(a.sentAt!));
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local hits win over server hits for the same message — they carry the
|
||||||
|
/// full text and exact metadata. Result is newest first.
|
||||||
|
static List<ChatSearchHit> merge(
|
||||||
|
List<ChatSearchHit> local,
|
||||||
|
List<ChatSearchHit> server,
|
||||||
|
) {
|
||||||
|
if (server.isEmpty) return local;
|
||||||
|
final seen = {for (final h in local) h.messageId};
|
||||||
|
return [...local, ...server.where((h) => seen.add(h.messageId))]
|
||||||
|
..sort((a, b) => b.messageId.compareTo(a.messageId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
|
||||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
|
||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
|
||||||
|
|
||||||
class ChatSearchMatch {
|
|
||||||
final int messageId;
|
|
||||||
final int timestamp;
|
|
||||||
|
|
||||||
const ChatSearchMatch({required this.messageId, required this.timestamp});
|
|
||||||
}
|
|
||||||
|
|
||||||
class ChatSearchController {
|
|
||||||
/// System messages that are folded into other bubbles (reactions, poll
|
|
||||||
/// votes, deletions) and therefore never rendered nor searched as their own
|
|
||||||
/// entry.
|
|
||||||
static bool isHiddenSystemMessage(GetChatResponseObject element) =>
|
|
||||||
element.systemMessage.contains('reaction') ||
|
|
||||||
element.systemMessage.contains('poll_voted') ||
|
|
||||||
element.systemMessage.contains('message_deleted');
|
|
||||||
|
|
||||||
static List<ChatSearchMatch> findMatches(
|
|
||||||
GetChatResponse response,
|
|
||||||
String query,
|
|
||||||
) {
|
|
||||||
final q = query.trim().toLowerCase();
|
|
||||||
if (q.isEmpty) return const [];
|
|
||||||
|
|
||||||
final matches = <ChatSearchMatch>[];
|
|
||||||
for (final element in response.sortByTimestamp()) {
|
|
||||||
if (isHiddenSystemMessage(element)) continue;
|
|
||||||
|
|
||||||
final haystackText = RichObjectStringProcessor.parseToString(
|
|
||||||
element.message,
|
|
||||||
element.messageParameters,
|
|
||||||
).toLowerCase();
|
|
||||||
|
|
||||||
var matched = haystackText.contains(q);
|
|
||||||
if (!matched &&
|
|
||||||
element.messageType != GetRoomResponseObjectMessageType.system) {
|
|
||||||
matched = element.actorDisplayName.toLowerCase().contains(q);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matched) {
|
|
||||||
matches.add(
|
|
||||||
ChatSearchMatch(
|
|
||||||
messageId: element.id,
|
|
||||||
timestamp: element.timestamp,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
matches.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
|
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../session/session_manager.dart';
|
||||||
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
|
import '../../../../widget/media_gallery/gallery_item.dart';
|
||||||
|
import '../../../../widget/media_gallery/gallery_source.dart';
|
||||||
|
import '../details/shared_items_view.dart';
|
||||||
|
|
||||||
|
GalleryItem? galleryItemFromTalk(GetChatResponseObject message) {
|
||||||
|
final file = message.messageParameters?['file'];
|
||||||
|
if (file == null || file.path == null) return null;
|
||||||
|
final kind = galleryMediaKindOf(file.name, mimeType: file.mimetype);
|
||||||
|
if (kind == null) return null;
|
||||||
|
return GalleryItem(
|
||||||
|
file: RemoteFileRef.fromTalk(file),
|
||||||
|
kind: kind,
|
||||||
|
messageId: message.id,
|
||||||
|
subtitle:
|
||||||
|
'${message.actorDisplayName} · ${message.sentAt.formatDateTime()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All images and videos of a chat, via Talk's shared-items `media` stream.
|
||||||
|
/// Opens on the tapped message and fills in around it in the background.
|
||||||
|
class TalkGallerySource extends GallerySource {
|
||||||
|
/// The share endpoint only pages backwards from the newest item, so reaching
|
||||||
|
/// an old tapped image means walking down to it; give up beyond this.
|
||||||
|
static const _maxCatchUpPages = 25;
|
||||||
|
|
||||||
|
final String token;
|
||||||
|
final GalleryItem initial;
|
||||||
|
final int _epoch = SessionManager().sessionEpoch;
|
||||||
|
|
||||||
|
List<GalleryItem> _items;
|
||||||
|
int? _cursor;
|
||||||
|
bool _hasOlder = false;
|
||||||
|
bool _loading = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
TalkGallerySource({required this.token, required this.initial})
|
||||||
|
: _items = [initial] {
|
||||||
|
_catchUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<GalleryItem> get items => _items;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get hasOlder => _hasOlder && !_loading;
|
||||||
|
|
||||||
|
bool get _stale => _disposed || !SessionManager().isCurrentSession(_epoch);
|
||||||
|
|
||||||
|
Future<SharedItemsPage> _fetch(int? cursor) async => buildSharedItemsPage(
|
||||||
|
await GetSharedItems(
|
||||||
|
token,
|
||||||
|
objectType: 'media',
|
||||||
|
limit: sharedItemsPageSize,
|
||||||
|
lastKnownMessageId: cursor,
|
||||||
|
).run(),
|
||||||
|
cursor,
|
||||||
|
);
|
||||||
|
|
||||||
|
Iterable<GalleryItem> _toItems(SharedItemsPage page) =>
|
||||||
|
page.items.map(galleryItemFromTalk).nonNulls;
|
||||||
|
|
||||||
|
Future<void> _catchUp() async {
|
||||||
|
final initialId = initial.messageId!;
|
||||||
|
_loading = true;
|
||||||
|
try {
|
||||||
|
final collected = <GalleryItem>[];
|
||||||
|
int? cursor;
|
||||||
|
var hasMore = true;
|
||||||
|
var reached = false;
|
||||||
|
for (var pages = 0; hasMore && pages < _maxCatchUpPages; pages++) {
|
||||||
|
final page = await _fetch(cursor);
|
||||||
|
if (_stale) return;
|
||||||
|
collected.addAll(_toItems(page));
|
||||||
|
cursor = page.lastKnownMessageId;
|
||||||
|
hasMore = page.hasMore;
|
||||||
|
if (page.items.any((item) => item.id <= initialId)) {
|
||||||
|
reached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reached || !hasMore) {
|
||||||
|
_items = mergeGalleryItems(_items, collected);
|
||||||
|
_cursor = cursor;
|
||||||
|
_hasOlder = hasMore;
|
||||||
|
} else {
|
||||||
|
// Too far down: skip the newer media and only page older ones.
|
||||||
|
_cursor = initialId;
|
||||||
|
_hasOlder = true;
|
||||||
|
}
|
||||||
|
} on Object {
|
||||||
|
if (_stale) return;
|
||||||
|
_cursor = initialId;
|
||||||
|
_hasOlder = true;
|
||||||
|
} finally {
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> loadOlder() async {
|
||||||
|
if (!hasOlder) return;
|
||||||
|
_loading = true;
|
||||||
|
try {
|
||||||
|
final page = await _fetch(_cursor);
|
||||||
|
if (_stale) return;
|
||||||
|
_items = mergeGalleryItems(_items, _toItems(page));
|
||||||
|
_cursor = page.lastKnownMessageId;
|
||||||
|
_hasOlder = page.hasMore;
|
||||||
|
} on Object {
|
||||||
|
// Retried on the next page change.
|
||||||
|
} finally {
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
if (!_stale) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,14 +10,16 @@ import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_res
|
|||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../model/endpoint_data.dart';
|
import '../../../../model/endpoint_data.dart';
|
||||||
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../session/session_manager.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../../share_intent/remote_file_ref.dart';
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../utils/downloads/download_job.dart';
|
import '../../../../utils/downloads/download_job.dart';
|
||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
import '../../../../widget/downloads/download_trigger.dart';
|
import '../../../../widget/downloads/download_trigger.dart';
|
||||||
|
import '../data/talk_gallery_source.dart';
|
||||||
|
|
||||||
const int _sharedItemsPageSize = 20;
|
const int sharedItemsPageSize = 20;
|
||||||
|
|
||||||
/// The non-media shared-item categories Talk exposes, in tab order. `media` is
|
/// The non-media shared-item categories Talk exposes, in tab order. `media` is
|
||||||
/// handled separately (split into Bilder/Videos). `location`/`deckcard` are rich
|
/// handled separately (split into Bilder/Videos). `location`/`deckcard` are rich
|
||||||
@@ -64,7 +66,7 @@ SharedItemsPage buildSharedItemsPage(
|
|||||||
) {
|
) {
|
||||||
final lastGiven = response.lastGivenMessageId;
|
final lastGiven = response.lastGivenMessageId;
|
||||||
final hasMore =
|
final hasMore =
|
||||||
response.items.length >= _sharedItemsPageSize &&
|
response.items.length >= sharedItemsPageSize &&
|
||||||
lastGiven != null &&
|
lastGiven != null &&
|
||||||
lastGiven != previousMessageId;
|
lastGiven != previousMessageId;
|
||||||
return SharedItemsPage(
|
return SharedItemsPage(
|
||||||
@@ -79,7 +81,7 @@ SharedItemsPage buildSharedItemsPage(
|
|||||||
/// its `X-Chat-Last-Given` header. [hasMore] is a heuristic — a full page from
|
/// its `X-Chat-Last-Given` header. [hasMore] is a heuristic — a full page from
|
||||||
/// the overview means there are probably older items to fetch on scroll.
|
/// the overview means there are probably older items to fetch on scroll.
|
||||||
SharedItemsPage _seedFromOverview(List<GetChatResponseObject> rawItems) {
|
SharedItemsPage _seedFromOverview(List<GetChatResponseObject> rawItems) {
|
||||||
final hasMore = rawItems.length >= _sharedItemsPageSize;
|
final hasMore = rawItems.length >= sharedItemsPageSize;
|
||||||
final oldestId = rawItems.isEmpty
|
final oldestId = rawItems.isEmpty
|
||||||
? null
|
? null
|
||||||
: rawItems.map((item) => item.id).reduce((a, b) => a < b ? a : b);
|
: rawItems.map((item) => item.id).reduce((a, b) => a < b ? a : b);
|
||||||
@@ -106,7 +108,7 @@ class SharedItemsView extends StatefulWidget {
|
|||||||
/// screen, so opening this view needs no round-trip.
|
/// screen, so opening this view needs no round-trip.
|
||||||
static Future<GetSharedItemsOverviewResponse> prefetchOverview(
|
static Future<GetSharedItemsOverviewResponse> prefetchOverview(
|
||||||
String token,
|
String token,
|
||||||
) => GetSharedItemsOverview(token, limit: _sharedItemsPageSize).run();
|
) => GetSharedItemsOverview(token, limit: sharedItemsPageSize).run();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SharedItemsView> createState() => _SharedItemsViewState();
|
State<SharedItemsView> createState() => _SharedItemsViewState();
|
||||||
@@ -362,7 +364,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab>
|
|||||||
final response = await GetSharedItems(
|
final response = await GetSharedItems(
|
||||||
widget.token,
|
widget.token,
|
||||||
objectType: widget.objectType,
|
objectType: widget.objectType,
|
||||||
limit: _sharedItemsPageSize,
|
limit: sharedItemsPageSize,
|
||||||
lastKnownMessageId: _lastKnownMessageId,
|
lastKnownMessageId: _lastKnownMessageId,
|
||||||
).run();
|
).run();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -445,6 +447,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab>
|
|||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _SharedItemTile(
|
(context, index) => _SharedItemTile(
|
||||||
|
token: widget.token,
|
||||||
item: _items[index],
|
item: _items[index],
|
||||||
layout: _SharedItemsLayout.grid,
|
layout: _SharedItemsLayout.grid,
|
||||||
),
|
),
|
||||||
@@ -456,6 +459,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab>
|
|||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _SharedItemTile(
|
(context, index) => _SharedItemTile(
|
||||||
|
token: widget.token,
|
||||||
item: _items[index],
|
item: _items[index],
|
||||||
layout: _SharedItemsLayout.list,
|
layout: _SharedItemsLayout.list,
|
||||||
),
|
),
|
||||||
@@ -470,10 +474,15 @@ class _SharedItemsTabState extends State<_SharedItemsTab>
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SharedItemTile extends StatefulWidget {
|
class _SharedItemTile extends StatefulWidget {
|
||||||
|
final String token;
|
||||||
final GetChatResponseObject item;
|
final GetChatResponseObject item;
|
||||||
final _SharedItemsLayout layout;
|
final _SharedItemsLayout layout;
|
||||||
|
|
||||||
const _SharedItemTile({required this.item, required this.layout});
|
const _SharedItemTile({
|
||||||
|
required this.token,
|
||||||
|
required this.item,
|
||||||
|
required this.layout,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_SharedItemTile> createState() => _SharedItemTileState();
|
State<_SharedItemTile> createState() => _SharedItemTileState();
|
||||||
@@ -502,12 +511,18 @@ class _SharedItemTileState extends State<_SharedItemTile>
|
|||||||
if (guardDemoAction(context)) return;
|
if (guardDemoAction(context)) return;
|
||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
confirmCancelDownload();
|
confirmCancelDownload();
|
||||||
} else {
|
return;
|
||||||
startDownload(
|
|
||||||
name: _file.name,
|
|
||||||
remoteFile: RemoteFileRef.fromTalk(_file),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
final galleryItem = galleryItemFromTalk(widget.item);
|
||||||
|
if (galleryItem != null) {
|
||||||
|
AppRoutes.openMediaGallery(
|
||||||
|
context,
|
||||||
|
source: TalkGallerySource(token: widget.token, initial: galleryItem),
|
||||||
|
initialKey: galleryItem.key,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file));
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _previewUrl =>
|
String get _previewUrl =>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../../../api/demo/demo_mode.dart';
|
||||||
|
import '../../../../api/marianumcloud/search/search_chat_messages.dart';
|
||||||
|
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
|
import '../../../../utils/debouncer.dart';
|
||||||
|
import '../data/chat_message_matcher.dart';
|
||||||
|
|
||||||
|
/// Live state of a search inside one chat, mirroring `FilesSearchController`:
|
||||||
|
/// hits from the loaded history appear immediately, the server-side search
|
||||||
|
/// over the full history follows debounced and can be paged further.
|
||||||
|
class ChatSearchController extends ChangeNotifier {
|
||||||
|
ChatSearchController({
|
||||||
|
required this.chatToken,
|
||||||
|
required GetChatResponse? Function() loadedMessages,
|
||||||
|
}) : _loadedMessages = loadedMessages;
|
||||||
|
|
||||||
|
final String chatToken;
|
||||||
|
final GetChatResponse? Function() _loadedMessages;
|
||||||
|
|
||||||
|
static const Duration _serverDebounce = Duration(seconds: 1);
|
||||||
|
final String _debounceTag =
|
||||||
|
'chat-search-${DateTime.now().microsecondsSinceEpoch}';
|
||||||
|
final SearchChatMessages _api = SearchChatMessages();
|
||||||
|
|
||||||
|
String _query = '';
|
||||||
|
List<ChatSearchHit> _localResults = const [];
|
||||||
|
List<ChatSearchHit> _serverResults = const [];
|
||||||
|
Object? _serverCursor;
|
||||||
|
bool _serverLoading = false;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
Object? _serverError;
|
||||||
|
int _serverEpoch = 0;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
void _safeNotify() {
|
||||||
|
if (_disposed) return;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String get query => _query;
|
||||||
|
bool get serverLoading => _serverLoading;
|
||||||
|
bool get loadingMore => _loadingMore;
|
||||||
|
bool get canLoadMore => _serverCursor != null;
|
||||||
|
Object? get serverError => _serverError;
|
||||||
|
|
||||||
|
List<ChatSearchHit> get combinedResults =>
|
||||||
|
ChatMessageMatcher.merge(_localResults, _serverResults);
|
||||||
|
|
||||||
|
void setQuery(String value) {
|
||||||
|
if (value == _query) return;
|
||||||
|
_query = value;
|
||||||
|
// Invalidates in-flight server calls of the previous query.
|
||||||
|
++_serverEpoch;
|
||||||
|
_serverResults = const [];
|
||||||
|
_serverCursor = null;
|
||||||
|
_loadingMore = false;
|
||||||
|
_serverError = null;
|
||||||
|
if (_query.trim().isEmpty) {
|
||||||
|
Debouncer.cancel(_debounceTag);
|
||||||
|
_localResults = const [];
|
||||||
|
_serverLoading = false;
|
||||||
|
_safeNotify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final loaded = _loadedMessages();
|
||||||
|
_localResults = loaded == null
|
||||||
|
? const []
|
||||||
|
: ChatMessageMatcher.findMatches(loaded, _query);
|
||||||
|
// Demo sessions must not hit the real Nextcloud (see DemoHttpClient).
|
||||||
|
_serverLoading = !DemoMode.active;
|
||||||
|
_safeNotify();
|
||||||
|
if (_serverLoading) {
|
||||||
|
Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void retry() {
|
||||||
|
if (_query.trim().isEmpty || DemoMode.active) return;
|
||||||
|
++_serverEpoch;
|
||||||
|
Debouncer.cancel(_debounceTag);
|
||||||
|
_serverResults = const [];
|
||||||
|
_serverCursor = null;
|
||||||
|
_loadingMore = false;
|
||||||
|
_serverLoading = true;
|
||||||
|
_serverError = null;
|
||||||
|
_safeNotify();
|
||||||
|
_runServerCall();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadMore() {
|
||||||
|
if (_serverCursor == null || _serverLoading || _loadingMore) return;
|
||||||
|
_loadingMore = true;
|
||||||
|
_safeNotify();
|
||||||
|
_runServerCall(cursor: _serverCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runServerCall({Object? cursor}) async {
|
||||||
|
final epoch = _serverEpoch;
|
||||||
|
try {
|
||||||
|
final response = await _api.run(
|
||||||
|
chatToken: chatToken,
|
||||||
|
term: _query.trim(),
|
||||||
|
cursor: cursor,
|
||||||
|
);
|
||||||
|
if (epoch != _serverEpoch) return;
|
||||||
|
_serverResults = [
|
||||||
|
..._serverResults,
|
||||||
|
...response.entries.map(ChatSearchHit.fromServer),
|
||||||
|
];
|
||||||
|
_serverCursor = response.cursor;
|
||||||
|
_serverError = null;
|
||||||
|
} on Object catch (e) {
|
||||||
|
if (epoch != _serverEpoch) return;
|
||||||
|
_serverCursor = null;
|
||||||
|
_serverError = e;
|
||||||
|
}
|
||||||
|
_serverLoading = false;
|
||||||
|
_loadingMore = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
Debouncer.cancel(_debounceTag);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/chat_message_matcher.dart';
|
||||||
|
import 'chat_search_controller.dart';
|
||||||
|
import 'chat_search_results.dart';
|
||||||
|
|
||||||
|
/// Search inside one chat, built like `FilesSearchDelegate`. Closes with the
|
||||||
|
/// tapped hit so the chat can jump there.
|
||||||
|
class ChatSearchDelegate extends SearchDelegate<ChatSearchHit?> {
|
||||||
|
final ChatSearchController _controller;
|
||||||
|
|
||||||
|
ChatSearchDelegate(this._controller)
|
||||||
|
: super(searchFieldLabel: 'In Chat suchen');
|
||||||
|
|
||||||
|
/// Must be called by the host widget after `showSearch` returns so the
|
||||||
|
/// controller's listeners and pending debounce timers are released.
|
||||||
|
void disposeController() => _controller.dispose();
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
|
if (query.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Suche leeren',
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
query = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget? buildLeading(BuildContext context) => IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
tooltip: 'Zurück',
|
||||||
|
onPressed: () => close(context, null),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildResults(BuildContext context) => buildSuggestions(context);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildSuggestions(BuildContext context) {
|
||||||
|
_controller.setQuery(query);
|
||||||
|
return ChatSearchResults(
|
||||||
|
controller: _controller,
|
||||||
|
onResultTap: (hit) => close(context, hit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../api/errors/error_mapper.dart';
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart';
|
||||||
|
import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart';
|
||||||
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart';
|
||||||
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart';
|
||||||
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart';
|
||||||
|
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart';
|
||||||
|
import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
|
||||||
|
import '../../../../widget/placeholder_view.dart';
|
||||||
|
import '../../../../widget/user_avatar.dart';
|
||||||
|
import '../data/chat_message_matcher.dart';
|
||||||
|
import '../widgets/highlighted_linkify.dart';
|
||||||
|
import 'chat_search_controller.dart';
|
||||||
|
|
||||||
|
/// Renders a [ChatSearchController] with the same loading / error views as
|
||||||
|
/// `FilesSearchResults`.
|
||||||
|
class ChatSearchResults extends StatelessWidget {
|
||||||
|
final ChatSearchController controller;
|
||||||
|
final ValueChanged<ChatSearchHit> onResultTap;
|
||||||
|
|
||||||
|
const ChatSearchResults({
|
||||||
|
required this.controller,
|
||||||
|
required this.onResultTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) =>
|
||||||
|
BlocModule<LoadableStateBloc, LoadableStateState>(
|
||||||
|
create: (_) => LoadableStateBloc(),
|
||||||
|
child: (context, bloc, _) {
|
||||||
|
bloc.reFetch = controller.retry;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: controller,
|
||||||
|
builder: (context, _) => _buildBody(context),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context) {
|
||||||
|
if (controller.query.trim().isEmpty) {
|
||||||
|
return const PlaceholderView(
|
||||||
|
icon: Icons.search,
|
||||||
|
text: 'Tippen, um im Chat zu suchen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final combined = controller.combinedResults;
|
||||||
|
final hasContent = combined.isNotEmpty;
|
||||||
|
final hasError = controller.serverError != null;
|
||||||
|
final isLoading = controller.serverLoading;
|
||||||
|
|
||||||
|
final showPrimaryLoading = isLoading && !hasContent;
|
||||||
|
final showBackgroundLoading = isLoading && hasContent;
|
||||||
|
final showErrorScreen = hasError && !hasContent && !isLoading;
|
||||||
|
final showErrorBar = hasError && hasContent;
|
||||||
|
final showEmpty = !hasContent && !hasError && !isLoading;
|
||||||
|
|
||||||
|
final errorMessage = hasError
|
||||||
|
? errorToUserMessage(controller.serverError)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
LoadableStateErrorBar(
|
||||||
|
visible: showErrorBar,
|
||||||
|
hasContent: hasContent,
|
||||||
|
message: errorMessage,
|
||||||
|
),
|
||||||
|
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
|
||||||
|
LoadableStateErrorScreen(
|
||||||
|
visible: showErrorScreen,
|
||||||
|
message: errorMessage,
|
||||||
|
),
|
||||||
|
if (showEmpty)
|
||||||
|
const PlaceholderView(
|
||||||
|
icon: Icons.search_off_outlined,
|
||||||
|
text: 'Keine Treffer gefunden.',
|
||||||
|
),
|
||||||
|
if (hasContent) _resultList(combined),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _resultList(List<ChatSearchHit> hits) {
|
||||||
|
final showFooter = controller.canLoadMore || controller.loadingMore;
|
||||||
|
return NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (n) {
|
||||||
|
if (n.metrics.extentAfter < 400) controller.loadMore();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: hits.length + (showFooter ? 1 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == hits.length) {
|
||||||
|
// A short first page never scrolls, so the footer also triggers
|
||||||
|
// the next page when it gets built.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => controller.loadMore(),
|
||||||
|
);
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final hit = hits[index];
|
||||||
|
return _ChatSearchTile(
|
||||||
|
key: ValueKey(hit.messageId),
|
||||||
|
hit: hit,
|
||||||
|
query: controller.query,
|
||||||
|
onTap: () => onResultTap(hit),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatSearchTile extends StatelessWidget {
|
||||||
|
final ChatSearchHit hit;
|
||||||
|
final String query;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ChatSearchTile({
|
||||||
|
required this.hit,
|
||||||
|
required this.query,
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final base =
|
||||||
|
theme.textTheme.bodyMedium ?? DefaultTextStyle.of(context).style;
|
||||||
|
final actorId = hit.actorId;
|
||||||
|
final sentAt = hit.sentAt;
|
||||||
|
return ListTile(
|
||||||
|
leading: actorId != null
|
||||||
|
? UserAvatar(
|
||||||
|
id: actorId,
|
||||||
|
size: 20,
|
||||||
|
semanticLabel: hit.actorDisplayName,
|
||||||
|
)
|
||||||
|
: const CircleAvatar(radius: 20, child: Icon(Icons.info_outline)),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: buildHighlightedSpans(
|
||||||
|
text: hit.actorDisplayName ?? 'System',
|
||||||
|
query: query,
|
||||||
|
baseStyle: theme.textTheme.titleSmall ?? base,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (sentAt != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8),
|
||||||
|
child: Text(
|
||||||
|
sentAt.year == DateTime.now().year
|
||||||
|
? sentAt.formatDateShortHm()
|
||||||
|
: sentAt.formatDate(),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: buildHighlightedSpans(
|
||||||
|
text: hit.text,
|
||||||
|
query: query,
|
||||||
|
baseStyle: base.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
|||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../extensions/text.dart';
|
import '../../../../extensions/text.dart';
|
||||||
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../share_intent/remote_file_ref.dart';
|
import '../../../../share_intent/remote_file_ref.dart';
|
||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../utils/downloads/download_job.dart';
|
import '../../../../utils/downloads/download_job.dart';
|
||||||
@@ -14,6 +15,7 @@ import '../../../../widget/demo_restricted.dart';
|
|||||||
import '../../../../widget/downloads/download_trigger.dart';
|
import '../../../../widget/downloads/download_trigger.dart';
|
||||||
import '../data/chat_bubble_styles.dart';
|
import '../data/chat_bubble_styles.dart';
|
||||||
import '../data/chat_message.dart';
|
import '../data/chat_message.dart';
|
||||||
|
import '../data/talk_gallery_source.dart';
|
||||||
import 'answer_reference.dart';
|
import 'answer_reference.dart';
|
||||||
import 'bubble.dart';
|
import 'bubble.dart';
|
||||||
import 'chat_bubble_poll.dart';
|
import 'chat_bubble_poll.dart';
|
||||||
@@ -21,7 +23,7 @@ import 'chat_bubble_reactions.dart';
|
|||||||
import 'chat_message_options_dialog.dart';
|
import 'chat_message_options_dialog.dart';
|
||||||
import 'highlighted_linkify.dart';
|
import 'highlighted_linkify.dart';
|
||||||
|
|
||||||
enum SearchHighlight { none, secondary, active }
|
enum SearchHighlight { none, active }
|
||||||
|
|
||||||
const double _kSpacing = 3;
|
const double _kSpacing = 3;
|
||||||
const double _kTimeIconSize = 11;
|
const double _kTimeIconSize = 11;
|
||||||
@@ -137,13 +139,6 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
switch (widget.matchHighlight) {
|
switch (widget.matchHighlight) {
|
||||||
case SearchHighlight.none:
|
case SearchHighlight.none:
|
||||||
return base;
|
return base;
|
||||||
case SearchHighlight.secondary:
|
|
||||||
return base.copyWith(
|
|
||||||
borderWidth: 1.5,
|
|
||||||
borderColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary.withValues(alpha: 0.45),
|
|
||||||
);
|
|
||||||
case SearchHighlight.active:
|
case SearchHighlight.active:
|
||||||
return base.copyWith(
|
return base.copyWith(
|
||||||
borderWidth: 3,
|
borderWidth: 3,
|
||||||
@@ -192,9 +187,21 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
if (guardDemoAction(context)) return;
|
if (guardDemoAction(context)) return;
|
||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
confirmCancelDownload();
|
confirmCancelDownload();
|
||||||
} else {
|
return;
|
||||||
_startFileDownload();
|
|
||||||
}
|
}
|
||||||
|
final galleryItem = galleryItemFromTalk(widget.bubbleData);
|
||||||
|
if (galleryItem != null) {
|
||||||
|
AppRoutes.openMediaGallery(
|
||||||
|
context,
|
||||||
|
source: TalkGallerySource(
|
||||||
|
token: widget.chatData.token,
|
||||||
|
initial: galleryItem,
|
||||||
|
),
|
||||||
|
initialKey: galleryItem.key,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_startFileDownload();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateMessage(BuildContext context) {
|
void _updateMessage(BuildContext context) {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
|
import '../../../../model/endpoint_data.dart';
|
||||||
|
import '../../../../session/session_manager.dart';
|
||||||
|
import '../../../../widget/media_gallery/gallery_item.dart';
|
||||||
|
import '../../files/data/file_type_icon.dart';
|
||||||
|
|
||||||
|
/// File attachment in a chat bubble. Only what the media gallery pages through
|
||||||
|
/// renders as a plain picture; other previews (documents, audio covers) carry
|
||||||
|
/// a name strip so they don't pass for photos.
|
||||||
|
class ChatFileAttachment extends StatelessWidget {
|
||||||
|
final RichObjectString file;
|
||||||
|
|
||||||
|
const ChatFileAttachment({required this.file, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final kind = galleryMediaKindOf(file.name, mimeType: file.mimetype);
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl:
|
||||||
|
'${EndpointData().nextcloud().origin()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1',
|
||||||
|
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
fadeOutDuration: Duration.zero,
|
||||||
|
errorListener: (_) {},
|
||||||
|
placeholder: (context, url) => const Padding(
|
||||||
|
padding: EdgeInsets.all(15),
|
||||||
|
child: SizedBox(width: 50, child: LinearProgressIndicator()),
|
||||||
|
),
|
||||||
|
imageBuilder: switch (kind) {
|
||||||
|
GalleryMediaKind.image => null,
|
||||||
|
GalleryMediaKind.video => (context, image) => Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Image(image: image),
|
||||||
|
const _PlayBadge(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
null => (context, image) => Stack(
|
||||||
|
children: [
|
||||||
|
Image(image: image),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: _NameStrip(name: file.name),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
errorWidget: (context, url, error) => kind == GalleryMediaKind.video
|
||||||
|
? const _VideoPlaceholder()
|
||||||
|
: _FileRow(name: file.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayBadge extends StatelessWidget {
|
||||||
|
const _PlayBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(Icons.play_arrow, color: Colors.white, size: 28),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same footprint as a typical preview, so videos without a server thumbnail
|
||||||
|
/// still read as media.
|
||||||
|
class _VideoPlaceholder extends StatelessWidget {
|
||||||
|
const _VideoPlaceholder();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
width: 130,
|
||||||
|
height: 90,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black87,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: const Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.movie_outlined, color: Colors.white38, size: 56),
|
||||||
|
_PlayBadge(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NameStrip extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
const _NameStrip({required this.name});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ColoredBox(
|
||||||
|
color: Colors.black54,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(iconForFileName(name), size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileRow extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
const _FileRow({required this.name});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.file_open_outlined, size: 35),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|
||||||
final TextEditingController controller;
|
|
||||||
final int matchCount;
|
|
||||||
final int activeIndex;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
final VoidCallback onClose;
|
|
||||||
final VoidCallback? onPrevious;
|
|
||||||
final VoidCallback? onNext;
|
|
||||||
|
|
||||||
const ChatSearchAppBar({
|
|
||||||
required this.controller,
|
|
||||||
required this.matchCount,
|
|
||||||
required this.activeIndex,
|
|
||||||
required this.onChanged,
|
|
||||||
required this.onClose,
|
|
||||||
required this.onPrevious,
|
|
||||||
required this.onNext,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final counterText = matchCount == 0
|
|
||||||
? '0/0'
|
|
||||||
: '${activeIndex + 1}/$matchCount';
|
|
||||||
return AppBar(
|
|
||||||
leading: IconButton(
|
|
||||||
tooltip: 'Zurück',
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
onPressed: onClose,
|
|
||||||
),
|
|
||||||
title: TextField(
|
|
||||||
controller: controller,
|
|
||||||
autofocus: true,
|
|
||||||
textInputAction: TextInputAction.search,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: 'In Chat suchen…',
|
|
||||||
border: InputBorder.none,
|
|
||||||
),
|
|
||||||
onChanged: onChanged,
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: Text(
|
|
||||||
counterText,
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Vorheriges Ergebnis',
|
|
||||||
icon: const Icon(Icons.keyboard_arrow_up),
|
|
||||||
onPressed: onPrevious,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Nächstes Ergebnis',
|
|
||||||
icon: const Icon(Icons.keyboard_arrow_down),
|
|
||||||
onPressed: onNext,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../extensions/date_time.dart';
|
||||||
|
|
||||||
|
/// Shown while the chat pages back through its history towards a search hit.
|
||||||
|
/// Progress is estimated by time, since the server reports no message counts.
|
||||||
|
class HistoryJumpBanner extends StatelessWidget {
|
||||||
|
final DateTime startedAt;
|
||||||
|
final DateTime? target;
|
||||||
|
final DateTime? loadedUntil;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
|
||||||
|
const HistoryJumpBanner({
|
||||||
|
required this.startedAt,
|
||||||
|
required this.target,
|
||||||
|
required this.loadedUntil,
|
||||||
|
required this.onCancel,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
double? get _progress {
|
||||||
|
final target = this.target;
|
||||||
|
final loadedUntil = this.loadedUntil;
|
||||||
|
if (target == null || loadedUntil == null) return null;
|
||||||
|
final total = startedAt.difference(target).inSeconds;
|
||||||
|
if (total <= 0) return null;
|
||||||
|
return (startedAt.difference(loadedUntil).inSeconds / total).clamp(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final target = this.target;
|
||||||
|
final loadedUntil = this.loadedUntil;
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.history, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
target != null
|
||||||
|
? 'Springe zur Nachricht vom ${target.formatDate()}'
|
||||||
|
: 'Springe zur Nachricht',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
loadedUntil != null
|
||||||
|
? 'Verlauf geladen bis ${loadedUntil.formatDate()}'
|
||||||
|
: 'Lade älteren Verlauf …',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: onCancel, child: const Text('Abbrechen')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
LinearProgressIndicator(value: _progress),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,18 +39,24 @@ class AvatarDiskCache {
|
|||||||
|
|
||||||
/// Kicks off directory resolution so [readSync] can hit on the very first
|
/// Kicks off directory resolution so [readSync] can hit on the very first
|
||||||
/// avatar of a session. Fire-and-forget from app start; safe to call twice.
|
/// avatar of a session. Fire-and-forget from app start; safe to call twice.
|
||||||
void warmUp() => unawaited(_directory());
|
void warmUp() => unawaited(_directory().then((_) {}, onError: (Object _) {}));
|
||||||
|
|
||||||
Future<Directory> _directory() {
|
Future<Directory> _directory() {
|
||||||
return _dirFuture ??= _resolveDirectory();
|
return _dirFuture ??= _resolveDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> _resolveDirectory() async {
|
Future<Directory> _resolveDirectory() async {
|
||||||
|
try {
|
||||||
final base = await getApplicationCacheDirectory();
|
final base = await getApplicationCacheDirectory();
|
||||||
final dir = Directory('${base.path}/$_subDirectory');
|
final dir = Directory('${base.path}/$_subDirectory');
|
||||||
await dir.create(recursive: true);
|
await dir.create(recursive: true);
|
||||||
_dirPath = dir.path;
|
_dirPath = dir.path;
|
||||||
return dir;
|
return dir;
|
||||||
|
} on Object {
|
||||||
|
// Not memoized (disk full at launch): the next call tries again.
|
||||||
|
_dirFuture = null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// File-safe, prefix-evictable name. The subject id is hex-encoded so it can
|
/// File-safe, prefix-evictable name. The subject id is hex-encoded so it can
|
||||||
|
|||||||
@@ -53,13 +53,21 @@ class FilePick {
|
|||||||
return result?.files.map((e) => e.path).nonNulls.toList();
|
return result?.files.map((e) => e.path).nonNulls.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _inFlight = false;
|
||||||
|
|
||||||
|
/// A second tap while the native picker is already open (double tap, or a
|
||||||
|
/// tap on another source tile) would make the plugin throw instead of
|
||||||
|
/// opening a second picker; it is treated as a cancel.
|
||||||
static Future<T?> _guarded<T>(
|
static Future<T?> _guarded<T>(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Future<T?> Function() pick,
|
Future<T?> Function() pick,
|
||||||
) async {
|
) async {
|
||||||
|
if (_inFlight) return null;
|
||||||
|
_inFlight = true;
|
||||||
try {
|
try {
|
||||||
return await pick();
|
return await pick();
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
|
if (_isAlreadyActive(e)) return null;
|
||||||
final denied = PermissionException.fromPlatform(e);
|
final denied = PermissionException.fromPlatform(e);
|
||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
if (denied != null) {
|
if (denied != null) {
|
||||||
@@ -71,9 +79,15 @@ class FilePick {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (context.mounted) _showErrorDialog(context, e);
|
if (context.mounted) _showErrorDialog(context, e);
|
||||||
return null;
|
return null;
|
||||||
|
} finally {
|
||||||
|
_inFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android reports `already_active`, iOS `multiple_request`.
|
||||||
|
static bool _isAlreadyActive(PlatformException e) =>
|
||||||
|
e.code == 'already_active' || e.code == 'multiple_request';
|
||||||
|
|
||||||
static void _showPermissionDialog(
|
static void _showPermissionDialog(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
PermissionException denied,
|
PermissionException denied,
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:file_picker/file_picker.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
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:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:open_filex/open_filex.dart';
|
import 'package:open_filex/open_filex.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
|
|
||||||
import '../routing/app_routes.dart';
|
import '../routing/app_routes.dart';
|
||||||
import '../share_intent/remote_file_ref.dart';
|
import '../share_intent/remote_file_ref.dart';
|
||||||
@@ -18,16 +16,15 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
|||||||
import '../utils/downloads/download_manager.dart';
|
import '../utils/downloads/download_manager.dart';
|
||||||
import '../utils/screen_bound_image.dart';
|
import '../utils/screen_bound_image.dart';
|
||||||
import 'app_progress_indicator.dart';
|
import 'app_progress_indicator.dart';
|
||||||
import 'async_action_button.dart';
|
|
||||||
import 'centered_leading.dart';
|
import 'centered_leading.dart';
|
||||||
import 'downloads/stale_download_guard.dart';
|
import 'downloads/stale_download_guard.dart';
|
||||||
import 'file_viewer/code_line.dart';
|
import 'file_viewer/code_line.dart';
|
||||||
import 'file_viewer/deferred_pdf_viewer.dart';
|
import 'file_viewer/deferred_pdf_viewer.dart';
|
||||||
import 'file_viewer/file_kind.dart';
|
import 'file_viewer/file_kind.dart';
|
||||||
|
import 'file_viewer/local_file_actions.dart';
|
||||||
import 'file_viewer/media_player.dart';
|
import 'file_viewer/media_player.dart';
|
||||||
import 'file_viewer/unknown_preview_block.dart';
|
import 'file_viewer/unknown_preview_block.dart';
|
||||||
import 'info_dialog.dart';
|
import 'info_dialog.dart';
|
||||||
import 'share_position_origin.dart';
|
|
||||||
|
|
||||||
class FileViewer extends StatefulWidget {
|
class FileViewer extends StatefulWidget {
|
||||||
final String path;
|
final String path;
|
||||||
@@ -137,54 +134,11 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
break;
|
break;
|
||||||
case FileViewingActions.share:
|
case FileViewingActions.share:
|
||||||
if (!_ensureLocalFile()) return;
|
if (!_ensureLocalFile()) return;
|
||||||
unawaited(
|
unawaited(shareLocalFile(context, widget.path));
|
||||||
runWithErrorDialog(
|
|
||||||
context,
|
|
||||||
() => SharePlus.instance.share(
|
|
||||||
ShareParams(
|
|
||||||
files: [XFile(widget.path)],
|
|
||||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case FileViewingActions.save:
|
case FileViewingActions.save:
|
||||||
if (!_ensureLocalFile()) return;
|
if (!_ensureLocalFile()) return;
|
||||||
try {
|
await saveLocalFile(context, widget.path);
|
||||||
final source = File(widget.path);
|
|
||||||
final size = await source.length();
|
|
||||||
// file_picker has no path/stream save API, so the whole file
|
|
||||||
// gets loaded into RAM. Cap big media; user falls back to share.
|
|
||||||
const maxBytes = 200 * 1024 * 1024;
|
|
||||||
if (size > maxBytes) {
|
|
||||||
if (!mounted) return;
|
|
||||||
InfoDialog.show(
|
|
||||||
context,
|
|
||||||
'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), '
|
|
||||||
'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.',
|
|
||||||
title: 'Speichern nicht möglich',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final bytes = await source.readAsBytes();
|
|
||||||
final saved = await FilePicker.saveFile(
|
|
||||||
fileName: _fileName,
|
|
||||||
bytes: bytes,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
if (saved != null) {
|
|
||||||
InfoDialog.show(context, 'Datei gespeichert.');
|
|
||||||
}
|
|
||||||
} on Object catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
InfoDialog.show(
|
|
||||||
context,
|
|
||||||
'Speichern fehlgeschlagen: $e',
|
|
||||||
copyable: true,
|
|
||||||
title: 'Fehler',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,29 @@ const Set<String> _textExtensions = {
|
|||||||
'srt', 'vtt',
|
'srt', 'vtt',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Further raster formats the gallery shows. They decode via the platform
|
||||||
|
/// codecs (HEIC/AVIF) or are JPEG/ICO under another name. SVG and TIFF stay
|
||||||
|
/// out: without a server preview only the file viewer can render them.
|
||||||
|
const Set<String> _galleryOnlyImageExtensions = {
|
||||||
|
'heic',
|
||||||
|
'heif',
|
||||||
|
'avif',
|
||||||
|
'jfif',
|
||||||
|
'jpe',
|
||||||
|
'ico',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Extension-only check (no content sniff) for files the media gallery can
|
||||||
|
/// page through — the fallback when no MIME type is known.
|
||||||
|
bool isGalleryImageName(String name) {
|
||||||
|
final ext = name.split('.').last.toLowerCase();
|
||||||
|
return _imageExtensions.contains(ext) ||
|
||||||
|
_galleryOnlyImageExtensions.contains(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isGalleryVideoName(String name) =>
|
||||||
|
_videoExtensions.contains(name.split('.').last.toLowerCase());
|
||||||
|
|
||||||
/// Detects the [FileKind] of the file at [path] from its extension, falling
|
/// Detects the [FileKind] of the file at [path] from its extension, falling
|
||||||
/// back to an 8 KB content sniff ([_looksLikeText]) for unknown extensions.
|
/// back to an 8 KB content sniff ([_looksLikeText]) for unknown extensions.
|
||||||
Future<FileKind> detectFileKind(String path) async {
|
Future<FileKind> detectFileKind(String path) async {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
import '../async_action_button.dart';
|
||||||
|
import '../info_dialog.dart';
|
||||||
|
import '../share_position_origin.dart';
|
||||||
|
|
||||||
|
/// "Teilen"/"Speichern" for a file that is on disk, shared by the file viewer
|
||||||
|
/// and the media gallery.
|
||||||
|
Future<void> shareLocalFile(BuildContext context, String path) =>
|
||||||
|
runWithErrorDialog(
|
||||||
|
context,
|
||||||
|
() => SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
files: [XFile(path)],
|
||||||
|
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> saveLocalFile(BuildContext context, String path) async {
|
||||||
|
try {
|
||||||
|
final source = File(path);
|
||||||
|
final size = await source.length();
|
||||||
|
// file_picker has no path/stream save API, so the whole file
|
||||||
|
// gets loaded into RAM. Cap big media; user falls back to share.
|
||||||
|
const maxBytes = 200 * 1024 * 1024;
|
||||||
|
if (size > maxBytes) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
InfoDialog.show(
|
||||||
|
context,
|
||||||
|
'Diese Datei ist zu groß (${(size / (1024 * 1024)).toStringAsFixed(0)} MB), '
|
||||||
|
'um direkt gespeichert zu werden. Nutze stattdessen die Teilen-Funktion.',
|
||||||
|
title: 'Speichern nicht möglich',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final bytes = await source.readAsBytes();
|
||||||
|
final saved = await FilePicker.saveFile(
|
||||||
|
fileName: path.split('/').last,
|
||||||
|
bytes: bytes,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (saved != null) {
|
||||||
|
InfoDialog.show(context, 'Datei gespeichert.');
|
||||||
|
}
|
||||||
|
} on Object catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
InfoDialog.show(
|
||||||
|
context,
|
||||||
|
'Speichern fehlgeschlagen: $e',
|
||||||
|
copyable: true,
|
||||||
|
title: 'Fehler',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,13 @@ class MediaPlayer extends StatefulWidget {
|
|||||||
final String path;
|
final String path;
|
||||||
final bool isAudio;
|
final bool isAudio;
|
||||||
final String? filename;
|
final String? filename;
|
||||||
|
final bool autoPlay;
|
||||||
const MediaPlayer({
|
const MediaPlayer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.isAudio,
|
required this.isAudio,
|
||||||
this.filename,
|
this.filename,
|
||||||
|
this.autoPlay = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -57,7 +59,7 @@ class _MediaPlayerState extends State<MediaPlayer> {
|
|||||||
_video = controller;
|
_video = controller;
|
||||||
_chewie = ChewieController(
|
_chewie = ChewieController(
|
||||||
videoPlayerController: controller,
|
videoPlayerController: controller,
|
||||||
autoPlay: false,
|
autoPlay: widget.autoPlay,
|
||||||
looping: false,
|
looping: false,
|
||||||
allowFullScreen: true,
|
allowFullScreen: true,
|
||||||
allowPlaybackSpeedChanging: true,
|
allowPlaybackSpeedChanging: true,
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
|
||||||
|
import '../../api/marianumcloud/webdav/webdav_api.dart';
|
||||||
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../../session/session_manager.dart';
|
||||||
|
import '../../utils/downloads/download_manager.dart';
|
||||||
|
import '../../utils/screen_bound_image.dart';
|
||||||
|
import 'gallery_item.dart';
|
||||||
|
|
||||||
|
/// Server-rendered preview edge for the gallery; Nextcloud derives it from the
|
||||||
|
/// max preview it already keeps for the thumbnails.
|
||||||
|
const int _previewSize = 2048;
|
||||||
|
|
||||||
|
/// Originals the gallery loaded on its own — outside [DownloadManager], so no
|
||||||
|
/// notification or tray entry. Wiped whenever an account stops being active.
|
||||||
|
class GalleryOriginals extends ChangeNotifier {
|
||||||
|
GalleryOriginals._();
|
||||||
|
static final GalleryOriginals instance = GalleryOriginals._();
|
||||||
|
|
||||||
|
static final CacheManager _images = CacheManager(
|
||||||
|
Config(
|
||||||
|
'galleryOriginals',
|
||||||
|
stalePeriod: const Duration(days: 14),
|
||||||
|
maxNrOfCacheObjects: 150,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Videos are large; keep only a handful around.
|
||||||
|
static final CacheManager _videos = CacheManager(
|
||||||
|
Config(
|
||||||
|
'galleryVideos',
|
||||||
|
stalePeriod: const Duration(days: 3),
|
||||||
|
maxNrOfCacheObjects: 10,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final Map<String, _Flight> _flights = {};
|
||||||
|
|
||||||
|
static CacheManager _managerFor(GalleryItem item) =>
|
||||||
|
item.isVideo ? _videos : _images;
|
||||||
|
|
||||||
|
static String _cacheKey(GalleryItem item) =>
|
||||||
|
'${SessionManager().requireNextcloud().username}:${item.file.path}'
|
||||||
|
':${item.eTag ?? ''}';
|
||||||
|
|
||||||
|
static String _webdavUrl(String remotePath) =>
|
||||||
|
'${WebdavApi.buildWebdavUrl()}'
|
||||||
|
'${Uri.encodeComponent(remotePath).replaceAll('%2F', '/')}';
|
||||||
|
|
||||||
|
/// Download progress (0–1, null while the size is unknown) of a running
|
||||||
|
/// original download, or null when none runs.
|
||||||
|
ValueListenable<double?>? progressOf(GalleryItem item) =>
|
||||||
|
_flights[_cacheKey(item)]?.progress;
|
||||||
|
|
||||||
|
Future<File?> cached(GalleryItem item) async {
|
||||||
|
final info = await _managerFor(item).getFileFromCache(_cacheKey(item));
|
||||||
|
final file = info?.file;
|
||||||
|
return file != null && file.existsSync() ? file : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the original once; parallel callers share the same download.
|
||||||
|
Future<File> load(GalleryItem item) {
|
||||||
|
final key = _cacheKey(item);
|
||||||
|
final running = _flights[key];
|
||||||
|
if (running != null) return running.completer.future;
|
||||||
|
|
||||||
|
final flight = _Flight();
|
||||||
|
_flights[key] = flight;
|
||||||
|
notifyListeners();
|
||||||
|
final epoch = SessionManager().sessionEpoch;
|
||||||
|
final manager = _managerFor(item);
|
||||||
|
flight.subscription = manager
|
||||||
|
.getFileStream(
|
||||||
|
_webdavUrl(item.file.path),
|
||||||
|
key: key,
|
||||||
|
headers: SessionManager().requireNextcloud().authHeaders,
|
||||||
|
withProgress: true,
|
||||||
|
)
|
||||||
|
.listen(
|
||||||
|
(response) {
|
||||||
|
if (response is DownloadProgress) {
|
||||||
|
flight.progress.value = response.progress;
|
||||||
|
} else if (response is FileInfo) {
|
||||||
|
if (!SessionManager().isCurrentSession(epoch)) {
|
||||||
|
unawaited(manager.removeFile(key));
|
||||||
|
_finish(key, error: StateError('Konto gewechselt'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_finish(key, file: response.file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (Object e) => _finish(key, error: e),
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
return flight.completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _finish(String key, {File? file, Object? error}) {
|
||||||
|
final flight = _flights.remove(key);
|
||||||
|
if (flight == null) return;
|
||||||
|
unawaited(flight.cancel());
|
||||||
|
if (file != null) {
|
||||||
|
flight.completer.complete(file);
|
||||||
|
} else {
|
||||||
|
flight.completer.completeError(
|
||||||
|
error ?? StateError('Download fehlgeschlagen'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
scheduleMicrotask(flight.progress.dispose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies the original under its real file name (into the downloads folder,
|
||||||
|
/// which [SessionWipe] clears) — share sheets and "Öffnen mit" need the name
|
||||||
|
/// and extension, the cache file has neither.
|
||||||
|
Future<String> namedCopy(GalleryItem item) async {
|
||||||
|
final source = await cached(item) ?? await load(item);
|
||||||
|
final dir = await DownloadManager.downloadsDirectory();
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
final target = File('${dir.path}/${item.file.name}');
|
||||||
|
await source.copy(target.path);
|
||||||
|
return target.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear() async {
|
||||||
|
for (final key in _flights.keys.toList()) {
|
||||||
|
_finish(key, error: StateError('Abgebrochen'));
|
||||||
|
}
|
||||||
|
await _images.emptyCache();
|
||||||
|
await _videos.emptyCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Flight {
|
||||||
|
final Completer<File> completer = Completer<File>();
|
||||||
|
final ValueNotifier<double?> progress = ValueNotifier(null);
|
||||||
|
StreamSubscription<FileResponse>? subscription;
|
||||||
|
|
||||||
|
Future<void> cancel() async => subscription?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Screen-sized server preview. One instance per item and size, so the
|
||||||
|
/// viewer's precache and the page's image hit the same cache entry.
|
||||||
|
ImageProvider galleryPreviewProvider(BuildContext context, GalleryItem item) {
|
||||||
|
final fileId = item.file.fileId;
|
||||||
|
final url = fileId != null
|
||||||
|
? '${EndpointData().nextcloud().origin()}/index.php/core/preview'
|
||||||
|
'?fileId=$fileId&x=$_previewSize&y=$_previewSize&a=1'
|
||||||
|
: '${EndpointData().nextcloud().origin()}/index.php/core/preview.png'
|
||||||
|
'?file=${Uri.encodeQueryComponent(item.file.path)}'
|
||||||
|
'&x=$_previewSize&y=$_previewSize&a=1';
|
||||||
|
return screenBoundImage(
|
||||||
|
context,
|
||||||
|
CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
headers: SessionManager().requireNextcloud().authHeaders,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
|
||||||
|
import '../../utils/screen_bound_image.dart';
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
|
import 'gallery_cache.dart';
|
||||||
|
import 'gallery_item.dart';
|
||||||
|
|
||||||
|
/// One image in the gallery: the server preview first, the original on top
|
||||||
|
/// once the user rests on the page or zooms in.
|
||||||
|
class GalleryImagePage extends StatefulWidget {
|
||||||
|
final GalleryItem item;
|
||||||
|
final bool isActive;
|
||||||
|
final ValueListenable<int> rotateSignal;
|
||||||
|
|
||||||
|
const GalleryImagePage({
|
||||||
|
required this.item,
|
||||||
|
required this.isActive,
|
||||||
|
required this.rotateSignal,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GalleryImagePage> createState() => _GalleryImagePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryImagePageState extends State<GalleryImagePage> {
|
||||||
|
// Short enough to feel instant, long enough that flicking through a chat
|
||||||
|
// does not pull every original.
|
||||||
|
static const _restDelay = Duration(milliseconds: 400);
|
||||||
|
static const _fadeDuration = Duration(milliseconds: 200);
|
||||||
|
static const _doubleTapZoom = 2.5;
|
||||||
|
|
||||||
|
/// Max zoom reaches this many times the image's native pixel density
|
||||||
|
/// (1 image pixel = 1 screen pixel), so sharp photos zoom far in.
|
||||||
|
static const _beyondNativeZoom = 3.0;
|
||||||
|
|
||||||
|
/// Floor for small images, which would otherwise barely zoom at all.
|
||||||
|
static const _minMaxZoom = 4.0;
|
||||||
|
|
||||||
|
final PhotoViewController _controller = PhotoViewController();
|
||||||
|
final PhotoViewScaleStateController _scaleState =
|
||||||
|
PhotoViewScaleStateController();
|
||||||
|
final List<(ImageStream, ImageStreamListener)> _streams = [];
|
||||||
|
StreamSubscription<PhotoViewControllerValue>? _zoomSub;
|
||||||
|
Timer? _restTimer;
|
||||||
|
Timer? _hidePreviewTimer;
|
||||||
|
|
||||||
|
ImageProvider? _preview;
|
||||||
|
ImageProvider? _original;
|
||||||
|
Size? _imageSize;
|
||||||
|
|
||||||
|
/// Decoded pixel size of the original, once shown.
|
||||||
|
Size? _originalSize;
|
||||||
|
bool _previewFailed = false;
|
||||||
|
bool _originalFailed = false;
|
||||||
|
bool _loadingOriginal = false;
|
||||||
|
bool _originalReady = false;
|
||||||
|
bool _hidePreview = false;
|
||||||
|
ValueListenable<double?>? _progress;
|
||||||
|
late int _rotationsSeen;
|
||||||
|
|
||||||
|
bool get _isZoomed => switch (_scaleState.scaleState) {
|
||||||
|
PhotoViewScaleState.zoomedIn ||
|
||||||
|
PhotoViewScaleState.covering ||
|
||||||
|
PhotoViewScaleState.originalSize => true,
|
||||||
|
PhotoViewScaleState.initial || PhotoViewScaleState.zoomedOut => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_rotationsSeen = widget.rotateSignal.value;
|
||||||
|
widget.rotateSignal.addListener(_onRotate);
|
||||||
|
GalleryOriginals.instance.addListener(_onFlightsChanged);
|
||||||
|
_progress = GalleryOriginals.instance.progressOf(widget.item);
|
||||||
|
// Pinch zoom only updates the scale state silently, so listen to the
|
||||||
|
// controller instead of scaleStateChangedCallback.
|
||||||
|
_zoomSub = _controller.outputStateStream.listen((_) {
|
||||||
|
if (_isZoomed) _loadOriginal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_preview != null) return;
|
||||||
|
final preview = galleryPreviewProvider(context, widget.item);
|
||||||
|
_preview = preview;
|
||||||
|
_listen(
|
||||||
|
preview,
|
||||||
|
onImage: _adoptSize,
|
||||||
|
onError: () {
|
||||||
|
setState(() => _previewFailed = true);
|
||||||
|
_loadOriginal();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
unawaited(_useCachedOriginal());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant GalleryImagePage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.isActive == oldWidget.isActive) return;
|
||||||
|
if (widget.isActive) {
|
||||||
|
_scheduleRest();
|
||||||
|
} else {
|
||||||
|
_restTimer?.cancel();
|
||||||
|
_scaleState.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.rotateSignal.removeListener(_onRotate);
|
||||||
|
GalleryOriginals.instance.removeListener(_onFlightsChanged);
|
||||||
|
_restTimer?.cancel();
|
||||||
|
_hidePreviewTimer?.cancel();
|
||||||
|
unawaited(_zoomSub?.cancel());
|
||||||
|
for (final (stream, listener) in _streams) {
|
||||||
|
stream.removeListener(listener);
|
||||||
|
}
|
||||||
|
_controller.dispose();
|
||||||
|
_scaleState.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _useCachedOriginal() async {
|
||||||
|
final cached = await GalleryOriginals.instance.cached(widget.item);
|
||||||
|
if (!mounted || _original != null) return;
|
||||||
|
if (cached != null) {
|
||||||
|
_setOriginal(cached);
|
||||||
|
} else if (widget.item.needsOriginal) {
|
||||||
|
_loadOriginal();
|
||||||
|
} else {
|
||||||
|
_scheduleRest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleRest() {
|
||||||
|
if (!widget.isActive || _original != null || _originalFailed) return;
|
||||||
|
_restTimer?.cancel();
|
||||||
|
_restTimer = Timer(_restDelay, _loadOriginal);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadOriginal() {
|
||||||
|
if (_original != null || _loadingOriginal || _originalFailed) return;
|
||||||
|
_restTimer?.cancel();
|
||||||
|
_loadingOriginal = true;
|
||||||
|
GalleryOriginals.instance
|
||||||
|
.load(widget.item)
|
||||||
|
.then(
|
||||||
|
(file) {
|
||||||
|
if (mounted) _setOriginal(file);
|
||||||
|
},
|
||||||
|
onError: (Object _) {
|
||||||
|
if (!mounted) return;
|
||||||
|
// The preview stays; no automatic retry, or every zoom frame would
|
||||||
|
// start a new request.
|
||||||
|
setState(() {
|
||||||
|
_loadingOriginal = false;
|
||||||
|
_originalFailed = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setOriginal(File file) {
|
||||||
|
final original = screenBoundImage(context, FileImage(file), scale: 2);
|
||||||
|
setState(() {
|
||||||
|
_original = original;
|
||||||
|
_loadingOriginal = false;
|
||||||
|
});
|
||||||
|
_listen(
|
||||||
|
original,
|
||||||
|
onImage: (size) {
|
||||||
|
_adoptSize(size);
|
||||||
|
setState(() {
|
||||||
|
_originalReady = true;
|
||||||
|
_originalSize = size;
|
||||||
|
});
|
||||||
|
// Drop the preview after the fade so transparent images don't show
|
||||||
|
// it through.
|
||||||
|
_hidePreviewTimer = Timer(_fadeDuration, () {
|
||||||
|
if (mounted) setState(() => _hidePreview = true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => setState(() {
|
||||||
|
_original = null;
|
||||||
|
_originalFailed = true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first resolved image fixes the zoom geometry; the other one is drawn
|
||||||
|
/// into the same box, so swapping preview and original keeps the zoom.
|
||||||
|
void _adoptSize(Size size) {
|
||||||
|
if (_imageSize != null) return;
|
||||||
|
setState(() => _imageSize = size);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _listen(
|
||||||
|
ImageProvider provider, {
|
||||||
|
required void Function(Size size) onImage,
|
||||||
|
required VoidCallback onError,
|
||||||
|
}) {
|
||||||
|
final stream = provider.resolve(createLocalImageConfiguration(context));
|
||||||
|
late final ImageStreamListener listener;
|
||||||
|
late final (ImageStream, ImageStreamListener) entry;
|
||||||
|
// A completer throws when a listener is removed twice, so a fired
|
||||||
|
// listener also leaves the list dispose() walks.
|
||||||
|
void detach() {
|
||||||
|
stream.removeListener(listener);
|
||||||
|
_streams.remove(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
listener = ImageStreamListener(
|
||||||
|
(info, _) {
|
||||||
|
detach();
|
||||||
|
if (!mounted) return;
|
||||||
|
onImage(
|
||||||
|
Size(info.image.width.toDouble(), info.image.height.toDouble()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (_, _) {
|
||||||
|
detach();
|
||||||
|
if (mounted) onError();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
entry = (stream, listener);
|
||||||
|
_streams.add(entry);
|
||||||
|
stream.addListener(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRotate() {
|
||||||
|
final value = widget.rotateSignal.value;
|
||||||
|
final pending = value - _rotationsSeen;
|
||||||
|
_rotationsSeen = value;
|
||||||
|
if (widget.isActive && pending > 0) {
|
||||||
|
_controller.rotation += pending * pi / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFlightsChanged() {
|
||||||
|
final progress = GalleryOriginals.instance.progressOf(widget.item);
|
||||||
|
if (progress != _progress && mounted) setState(() => _progress = progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Double tap toggles between fitted and [_doubleTapZoom]× fitted.
|
||||||
|
static PhotoViewScaleState _scaleCycle(PhotoViewScaleState state) =>
|
||||||
|
state == PhotoViewScaleState.initial
|
||||||
|
? PhotoViewScaleState.originalSize
|
||||||
|
: PhotoViewScaleState.initial;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final imageSize = _imageSize;
|
||||||
|
final background = Theme.of(context).colorScheme.surface;
|
||||||
|
if (imageSize == null) {
|
||||||
|
if (_previewFailed && _originalFailed) {
|
||||||
|
return const _ImageUnavailable();
|
||||||
|
}
|
||||||
|
return const Center(child: AppProgressIndicator.large());
|
||||||
|
}
|
||||||
|
final preview = _preview;
|
||||||
|
final original = _original;
|
||||||
|
final progress = _progress;
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
// PhotoView's "original size" is the child box itself; sizing it
|
||||||
|
// to a multiple of the fitted image makes that the double-tap
|
||||||
|
// zoom, independent of preview vs. original resolution.
|
||||||
|
final fitted = applyBoxFit(
|
||||||
|
BoxFit.contain,
|
||||||
|
imageSize,
|
||||||
|
constraints.biggest,
|
||||||
|
).destination;
|
||||||
|
final childSize = fitted * _doubleTapZoom;
|
||||||
|
// Until the original is in, the preview's resolution bounds the
|
||||||
|
// zoom; it widens once the original arrives.
|
||||||
|
final detail = _originalSize ?? imageSize;
|
||||||
|
final nativeZoom =
|
||||||
|
detail.width /
|
||||||
|
MediaQuery.devicePixelRatioOf(context) /
|
||||||
|
fitted.width;
|
||||||
|
final maxZoom = max(_minMaxZoom, nativeZoom * _beyondNativeZoom);
|
||||||
|
return PhotoView.customChild(
|
||||||
|
controller: _controller,
|
||||||
|
scaleStateController: _scaleState,
|
||||||
|
childSize: childSize,
|
||||||
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
maxScale: PhotoViewComputedScale.contained * maxZoom,
|
||||||
|
scaleStateCycle: _scaleCycle,
|
||||||
|
// A non-none filterQuality makes PhotoView resize only its own
|
||||||
|
// Image instead of transforming — a custom child would never
|
||||||
|
// scale. Quality is set on the images below instead.
|
||||||
|
filterQuality: FilterQuality.none,
|
||||||
|
backgroundDecoration: BoxDecoration(color: background),
|
||||||
|
child: SizedBox.fromSize(
|
||||||
|
size: childSize,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (preview != null && !_previewFailed && !_hidePreview)
|
||||||
|
Image(
|
||||||
|
image: preview,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
),
|
||||||
|
if (original != null)
|
||||||
|
AnimatedOpacity(
|
||||||
|
opacity: _originalReady ? 1 : 0,
|
||||||
|
duration: _fadeDuration,
|
||||||
|
child: Image(
|
||||||
|
image: original,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: SafeArea(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: _fadeDuration,
|
||||||
|
child: progress == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: ValueListenableBuilder<double?>(
|
||||||
|
valueListenable: progress,
|
||||||
|
builder: (context, value, _) =>
|
||||||
|
GalleryProgressBadge(value: value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small, unobtrusive ring for a running original download.
|
||||||
|
class GalleryProgressBadge extends StatelessWidget {
|
||||||
|
final double? value;
|
||||||
|
|
||||||
|
const GalleryProgressBadge({required this.value, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black45,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: value,
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
semanticsLabel: 'Original wird geladen',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageUnavailable extends StatelessWidget {
|
||||||
|
const _ImageUnavailable();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.broken_image_outlined, size: 48),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Bild konnte nicht geladen werden',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import '../../share_intent/remote_file_ref.dart';
|
||||||
|
import '../file_viewer/file_kind.dart';
|
||||||
|
|
||||||
|
enum GalleryMediaKind { image, video }
|
||||||
|
|
||||||
|
/// Single rule for "is this a picture/video": the gallery and the chat bubble
|
||||||
|
/// both use it, so whatever looks like media in a chat is also in the gallery.
|
||||||
|
GalleryMediaKind? galleryMediaKindOf(String name, {String? mimeType}) {
|
||||||
|
final mime = mimeType?.toLowerCase() ?? '';
|
||||||
|
// Only formats the device decodes itself — image/* also covers PSD, SVG,
|
||||||
|
// TIFF …, which would hinge on a server preview.
|
||||||
|
if (_galleryImageMimes.contains(mime)) return GalleryMediaKind.image;
|
||||||
|
if (mime.startsWith('video/')) return GalleryMediaKind.video;
|
||||||
|
if (isGalleryImageName(name)) return GalleryMediaKind.image;
|
||||||
|
if (isGalleryVideoName(name)) return GalleryMediaKind.video;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Set<String> _galleryImageMimes = {
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
'image/bmp',
|
||||||
|
'image/x-ms-bmp',
|
||||||
|
'image/heic',
|
||||||
|
'image/heif',
|
||||||
|
'image/avif',
|
||||||
|
'image/x-icon',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
};
|
||||||
|
|
||||||
|
class GalleryItem {
|
||||||
|
final RemoteFileRef file;
|
||||||
|
final GalleryMediaKind kind;
|
||||||
|
|
||||||
|
/// Talk message id: orders chat media and dedupes overlapping pages.
|
||||||
|
final int? messageId;
|
||||||
|
|
||||||
|
/// Second line in the app bar, e.g. sender and date.
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
|
/// Part of the cache key so an overwritten file is not served stale.
|
||||||
|
final String? eTag;
|
||||||
|
|
||||||
|
const GalleryItem({
|
||||||
|
required this.file,
|
||||||
|
required this.kind,
|
||||||
|
this.messageId,
|
||||||
|
this.subtitle,
|
||||||
|
this.eTag,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get key => messageId?.toString() ?? file.path;
|
||||||
|
|
||||||
|
bool get isVideo => kind == GalleryMediaKind.video;
|
||||||
|
|
||||||
|
String get _extension => file.name.split('.').last.toLowerCase();
|
||||||
|
|
||||||
|
/// Server previews of a GIF are a single still frame.
|
||||||
|
bool get needsOriginal => _extension == 'gif';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds [incoming] to [current], dropping duplicates by [GalleryItem.key] and
|
||||||
|
/// keeping chat media in chronological (message id) order.
|
||||||
|
List<GalleryItem> mergeGalleryItems(
|
||||||
|
List<GalleryItem> current,
|
||||||
|
Iterable<GalleryItem> incoming,
|
||||||
|
) {
|
||||||
|
final byKey = {for (final item in current) item.key: item};
|
||||||
|
for (final item in incoming) {
|
||||||
|
byKey.putIfAbsent(item.key, () => item);
|
||||||
|
}
|
||||||
|
final merged = byKey.values.toList();
|
||||||
|
if (merged.every((item) => item.messageId != null)) {
|
||||||
|
merged.sort((a, b) => a.messageId!.compareTo(b.messageId!));
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'gallery_item.dart';
|
||||||
|
|
||||||
|
/// Items the media gallery pages through. Sources that grow while the viewer
|
||||||
|
/// is open (chat history) notify; the viewer keeps the shown item in place.
|
||||||
|
abstract class GallerySource extends ChangeNotifier {
|
||||||
|
List<GalleryItem> get items;
|
||||||
|
|
||||||
|
bool get hasOlder => false;
|
||||||
|
|
||||||
|
/// Called when the user nears the start (oldest end) of [items].
|
||||||
|
Future<void> loadOlder() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StaticGallerySource extends GallerySource {
|
||||||
|
@override
|
||||||
|
final List<GalleryItem> items;
|
||||||
|
|
||||||
|
StaticGallerySource(this.items);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../file_viewer/media_player.dart';
|
||||||
|
import 'gallery_cache.dart';
|
||||||
|
import 'gallery_item.dart';
|
||||||
|
|
||||||
|
/// A video in the gallery: thumbnail and play button; the file is only
|
||||||
|
/// downloaded on play. Leaving the page tears the player down.
|
||||||
|
class GalleryVideoPage extends StatefulWidget {
|
||||||
|
final GalleryItem item;
|
||||||
|
final bool isActive;
|
||||||
|
|
||||||
|
const GalleryVideoPage({
|
||||||
|
required this.item,
|
||||||
|
required this.isActive,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GalleryVideoPage> createState() => _GalleryVideoPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryVideoPageState extends State<GalleryVideoPage> {
|
||||||
|
String? _path;
|
||||||
|
bool _playing = false;
|
||||||
|
bool _failed = false;
|
||||||
|
ValueListenable<double?>? _progress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
GalleryOriginals.instance.addListener(_onFlightsChanged);
|
||||||
|
_progress = GalleryOriginals.instance.progressOf(widget.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant GalleryVideoPage oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (!widget.isActive && _playing) _playing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
GalleryOriginals.instance.removeListener(_onFlightsChanged);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onFlightsChanged() {
|
||||||
|
final progress = GalleryOriginals.instance.progressOf(widget.item);
|
||||||
|
if (progress != _progress && mounted) setState(() => _progress = progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _play() async {
|
||||||
|
setState(() => _failed = false);
|
||||||
|
try {
|
||||||
|
final file =
|
||||||
|
await GalleryOriginals.instance.cached(widget.item) ??
|
||||||
|
await GalleryOriginals.instance.load(widget.item);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_path = file.path;
|
||||||
|
_playing = widget.isActive;
|
||||||
|
});
|
||||||
|
} on Object {
|
||||||
|
if (mounted) setState(() => _failed = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final path = _path;
|
||||||
|
if (_playing && path != null) {
|
||||||
|
return MediaPlayer(
|
||||||
|
key: ValueKey(path),
|
||||||
|
path: path,
|
||||||
|
isAudio: false,
|
||||||
|
autoPlay: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final progress = _progress;
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Image(
|
||||||
|
image: galleryPreviewProvider(context, widget.item),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, _, _) =>
|
||||||
|
const Center(child: Icon(Icons.movie_outlined, size: 96)),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: progress != null
|
||||||
|
? ValueListenableBuilder<double?>(
|
||||||
|
valueListenable: progress,
|
||||||
|
builder: (context, value, _) => _PlayCircle(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: value,
|
||||||
|
color: Colors.white,
|
||||||
|
strokeWidth: 3,
|
||||||
|
semanticsLabel: 'Video wird geladen',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _PlayCircle(
|
||||||
|
onTap: () => unawaited(_play()),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 40,
|
||||||
|
semanticLabel: 'Abspielen',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_failed)
|
||||||
|
Positioned(
|
||||||
|
left: 24,
|
||||||
|
right: 24,
|
||||||
|
bottom: 32,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Text(
|
||||||
|
'Video konnte nicht geladen werden. Tippe erneut auf Abspielen.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayCircle extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const _PlayCircle({required this.child, this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Material(
|
||||||
|
color: Colors.black54,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
child: Center(child: SizedBox(width: 40, height: 40, child: child)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
|
||||||
|
import '../../routing/app_routes.dart';
|
||||||
|
import '../app_progress_indicator.dart';
|
||||||
|
import '../file_viewer/local_file_actions.dart';
|
||||||
|
import '../info_dialog.dart';
|
||||||
|
import 'gallery_cache.dart';
|
||||||
|
import 'gallery_image_page.dart';
|
||||||
|
import 'gallery_item.dart';
|
||||||
|
import 'gallery_source.dart';
|
||||||
|
import 'gallery_video_page.dart';
|
||||||
|
|
||||||
|
enum _GalleryAction { openExternal, sendToChat, saveToCloud, share, save }
|
||||||
|
|
||||||
|
/// Full-screen, swipeable view over the images and videos of a chat or
|
||||||
|
/// folder. Owns (and disposes) [source].
|
||||||
|
class MediaGalleryViewer extends StatefulWidget {
|
||||||
|
final GallerySource source;
|
||||||
|
final String initialKey;
|
||||||
|
|
||||||
|
const MediaGalleryViewer({
|
||||||
|
required this.source,
|
||||||
|
required this.initialKey,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MediaGalleryViewer> createState() => _MediaGalleryViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MediaGalleryViewerState extends State<MediaGalleryViewer> {
|
||||||
|
/// Neighbours whose previews are fetched ahead, per side.
|
||||||
|
static const _precacheRange = 2;
|
||||||
|
|
||||||
|
late final PageController _pageController;
|
||||||
|
final ValueNotifier<int> _rotateSignal = ValueNotifier(0);
|
||||||
|
late String _anchorKey;
|
||||||
|
late int _index;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
List<GalleryItem> get _items => widget.source.items;
|
||||||
|
|
||||||
|
GalleryItem get _current => _items[_index];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_anchorKey = widget.initialKey;
|
||||||
|
_index = _items.indexWhere((item) => item.key == _anchorKey);
|
||||||
|
if (_index < 0) _index = 0;
|
||||||
|
_pageController = PageController(initialPage: _index);
|
||||||
|
widget.source.addListener(_onSourceChanged);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) _onIndexSettled();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.source.removeListener(_onSourceChanged);
|
||||||
|
widget.source.dispose();
|
||||||
|
_pageController.dispose();
|
||||||
|
_rotateSignal.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Older chat media lands in front of the shown item; jump along so the
|
||||||
|
/// user keeps looking at the same picture.
|
||||||
|
void _onSourceChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final index = _items.indexWhere((item) => item.key == _anchorKey);
|
||||||
|
setState(() {
|
||||||
|
if (index >= 0) _index = index;
|
||||||
|
});
|
||||||
|
if (index >= 0 &&
|
||||||
|
_pageController.hasClients &&
|
||||||
|
_pageController.page?.round() != index) {
|
||||||
|
_pageController.jumpToPage(index);
|
||||||
|
}
|
||||||
|
_precacheAround();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPageChanged(int index) {
|
||||||
|
setState(() {
|
||||||
|
_index = index;
|
||||||
|
_anchorKey = _items[index].key;
|
||||||
|
});
|
||||||
|
_onIndexSettled();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIndexSettled() {
|
||||||
|
_precacheAround();
|
||||||
|
if (_index <= _precacheRange && widget.source.hasOlder) {
|
||||||
|
unawaited(widget.source.loadOlder());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _precacheAround() {
|
||||||
|
for (var d = -_precacheRange; d <= _precacheRange; d++) {
|
||||||
|
final i = _index + d;
|
||||||
|
if (d == 0 || i < 0 || i >= _items.length) continue;
|
||||||
|
unawaited(
|
||||||
|
precacheImage(
|
||||||
|
galleryPreviewProvider(context, _items[i]),
|
||||||
|
context,
|
||||||
|
onError: (_, _) {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleAction(_GalleryAction action) async {
|
||||||
|
final item = _current;
|
||||||
|
switch (action) {
|
||||||
|
case _GalleryAction.sendToChat:
|
||||||
|
AppRoutes.openInternalShareToChat(context, item.file);
|
||||||
|
return;
|
||||||
|
case _GalleryAction.saveToCloud:
|
||||||
|
AppRoutes.openInternalSaveToFolder(context, item.file);
|
||||||
|
return;
|
||||||
|
case _GalleryAction.openExternal:
|
||||||
|
case _GalleryAction.share:
|
||||||
|
case _GalleryAction.save:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (_busy) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
final String path;
|
||||||
|
try {
|
||||||
|
path = await GalleryOriginals.instance.namedCopy(item);
|
||||||
|
} on Object catch (e) {
|
||||||
|
if (mounted) InfoDialog.showError(context, e);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
switch (action) {
|
||||||
|
case _GalleryAction.openExternal:
|
||||||
|
AppRoutes.openFileViewer(
|
||||||
|
context,
|
||||||
|
path,
|
||||||
|
openExternal: true,
|
||||||
|
remoteFile: item.file,
|
||||||
|
);
|
||||||
|
case _GalleryAction.share:
|
||||||
|
await shareLocalFile(context, path);
|
||||||
|
case _GalleryAction.save:
|
||||||
|
await saveLocalFile(context, path);
|
||||||
|
case _GalleryAction.sendToChat:
|
||||||
|
case _GalleryAction.saveToCloud:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _appBar(BuildContext context) {
|
||||||
|
final item = _current;
|
||||||
|
final subtitle = item.subtitle;
|
||||||
|
final position = '${_index + 1} / ${_items.length}';
|
||||||
|
return AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(item.file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
Text(
|
||||||
|
subtitle == null ? position : '$position · $subtitle',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_busy)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Center(child: AppProgressIndicator.small()),
|
||||||
|
),
|
||||||
|
if (!item.isVideo)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _rotateSignal.value++,
|
||||||
|
tooltip: 'Drehen',
|
||||||
|
icon: const Icon(Icons.rotate_right),
|
||||||
|
),
|
||||||
|
PopupMenuButton<_GalleryAction>(
|
||||||
|
tooltip: 'Dateiaktionen',
|
||||||
|
onSelected: (action) => unawaited(_handleAction(action)),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
_menuItem(
|
||||||
|
_GalleryAction.openExternal,
|
||||||
|
Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
|
||||||
|
Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
|
||||||
|
),
|
||||||
|
_menuItem(
|
||||||
|
_GalleryAction.sendToChat,
|
||||||
|
Icons.chat_bubble_outline,
|
||||||
|
'An Talk-Chat senden',
|
||||||
|
),
|
||||||
|
_menuItem(
|
||||||
|
_GalleryAction.saveToCloud,
|
||||||
|
Icons.cloud_outlined,
|
||||||
|
'In Cloud speichern',
|
||||||
|
),
|
||||||
|
_menuItem(_GalleryAction.share, Icons.share_outlined, 'Teilen'),
|
||||||
|
_menuItem(
|
||||||
|
_GalleryAction.save,
|
||||||
|
Icons.save_alt_outlined,
|
||||||
|
'Speichern',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupMenuItem<_GalleryAction> _menuItem(
|
||||||
|
_GalleryAction action,
|
||||||
|
IconData icon,
|
||||||
|
String label,
|
||||||
|
) => PopupMenuItem(
|
||||||
|
value: action,
|
||||||
|
child: ListTile(leading: Icon(icon), title: Text(label), dense: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
int? _indexForKey(Key key) {
|
||||||
|
if (key is! ValueKey<String>) return null;
|
||||||
|
final index = _items.indexWhere((item) => item.key == key.value);
|
||||||
|
return index < 0 ? null : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Scaffold(
|
||||||
|
appBar: _appBar(context),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
// The scope lets a page's PhotoView hand horizontal drags to the
|
||||||
|
// PageView once the image is panned to its edge.
|
||||||
|
body: PhotoViewGestureDetectorScope(
|
||||||
|
axis: Axis.horizontal,
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
itemCount: _items.length,
|
||||||
|
onPageChanged: _onPageChanged,
|
||||||
|
allowImplicitScrolling: true,
|
||||||
|
findChildIndexCallback: _indexForKey,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = _items[index];
|
||||||
|
final isActive = index == _index;
|
||||||
|
return ClipRect(
|
||||||
|
key: ValueKey(item.key),
|
||||||
|
child: item.isVideo
|
||||||
|
? GalleryVideoPage(item: item, isActive: isActive)
|
||||||
|
: GalleryImagePage(
|
||||||
|
item: item,
|
||||||
|
isActive: isActive,
|
||||||
|
rotateSignal: _rotateSignal,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ class PmImageView extends StatelessWidget {
|
|||||||
final available = constraints.maxWidth.isFinite
|
final available = constraints.maxWidth.isFinite
|
||||||
? constraints.maxWidth
|
? constraints.maxWidth
|
||||||
: MediaQuery.sizeOf(context).width;
|
: MediaQuery.sizeOf(context).width;
|
||||||
final targetWidth = _resolveWidth(node.width, available);
|
final targetWidth = resolvePmImageWidth(node.width, available);
|
||||||
|
|
||||||
Widget image = ConstrainedBox(
|
Widget image = ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
|
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
|
||||||
@@ -121,8 +121,13 @@ class PmImageView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
double? _resolveWidth(String? width, double available) {
|
}
|
||||||
if (width == null) return null;
|
|
||||||
|
/// Resolves a CSS `width` attribute against [available] pixels, or `null` when
|
||||||
|
/// the image should simply fill what it gets. Layout may hand out zero width
|
||||||
|
/// mid-animation, which would otherwise invert the clamp bounds.
|
||||||
|
double? resolvePmImageWidth(String? width, double available) {
|
||||||
|
if (width == null || available < 1) return null;
|
||||||
final length = parsePmCssLength(width);
|
final length = parsePmCssLength(width);
|
||||||
if (length == null) return null;
|
if (length == null) return null;
|
||||||
final value = length.value;
|
final value = length.value;
|
||||||
@@ -132,5 +137,4 @@ class PmImageView extends StatelessWidget {
|
|||||||
_ => value,
|
_ => value,
|
||||||
};
|
};
|
||||||
return resolved.clamp(1, available);
|
return resolved.clamp(1, available);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'app_progress_indicator.dart';
|
import 'app_progress_indicator.dart';
|
||||||
@@ -23,8 +25,28 @@ import 'app_progress_indicator.dart';
|
|||||||
/// wraps every tab screen in an animated `Transform.translate` and builds a tab
|
/// wraps every tab screen in an animated `Transform.translate` and builds a tab
|
||||||
/// lazily on first activation, which is how the ticker's in-place PDF pages hit
|
/// lazily on first activation, which is how the ticker's in-place PDF pages hit
|
||||||
/// the same window with no route animation in sight.
|
/// the same window with no route animation in sight.
|
||||||
|
///
|
||||||
|
/// Neither covers a subtree that is *moved* (GlobalKey) under a fresh
|
||||||
|
/// transform while already settled — Android's predictive back does exactly
|
||||||
|
/// that: on gesture start it swaps the route's transition widgets, and the
|
||||||
|
/// route content (`_subtreeKey`) lands under a brand-new `Transform.scale`
|
||||||
|
/// with no animation status change. Flutter rebuilds every stateful widget in
|
||||||
|
/// a moved subtree, so the gate re-checks its ancestors on each build and
|
||||||
|
/// re-gates while any of them has not been laid out yet.
|
||||||
|
///
|
||||||
|
/// The one thing the gate must *not* do is unmount the subtree while a pointer
|
||||||
|
/// is down on it: pointer events keep going to the hit-test path captured at
|
||||||
|
/// pointer-down, and `SfPdfViewer` dereferences its `context` in the handler
|
||||||
|
/// after dispose. iOS' back swipe hits both the edge detector and the page, so
|
||||||
|
/// the pop starts with the finger still on the viewer — the subtree is held
|
||||||
|
/// until the pointer lifts. An unlaid-out ancestor still wins, since that
|
||||||
|
/// crash is certain in the very same frame.
|
||||||
class RouteTransitionGate extends StatefulWidget {
|
class RouteTransitionGate extends StatefulWidget {
|
||||||
const RouteTransitionGate({super.key, required this.builder, this.placeholder});
|
const RouteTransitionGate({
|
||||||
|
super.key,
|
||||||
|
required this.builder,
|
||||||
|
this.placeholder,
|
||||||
|
});
|
||||||
|
|
||||||
final WidgetBuilder builder;
|
final WidgetBuilder builder;
|
||||||
|
|
||||||
@@ -45,6 +67,9 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
|||||||
bool _settled = false;
|
bool _settled = false;
|
||||||
bool _settleScheduled = false;
|
bool _settleScheduled = false;
|
||||||
|
|
||||||
|
int _activePointers = 0;
|
||||||
|
bool _childMounted = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -102,11 +127,58 @@ class _RouteTransitionGateState extends State<RouteTransitionGate> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
void deactivate() {
|
||||||
if (!_transitioning) {
|
// About to be moved (or disposed); whatever we end up under must earn
|
||||||
if (_settled) return widget.builder(context);
|
// the settled state again.
|
||||||
_scheduleSettle();
|
_settled = false;
|
||||||
|
super.deactivate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _hasUnlaidOutAncestor(BuildContext context) {
|
||||||
|
var found = false;
|
||||||
|
context.visitAncestorElements((element) {
|
||||||
|
final renderObject = element is RenderObjectElement
|
||||||
|
? element.renderObject
|
||||||
|
: null;
|
||||||
|
if (renderObject is RenderBox && !renderObject.hasSize) {
|
||||||
|
found = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerUp(PointerEvent _) {
|
||||||
|
_activePointers = math.max(0, _activePointers - 1);
|
||||||
|
if (_activePointers > 0 || !mounted) return;
|
||||||
|
// Held through a transition that has since finished: the subtree is
|
||||||
|
// mounted and laid out, nothing to re-earn. Otherwise let go now.
|
||||||
|
if (_transitioning) {
|
||||||
|
setState(() {});
|
||||||
|
} else {
|
||||||
|
_settled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final blocked = _hasUnlaidOutAncestor(context);
|
||||||
|
if (blocked) _settled = false;
|
||||||
|
final hold = _childMounted && _activePointers > 0 && !blocked;
|
||||||
|
if (hold && !_transitioning) _settled = true;
|
||||||
|
if ((_settled && !_transitioning) || hold) {
|
||||||
|
_childMounted = true;
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: (_) => _activePointers++,
|
||||||
|
onPointerUp: _onPointerUp,
|
||||||
|
onPointerCancel: _onPointerUp,
|
||||||
|
child: widget.builder(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_childMounted = false;
|
||||||
|
_activePointers = 0;
|
||||||
|
if (!_transitioning) _scheduleSettle();
|
||||||
return widget.placeholder ??
|
return widget.placeholder ??
|
||||||
const Center(child: AppProgressIndicator.large());
|
const Center(child: AppProgressIndicator.large());
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 555 KiB |
|
After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
Before Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 596 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 604 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 404 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 544 KiB |
|
Before Width: | Height: | Size: 490 KiB |
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
|
|||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.6.0+66
|
version: 1.7.0+68
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.8.0 <4.0.0"
|
sdk: ">=3.8.0 <4.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import 'package:marianum_mobile/api/errors/parse_exception.dart';
|
|||||||
import 'package:marianum_mobile/api/errors/permission_exception.dart';
|
import 'package:marianum_mobile/api/errors/permission_exception.dart';
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
|
(String, bool) errorUserMessageAndRetry(Object error) =>
|
||||||
|
(errorToUserMessage(error), errorAllowsRetry(error));
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('errorToUserMessage', () {
|
group('errorToUserMessage', () {
|
||||||
test('null falls back to the default message', () {
|
test('null falls back to the default message', () {
|
||||||
@@ -165,6 +168,96 @@ void main() {
|
|||||||
expect(errorToUserMessage(ex), contains('Zu viele Anfragen'));
|
expect(errorToUserMessage(ex), contains('Zu viele Anfragen'));
|
||||||
expect(errorAllowsRetry(ex), isTrue);
|
expect(errorAllowsRetry(ex), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
String davBody(String exception, String message) =>
|
||||||
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||||
|
'<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">\n'
|
||||||
|
' <s:exception>Sabre\\DAV\\Exception\\$exception</s:exception>\n'
|
||||||
|
' <s:message>$message</s:message>\n'
|
||||||
|
'</d:error>';
|
||||||
|
|
||||||
|
test('parseDavError extracts class and message', () {
|
||||||
|
final dav = parseDavError(
|
||||||
|
davBody(
|
||||||
|
'MethodNotAllowed',
|
||||||
|
'The resource you tried to create already exists',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(dav?.exception, 'MethodNotAllowed');
|
||||||
|
expect(dav?.message, 'The resource you tried to create already exists');
|
||||||
|
expect(parseDavError('<html>nope</html>'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MKCOL 405 on an existing folder says so and allows no retry', () {
|
||||||
|
final ex = DynamiteApiException(
|
||||||
|
405,
|
||||||
|
const {},
|
||||||
|
davBody(
|
||||||
|
'MethodNotAllowed',
|
||||||
|
'The resource you tried to create already exists',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(errorToUserMessage(ex), contains('existiert bereits'));
|
||||||
|
expect(
|
||||||
|
errorToTechnicalDetails(ex),
|
||||||
|
'HTTP 405 MethodNotAllowed: The resource you tried to create already exists',
|
||||||
|
);
|
||||||
|
expect(errorAllowsRetry(ex), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other DAV exceptions map to their own messages', () {
|
||||||
|
expect(
|
||||||
|
errorToUserMessage(
|
||||||
|
DynamiteApiException(
|
||||||
|
412,
|
||||||
|
const {},
|
||||||
|
davBody('PreconditionFailed', 'x'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
contains('Zielort'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
errorToUserMessage(
|
||||||
|
DynamiteApiException(409, const {}, davBody('Conflict', 'x')),
|
||||||
|
),
|
||||||
|
contains('übergeordnete Ordner'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
errorUserMessageAndRetry(
|
||||||
|
DynamiteApiException(423, const {}, davBody('Locked', 'x')),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Der Eintrag wird gerade bearbeitet. Bitte versuche es gleich erneut.',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
errorToUserMessage(
|
||||||
|
DynamiteApiException(
|
||||||
|
507,
|
||||||
|
const {},
|
||||||
|
davBody('InsufficientStorage', 'x'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
contains('Speicherplatz'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'unknown DAV exceptions keep the status mapping but name the class',
|
||||||
|
() {
|
||||||
|
final ex = DynamiteApiException(
|
||||||
|
404,
|
||||||
|
const {},
|
||||||
|
davBody('NotFound', 'File not found'),
|
||||||
|
);
|
||||||
|
expect(errorToUserMessage(ex), const NotFoundException().userMessage);
|
||||||
|
expect(
|
||||||
|
errorToTechnicalDetails(ex),
|
||||||
|
'HTTP 404 NotFound: File not found',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('errorToTechnicalDetails', () {
|
group('errorToTechnicalDetails', () {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_school
|
|||||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
|
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
|
||||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
|
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
|
||||||
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
|
||||||
|
import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
|
||||||
|
|
||||||
/// The demo interceptor answers MarianumConnect calls with these bodies; each
|
/// The demo interceptor answers MarianumConnect calls with these bodies; each
|
||||||
/// must round-trip through the same parser the real query uses. A shape
|
/// must round-trip through the same parser the real query uses. A shape
|
||||||
@@ -57,6 +58,17 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Eigener Case nötig: sonst greift der timetable/<type>/<id>-Fallback und
|
||||||
|
// liefert eine Woche statt `{events: [...]}` (iOS-Widget-Refresh crashte).
|
||||||
|
test('timetable/custom-events', () {
|
||||||
|
expect(
|
||||||
|
() => GetCustomTimetableEventResponse.fromJson(
|
||||||
|
asMap('timetable/custom-events'),
|
||||||
|
),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('holidays → non-empty list of McHoliday', () {
|
test('holidays → non-empty list of McHoliday', () {
|
||||||
final holidays = asList('holidays').map(McHoliday.fromJson).toList();
|
final holidays = asList('holidays').map(McHoliday.fromJson).toList();
|
||||||
expect(holidays, isNotEmpty);
|
expect(holidays, isNotEmpty);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/storage/keychain_write.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('isKeychainDuplicateItem', () {
|
||||||
|
test('recognises the OSStatus in the details', () {
|
||||||
|
final e = PlatformException(
|
||||||
|
code: 'Unexpected security result code',
|
||||||
|
message:
|
||||||
|
'Code: -25299, Message: The specified item already exists in the keychain.',
|
||||||
|
details: -25299,
|
||||||
|
);
|
||||||
|
expect(isKeychainDuplicateItem(e), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the message when details are missing', () {
|
||||||
|
final e = PlatformException(
|
||||||
|
code: 'Unexpected security result code',
|
||||||
|
message: 'Code: -25299, Message: duplicate',
|
||||||
|
);
|
||||||
|
expect(isKeychainDuplicateItem(e), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores other keychain errors and non-platform errors', () {
|
||||||
|
final e = PlatformException(
|
||||||
|
code: 'Unexpected security result code',
|
||||||
|
message: 'Code: -25308, Message: interaction not allowed',
|
||||||
|
details: -25308,
|
||||||
|
);
|
||||||
|
expect(isKeychainDuplicateItem(e), isFalse);
|
||||||
|
expect(isKeychainDuplicateItem(StateError('x')), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/api/marianumcloud/search/search_chat_messages_response.dart';
|
||||||
import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart';
|
import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart';
|
import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
import 'package:marianum_mobile/view/pages/talk/data/chat_search_controller.dart';
|
import 'package:marianum_mobile/view/pages/talk/data/chat_message_matcher.dart';
|
||||||
|
|
||||||
GetChatResponseObject _msg({
|
GetChatResponseObject _msg({
|
||||||
required int id,
|
required int id,
|
||||||
@@ -12,8 +13,7 @@ GetChatResponseObject _msg({
|
|||||||
GetRoomResponseObjectMessageType type =
|
GetRoomResponseObjectMessageType type =
|
||||||
GetRoomResponseObjectMessageType.comment,
|
GetRoomResponseObjectMessageType.comment,
|
||||||
Map<String, RichObjectString>? params,
|
Map<String, RichObjectString>? params,
|
||||||
}) =>
|
}) => GetChatResponseObject(
|
||||||
GetChatResponseObject(
|
|
||||||
id,
|
id,
|
||||||
'token',
|
'token',
|
||||||
GetRoomResponseObjectMessageActorType.user,
|
GetRoomResponseObjectMessageActorType.user,
|
||||||
@@ -29,19 +29,19 @@ GetChatResponseObject _msg({
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
GetChatResponse _response(List<GetChatResponseObject> messages) =>
|
GetChatResponse _response(List<GetChatResponseObject> messages) =>
|
||||||
GetChatResponse(messages.toSet());
|
GetChatResponse(messages.toSet());
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('ChatSearchController.findMatches', () {
|
group('ChatMessageMatcher.findMatches', () {
|
||||||
test('empty query returns no matches', () {
|
test('empty query returns no matches', () {
|
||||||
final response = _response([
|
final response = _response([
|
||||||
_msg(id: 1, timestamp: 100, message: 'Hallo'),
|
_msg(id: 1, timestamp: 100, message: 'Hallo'),
|
||||||
]);
|
]);
|
||||||
expect(ChatSearchController.findMatches(response, ''), isEmpty);
|
expect(ChatMessageMatcher.findMatches(response, ''), isEmpty);
|
||||||
expect(ChatSearchController.findMatches(response, ' '), isEmpty);
|
expect(ChatMessageMatcher.findMatches(response, ' '), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('matches message text case-insensitively', () {
|
test('matches message text case-insensitively', () {
|
||||||
@@ -49,7 +49,7 @@ void main() {
|
|||||||
_msg(id: 1, timestamp: 100, message: 'Hallo Welt'),
|
_msg(id: 1, timestamp: 100, message: 'Hallo Welt'),
|
||||||
_msg(id: 2, timestamp: 200, message: 'nichts hier'),
|
_msg(id: 2, timestamp: 200, message: 'nichts hier'),
|
||||||
]);
|
]);
|
||||||
final matches = ChatSearchController.findMatches(response, 'WELT');
|
final matches = ChatMessageMatcher.findMatches(response, 'WELT');
|
||||||
expect(matches.length, 1);
|
expect(matches.length, 1);
|
||||||
expect(matches.first.messageId, 1);
|
expect(matches.first.messageId, 1);
|
||||||
});
|
});
|
||||||
@@ -69,7 +69,7 @@ void main() {
|
|||||||
message: 'auch was',
|
message: 'auch was',
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
final matches = ChatSearchController.findMatches(response, 'lisa');
|
final matches = ChatMessageMatcher.findMatches(response, 'lisa');
|
||||||
expect(matches.length, 1);
|
expect(matches.length, 1);
|
||||||
expect(matches.first.messageId, 1);
|
expect(matches.first.messageId, 1);
|
||||||
});
|
});
|
||||||
@@ -85,10 +85,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
// Match on text content
|
// Match on text content
|
||||||
expect(
|
expect(ChatMessageMatcher.findMatches(response, 'beigetreten').length, 1);
|
||||||
ChatSearchController.findMatches(response, 'beigetreten').length,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
// Actor name alone (not in text) should not match for system messages
|
// Actor name alone (not in text) should not match for system messages
|
||||||
final actorOnlyResponse = _response([
|
final actorOnlyResponse = _response([
|
||||||
_msg(
|
_msg(
|
||||||
@@ -100,7 +97,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
ChatSearchController.findMatches(actorOnlyResponse, 'lisa'),
|
ChatMessageMatcher.findMatches(actorOnlyResponse, 'lisa'),
|
||||||
isEmpty,
|
isEmpty,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -132,7 +129,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
_msg(id: 3, timestamp: 300, message: 'Treffer'),
|
_msg(id: 3, timestamp: 300, message: 'Treffer'),
|
||||||
]);
|
]);
|
||||||
final matches = ChatSearchController.findMatches(response, 'Treffer');
|
final matches = ChatMessageMatcher.findMatches(response, 'Treffer');
|
||||||
expect(matches.length, 1);
|
expect(matches.length, 1);
|
||||||
expect(matches.first.messageId, 3);
|
expect(matches.first.messageId, 3);
|
||||||
},
|
},
|
||||||
@@ -155,7 +152,7 @@ void main() {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
final matches = ChatSearchController.findMatches(response, 'hausaufgab');
|
final matches = ChatMessageMatcher.findMatches(response, 'hausaufgab');
|
||||||
expect(matches.length, 1);
|
expect(matches.length, 1);
|
||||||
expect(matches.first.messageId, 1);
|
expect(matches.first.messageId, 1);
|
||||||
});
|
});
|
||||||
@@ -166,8 +163,65 @@ void main() {
|
|||||||
_msg(id: 2, timestamp: 300, message: 'X'),
|
_msg(id: 2, timestamp: 300, message: 'X'),
|
||||||
_msg(id: 3, timestamp: 200, message: 'X'),
|
_msg(id: 3, timestamp: 200, message: 'X'),
|
||||||
]);
|
]);
|
||||||
final matches = ChatSearchController.findMatches(response, 'x');
|
final matches = ChatMessageMatcher.findMatches(response, 'x');
|
||||||
expect(matches.map((m) => m.messageId).toList(), [2, 3, 1]);
|
expect(matches.map((m) => m.messageId).toList(), [2, 3, 1]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ChatMessageMatcher.merge', () {
|
||||||
|
test('dedupes by id preferring local hits, newest id first', () {
|
||||||
|
const local = [ChatSearchHit(messageId: 5, text: 'lokal')];
|
||||||
|
const server = [
|
||||||
|
ChatSearchHit(messageId: 9, text: 'neu'),
|
||||||
|
ChatSearchHit(messageId: 5, text: 'server'),
|
||||||
|
ChatSearchHit(messageId: 2, text: 'alt'),
|
||||||
|
];
|
||||||
|
final merged = ChatMessageMatcher.merge(local, server);
|
||||||
|
expect(merged.map((h) => h.messageId).toList(), [9, 5, 2]);
|
||||||
|
expect(merged[1].text, 'lokal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SearchChatMessagesEntry.fromJson', () {
|
||||||
|
test('reads id and timestamp from attributes', () {
|
||||||
|
final entry = SearchChatMessagesEntry.fromJson({
|
||||||
|
'title': 'Lisa Maier',
|
||||||
|
'subline': 'Die Hausaufgabe ist bis Freitag',
|
||||||
|
'attributes': {
|
||||||
|
'messageId': '123',
|
||||||
|
'actorId': 'lmaier',
|
||||||
|
'timestamp': '1700000000',
|
||||||
|
},
|
||||||
|
}, term: 'hausaufgabe')!;
|
||||||
|
expect(entry.messageId, 123);
|
||||||
|
expect(entry.actorId, 'lmaier');
|
||||||
|
expect(entry.actorDisplayName, 'Lisa Maier');
|
||||||
|
expect(entry.excerpt, 'Die Hausaufgabe ist bis Freitag');
|
||||||
|
expect(entry.sentAt, DateTime.fromMillisecondsSinceEpoch(1700000000000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects excerpt in title and falls back to resourceUrl id', () {
|
||||||
|
final entry = SearchChatMessagesEntry.fromJson({
|
||||||
|
'title': '…die Hausaufgabe',
|
||||||
|
'subline': 'Lisa Maier',
|
||||||
|
'resourceUrl': 'https://cloud/index.php/call/abc#message_77',
|
||||||
|
}, term: 'hausaufgabe')!;
|
||||||
|
expect(entry.messageId, 77);
|
||||||
|
expect(entry.excerpt, '…die Hausaufgabe');
|
||||||
|
expect(entry.actorDisplayName, 'Lisa Maier');
|
||||||
|
expect(entry.sentAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entries without message id are dropped', () {
|
||||||
|
final response = SearchChatMessagesResponse.fromJson({
|
||||||
|
'isPaginated': true,
|
||||||
|
'cursor': 25,
|
||||||
|
'entries': [
|
||||||
|
{'title': 'x', 'subline': 'y'},
|
||||||
|
],
|
||||||
|
}, term: 'y');
|
||||||
|
expect(response.entries, isEmpty);
|
||||||
|
expect(response.cursor, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/share_intent/remote_file_ref.dart';
|
||||||
|
import 'package:marianum_mobile/widget/media_gallery/gallery_item.dart';
|
||||||
|
|
||||||
|
GalleryItem _chat(int id, {String name = 'a.jpg'}) => GalleryItem(
|
||||||
|
file: RemoteFileRef(path: 'Talk/$name', name: name),
|
||||||
|
kind: GalleryMediaKind.image,
|
||||||
|
messageId: id,
|
||||||
|
);
|
||||||
|
|
||||||
|
GalleryItem _file(String name) => GalleryItem(
|
||||||
|
file: RemoteFileRef(path: '/Fotos/$name', name: name),
|
||||||
|
kind: GalleryMediaKind.image,
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('galleryMediaKindOf', () {
|
||||||
|
test('classifies images and videos case-insensitively', () {
|
||||||
|
expect(galleryMediaKindOf('Foto.JPG'), GalleryMediaKind.image);
|
||||||
|
expect(galleryMediaKindOf('scan.heic'), GalleryMediaKind.image);
|
||||||
|
expect(galleryMediaKindOf('clip.mov'), GalleryMediaKind.video);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers the MIME type over the extension', () {
|
||||||
|
expect(
|
||||||
|
galleryMediaKindOf('IMG_0042', mimeType: 'image/jpeg'),
|
||||||
|
GalleryMediaKind.image,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
galleryMediaKindOf('aufnahme.bin', mimeType: 'video/mp4'),
|
||||||
|
GalleryMediaKind.video,
|
||||||
|
);
|
||||||
|
expect(galleryMediaKindOf('scan.jfif'), GalleryMediaKind.image);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips image types without a usable preview', () {
|
||||||
|
expect(
|
||||||
|
galleryMediaKindOf(
|
||||||
|
'entwurf.psd',
|
||||||
|
mimeType: 'image/vnd.adobe.photoshop',
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores everything else', () {
|
||||||
|
expect(galleryMediaKindOf('Arbeitsblatt.pdf'), isNull);
|
||||||
|
expect(galleryMediaKindOf('logo.svg'), isNull);
|
||||||
|
expect(galleryMediaKindOf('scan.tiff', mimeType: 'image/tiff'), isNull);
|
||||||
|
expect(galleryMediaKindOf('README'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('mergeGalleryItems', () {
|
||||||
|
test('sorts chat media chronologically and drops duplicates', () {
|
||||||
|
final merged = mergeGalleryItems(
|
||||||
|
[_chat(50)],
|
||||||
|
[_chat(70), _chat(50), _chat(30)],
|
||||||
|
);
|
||||||
|
expect(merged.map((i) => i.messageId), [30, 50, 70]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the existing instance for a known key', () {
|
||||||
|
final initial = _chat(50);
|
||||||
|
final merged = mergeGalleryItems([initial], [_chat(50, name: 'b.jpg')]);
|
||||||
|
expect(merged.single, same(initial));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps folder order untouched', () {
|
||||||
|
final merged = mergeGalleryItems(
|
||||||
|
[_file('c.jpg'), _file('a.jpg')],
|
||||||
|
[_file('b.jpg')],
|
||||||
|
);
|
||||||
|
expect(merged.map((i) => i.file.name), ['c.jpg', 'a.jpg', 'b.jpg']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GIFs always need the original', () {
|
||||||
|
expect(_chat(1, name: 'lol.GIF').needsOriginal, isTrue);
|
||||||
|
expect(_chat(1).needsOriginal, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/widget/prosemirror/pm_image_view.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('resolvePmImageWidth', () {
|
||||||
|
test('returns null without a width attribute', () {
|
||||||
|
expect(resolvePmImageWidth(null, 300), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for unparseable widths', () {
|
||||||
|
expect(resolvePmImageWidth('auto', 300), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves px, %, em and rem', () {
|
||||||
|
expect(resolvePmImageWidth('120px', 300), 120);
|
||||||
|
expect(resolvePmImageWidth('50%', 300), 150);
|
||||||
|
expect(resolvePmImageWidth('2em', 300), 32);
|
||||||
|
expect(resolvePmImageWidth('2rem', 300), 32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps at the available width and floors at one pixel', () {
|
||||||
|
expect(resolvePmImageWidth('900px', 300), 300);
|
||||||
|
expect(resolvePmImageWidth('0px', 300), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives zero or sub-pixel available width', () {
|
||||||
|
expect(resolvePmImageWidth('50%', 0), isNull);
|
||||||
|
expect(resolvePmImageWidth('120px', 0.5), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,7 +11,12 @@ void main() {
|
|||||||
Widget gatedPage() => Scaffold(
|
Widget gatedPage() => Scaffold(
|
||||||
body: RouteTransitionGate(
|
body: RouteTransitionGate(
|
||||||
placeholder: const SizedBox(key: placeholder),
|
placeholder: const SizedBox(key: placeholder),
|
||||||
builder: (_) => const SizedBox(key: gated),
|
// Hit-testable like the real viewer, so pointer tracking sees it.
|
||||||
|
builder: (_) => const ColoredBox(
|
||||||
|
key: gated,
|
||||||
|
color: Color(0x00000000),
|
||||||
|
child: SizedBox.expand(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -29,7 +34,9 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
unawaited(
|
unawaited(
|
||||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 50));
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
@@ -45,7 +52,9 @@ void main() {
|
|||||||
testWidgets('swaps back to placeholder while the route pops', (tester) async {
|
testWidgets('swaps back to placeholder while the route pops', (tester) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
unawaited(
|
unawaited(
|
||||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(gated), findsOneWidget);
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
@@ -63,13 +72,17 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
await pumpApp(tester);
|
await pumpApp(tester);
|
||||||
unawaited(
|
unawaited(
|
||||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(gated), findsOneWidget);
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
navigator(tester).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => const Scaffold())),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(const Duration(milliseconds: 50));
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
@@ -84,6 +97,86 @@ void main() {
|
|||||||
expect(find.byKey(placeholder), findsNothing);
|
expect(find.byKey(placeholder), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('re-gates when the settled subtree moves under a fresh Transform', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Android's predictive back moves the route content (GlobalKey) under a
|
||||||
|
// brand-new Transform.scale without any animation status change. The
|
||||||
|
// padding changes the constraints so the moved subtree is laid out nested
|
||||||
|
// under the transform's first layout, which is when `localToGlobal` throws.
|
||||||
|
final subtree = GlobalKey();
|
||||||
|
var wrapped = false;
|
||||||
|
late StateSetter rebuild;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
rebuild = setState;
|
||||||
|
final child = KeyedSubtree(
|
||||||
|
key: subtree,
|
||||||
|
child: Scaffold(
|
||||||
|
body: RouteTransitionGate(
|
||||||
|
placeholder: const SizedBox(key: placeholder),
|
||||||
|
builder: (_) => const _LocalToGlobalProbe(key: gated),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!wrapped) return child;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(1),
|
||||||
|
child: Transform.scale(scale: 1, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
|
|
||||||
|
rebuild(() => wrapped = true);
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.byKey(placeholder), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('keeps the child mounted while a back swipe drives the pop', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// iOS back swipe: the edge detector is translucent, so the finger that
|
||||||
|
// drives the pop animation is also down on the viewer. Unmounting it now
|
||||||
|
// would route the next pointer move into a disposed State. (A programmatic
|
||||||
|
// pop is harmless — the Navigator cancels active pointers first.)
|
||||||
|
await pumpApp(tester);
|
||||||
|
unawaited(
|
||||||
|
navigator(
|
||||||
|
tester,
|
||||||
|
).push(MaterialPageRoute<void>(builder: (_) => gatedPage())),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
|
|
||||||
|
final gesture = await tester.startGesture(const Offset(5, 300));
|
||||||
|
await gesture.moveBy(const Offset(60, 0));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.moveBy(const Offset(60, 0));
|
||||||
|
await tester.pump();
|
||||||
|
// Pop animation is running (status reverse), finger still down.
|
||||||
|
expect(find.byKey(gated), findsOneWidget);
|
||||||
|
expect(find.byKey(placeholder), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byKey(gated), findsNothing);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
|
||||||
|
|
||||||
testWidgets('holds the child back for one frame without a route animation', (
|
testWidgets('holds the child back for one frame without a route animation', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
@@ -99,3 +192,25 @@ void main() {
|
|||||||
expect(find.byKey(placeholder), findsNothing);
|
expect(find.byKey(placeholder), findsNothing);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mimics SfPdfViewer: a stateful widget whose LayoutBuilder resolves a
|
||||||
|
/// child's global position during layout.
|
||||||
|
class _LocalToGlobalProbe extends StatefulWidget {
|
||||||
|
const _LocalToGlobalProbe({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_LocalToGlobalProbe> createState() => _LocalToGlobalProbeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LocalToGlobalProbeState extends State<_LocalToGlobalProbe> {
|
||||||
|
final _viewport = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final box = _viewport.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (box != null && box.hasSize) box.localToGlobal(Offset.zero);
|
||||||
|
return SizedBox.expand(key: _viewport);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# App-Store-Screenshots über den Demo-Login aufnehmen – iOS-Gegenstück zu
|
||||||
|
# tool/screenshots.sh.
|
||||||
|
#
|
||||||
|
# Fährt denselben integration_test-Lauf (integration_test/screenshot_test.dart)
|
||||||
|
# per flutter drive auf je einem iOS-Simulator pro Formfaktor: iPhone 6,5″
|
||||||
|
# (iPhone 14 Plus, 1284×2778) und iPad 13″ (iPad Pro 13-inch, 2064×2752). Die
|
||||||
|
# Simulatoren liefern die Frames direkt in der Zielauflösung, eine
|
||||||
|
# Größenumstellung wie per `adb shell wm size` ist nicht nötig. Fehlt ein
|
||||||
|
# Simulator, wird er mit gleichnamigem Gerätetyp angelegt.
|
||||||
|
#
|
||||||
|
# Ablage:
|
||||||
|
# - kanonisch (versioniert): materials/screenshots/{ios/6-5-inch,ipados/13-inch}/
|
||||||
|
# - Kopie für den Upload: ios/fastlane/screenshots/de-DE/<profil>_<name>.jpg
|
||||||
|
# (deliver ordnet die Bilder anhand der Pixelgröße dem Gerät zu; das Präfix
|
||||||
|
# verhindert nur Namenskollisionen zwischen iPhone und iPad.)
|
||||||
|
# Danach: `cd ios && fastlane upload_screenshots`.
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - Xcode mit iOS-Simulator-Runtime; Simulatoren (Namen per IPHONE_SIM/IPAD_SIM
|
||||||
|
# überschreibbar; Name = Gerätetyp aus `xcrun simctl list devicetypes`)
|
||||||
|
# - flutter im PATH
|
||||||
|
#
|
||||||
|
# Achtung: Pro Simulator werden App und Schlüsselbund zurückgesetzt, damit der
|
||||||
|
# Lauf garantiert mit dem Demo-Login startet und keine echte Sitzung zeigt.
|
||||||
|
# Deshalb eigene Simulatoren verwenden, nicht den für die Entwicklung.
|
||||||
|
#
|
||||||
|
# Aufruf (vom Client-Projektwurzelverzeichnis):
|
||||||
|
# tool/screenshots_ios.sh # debug-Build
|
||||||
|
# BUILD_MODE=profile tool/screenshots_ios.sh
|
||||||
|
# PROFILES="iphone" tool/screenshots_ios.sh # nur eine Größe
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
BUILD_MODE="${BUILD_MODE:-debug}"
|
||||||
|
FASTLANE_DIR="ios/fastlane/screenshots/de-DE"
|
||||||
|
BUNDLE_ID="eu.mhsl.marianum.mobile.client"
|
||||||
|
IPHONE_SIM="${IPHONE_SIM:-iPhone 14 Plus}"
|
||||||
|
IPAD_SIM="${IPAD_SIM:-iPad Pro 13-inch (M5)}"
|
||||||
|
PROFILES="${PROFILES:-iphone ipad}"
|
||||||
|
|
||||||
|
# UDID des ersten verfügbaren Simulators mit exakt diesem Namen.
|
||||||
|
sim_udid() {
|
||||||
|
xcrun simctl list devices available -j |
|
||||||
|
/usr/bin/python3 -c '
|
||||||
|
import json, sys
|
||||||
|
name = sys.argv[1]
|
||||||
|
for devices in json.load(sys.stdin)["devices"].values():
|
||||||
|
for d in devices:
|
||||||
|
if d["name"] == name:
|
||||||
|
print(d["udid"]); sys.exit(0)
|
||||||
|
sys.exit(1)' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOTED_BY_US=()
|
||||||
|
cleanup() {
|
||||||
|
for udid in ${BOOTED_BY_US[@]+"${BOOTED_BY_US[@]}"}; do
|
||||||
|
xcrun simctl status_bar "$udid" clear >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p "$FASTLANE_DIR"
|
||||||
|
|
||||||
|
for profile in $PROFILES; do
|
||||||
|
case "$profile" in
|
||||||
|
iphone) sim="$IPHONE_SIM"; materials_dir="materials/screenshots/ios/6-5-inch" ;;
|
||||||
|
ipad) sim="$IPAD_SIM"; materials_dir="materials/screenshots/ipados/13-inch" ;;
|
||||||
|
*) echo "Unbekanntes Profil '$profile' (erlaubt: iphone ipad)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
udid="$(sim_udid "$sim")" || {
|
||||||
|
echo "Simulator '$sim' fehlt, lege ihn an…"
|
||||||
|
udid="$(xcrun simctl create "$sim" "$sim")"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Profil '$profile' ($sim, $udid) -> $materials_dir ==="
|
||||||
|
mkdir -p "$materials_dir"
|
||||||
|
rm -f "$materials_dir"/*.png "$FASTLANE_DIR/${profile}_"*.jpg
|
||||||
|
|
||||||
|
if ! xcrun simctl list devices | grep -q "$udid) (Booted)"; then
|
||||||
|
xcrun simctl boot "$udid"
|
||||||
|
BOOTED_BY_US+=("$udid")
|
||||||
|
fi
|
||||||
|
xcrun simctl bootstatus "$udid" -b >/dev/null
|
||||||
|
|
||||||
|
# Apple-typische Statusleiste: 9:41, volles WLAN/Netz, Akku voll.
|
||||||
|
xcrun simctl status_bar "$udid" override \
|
||||||
|
--time "9:41" --dataNetwork wifi --wifiMode active --wifiBars 3 \
|
||||||
|
--cellularMode active --cellularBars 4 --operatorName "" \
|
||||||
|
--batteryState charged --batteryLevel 100
|
||||||
|
|
||||||
|
# Frische, ausgeloggte Sitzung: App löschen und Schlüsselbund leeren
|
||||||
|
# (flutter_secure_storage überlebt auf iOS eine Deinstallation).
|
||||||
|
xcrun simctl uninstall "$udid" "$BUNDLE_ID" >/dev/null 2>&1 || true
|
||||||
|
xcrun simctl keychain "$udid" reset >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
SCREENSHOT_OUT_DIR="$materials_dir" flutter drive \
|
||||||
|
-d "$udid" \
|
||||||
|
--driver=test_driver/integration_test.dart \
|
||||||
|
--target=integration_test/screenshot_test.dart \
|
||||||
|
--"$BUILD_MODE" \
|
||||||
|
--no-dds
|
||||||
|
|
||||||
|
# Kanonische Bilder mit Profil-Präfix in den Fastlane-Upload-Ordner spiegeln.
|
||||||
|
# Als JPEG, weil App Store Connect Screenshots mit Alpha-Kanal ablehnt und
|
||||||
|
# takeScreenshot RGBA-PNGs liefert.
|
||||||
|
for f in "$materials_dir"/*.png; do
|
||||||
|
sips -s format jpeg -s formatOptions best "$f" \
|
||||||
|
--out "$FASTLANE_DIR/${profile}_$(basename "${f%.png}").jpg" >/dev/null
|
||||||
|
done
|
||||||
|
echo "Profil '$profile' fertig: $(ls -1 "$materials_dir"/*.png 2>/dev/null | wc -l | tr -d ' ') Screenshots"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Kanonische Screenshots: materials/screenshots/{ios/6-5-inch,ipados/13-inch}/"
|
||||||
|
echo "Upload: cd ios && fastlane upload_screenshots"
|
||||||