18 Commits

Author SHA1 Message Date
MineTec 8c76f2d816 removed now indicator from android and ios widgets 2026-05-11 13:55:16 +02:00
MineTec c46f14f6a6 updated kotlin gradle plugin version to 2.2.20 2026-05-10 20:27:50 +02:00
MineTec b2b00d321e Merge pull request 'implemented chat long-polling and optimistic updates, centralized notification management, optimized avatar caching, cleanup and bugfixes' (#99) from develop-notifications into develop
Reviewed-on: #99
2026-05-10 15:02:12 +00:00
MineTec 1a11b9ac60 refactored internal documentation and simplified comments across chat BLoCs, file viewer, and navigation components 2026-05-10 17:01:50 +02:00
MineTec a0bc46f522 optimized avatar and linkify performance, refined navigation to preserve popups, implemented read marker caching, and added file size limits for saving, minor timetable details changes 2026-05-10 16:40:39 +02:00
MineTec 1458d8ce49 implemented chat long-polling and optimistic updates, centralized notification management, optimized avatar caching 2026-05-10 15:47:55 +02:00
MineTec 6ae396e605 Merge pull request 'general search, talk enhancements, overhauled fileviewer' (#98) from develop-search into develop
Reviewed-on: #98
2026-05-09 22:55:11 +00:00
MineTec ed2badfd35 fixed chat bubble link styling and gesture handling, and added android package visibility for common schemes 2026-05-10 00:54:13 +02:00
MineTec 1ff57b29f9 overhauled file viewer with video, audio, text, and SVG support, added media player and line-numbered text views, and fixed search controller recursion 2026-05-10 00:33:09 +02:00
MineTec c50a850ac9 reordered files app bar actions by moving search icon 2026-05-09 23:43:29 +02:00
MineTec 15833f3685 implemented disposal guard in files search controller to safely handle async listener notifications 2026-05-09 23:40:04 +02:00
MineTec bf28a678c9 implemented background prefetching for files root, added 24-hour caching for root directory listing, and enabled cache renewal for manual refreshes 2026-05-09 23:39:06 +02:00
MineTec 14090b96f4 implemented file search with local cache and server-side support, added result highlighting, and integrated search delegate into files page 2026-05-09 23:20:11 +02:00
MineTec 8e6b1877cc implemented search for marianum messages with name and date filtering 2026-05-09 22:35:20 +02:00
MineTec 9accb488f2 added delete confirmation dialog for chat messages and refined deletion logic flow 2026-05-09 22:32:45 +02:00
MineTec 79a6d9a594 filtered deleted messages from search and chat view, refactored chat bubble styling for deleted comments, and updated tests 2026-05-09 22:28:26 +02:00
MineTec 7d02e70459 implemented short relative date formatting for chat and added unit tests 2026-05-09 22:23:25 +02:00
MineTec 4c190de479 implemented in-chat search with text highlighting, added search navigation UI, and integrated scrollable list for message jumping 2026-05-09 22:21:36 +02:00
53 changed files with 3365 additions and 547 deletions
+23 -5
View File
@@ -73,16 +73,34 @@
android:resource="@xml/timetable_week_widget_info" /> android:resource="@xml/timetable_week_widget_info" />
</receiver> </receiver>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required so url_launcher / can_launch can actually see browsers,
https://developer.android.com/training/package-visibility?hl=en and mail clients and dialers under Android 11+ package-visibility rules
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. (otherwise UrlLauncher logs "component name for ... is null" and
link taps in Talk silently do nothing). The PROCESS_TEXT intent is
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> needed by io.flutter.plugin.text.ProcessTextPlugin (selection
menu).
See https://developer.android.com/training/package-visibility -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="http"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="mailto"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="tel"/>
</intent>
</queries> </queries>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device <!-- Workmanager periodic widget refresh needs to reschedule after device
@@ -167,14 +167,6 @@ object WidgetRenderer {
horizontalPaddingDp = 7, horizontalPaddingDp = 7,
) )
} }
maybeAddNowIndicator(
packageName,
views,
R.id.widget_day_grid,
hourHeightDp,
anchorDate = data.anchorDate,
periods = data.periods,
)
} }
views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
@@ -283,16 +275,6 @@ object WidgetRenderer {
horizontalPaddingDp = 3, horizontalPaddingDp = 3,
) )
} }
if (WidgetDateUtils.isSameDay(day, Date())) {
maybeAddNowIndicator(
packageName,
views,
columnId,
hourHeightDp,
anchorDate = day,
periods = data.periods,
)
}
} }
views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
@@ -644,34 +626,6 @@ object WidgetRenderer {
} }
} }
private fun maybeAddNowIndicator(
packageName: String,
parent: RemoteViews,
containerId: Int,
hourHeightDp: Float,
anchorDate: Date,
periods: List<WidgetPeriod>,
) {
if (!WidgetDateUtils.isSameDay(anchorDate, Date())) return
val now = Calendar.getInstance()
val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
if (periods.isNotEmpty()) {
if (nowMinutes < periods.first().startMinutes ||
nowMinutes > periods.last().endMinutes
) return
}
val virtualNow = realMinutesToVirtual(nowMinutes, periods)
val topDp = virtualNow * hourHeightDp / 60.0f
val indicator = RemoteViews(packageName, R.layout.widget_now_indicator)
indicator.setViewLayoutMargin(
R.id.widget_now_indicator_root,
RemoteViews.MARGIN_TOP,
topDp,
TypedValue.COMPLEX_UNIT_DIP,
)
parent.addView(containerId, indicator)
}
/// Custom-events use the user-picked palette (orange/red/green/blue, /// Custom-events use the user-picked palette (orange/red/green/blue,
/// mirroring CustomTimetableColors). /// mirroring CustomTimetableColors).
private fun statusDrawable(lesson: WidgetLesson): Int { private fun statusDrawable(lesson: WidgetLesson): Int {
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFE53935" />
</shape>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_now_indicator_root"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="0dp"
android:background="@drawable/widget_now_indicator" />
+1 -1
View File
@@ -21,7 +21,7 @@ plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.13.2' apply false id "com.android.application" version '8.13.2' apply false
id "com.android.library" version '8.13.2' apply false id "com.android.library" version '8.13.2' apply false
id "org.jetbrains.kotlin.android" version "2.1.10" apply false id "org.jetbrains.kotlin.android" version "2.2.20" apply false
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0' id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
} }
@@ -82,7 +82,6 @@ struct TimetableDayView: View {
TimeGridView( TimeGridView(
lessons: data.lessons, lessons: data.lessons,
periods: data.periods, periods: data.periods,
anchorDate: data.anchorDate,
hourHeight: max( hourHeight: max(
MIN_HOUR_HEIGHT, MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
@@ -135,7 +134,6 @@ struct TimetableDayView: View {
struct TimeGridView: View { struct TimeGridView: View {
let lessons: [WidgetLesson] let lessons: [WidgetLesson]
let periods: [WidgetPeriod] let periods: [WidgetPeriod]
let anchorDate: Date
let hourHeight: CGFloat let hourHeight: CGFloat
let showRoom: Bool let showRoom: Bool
let showTeacher: Bool let showTeacher: Bool
@@ -170,9 +168,6 @@ struct TimeGridView: View {
ForEach(lessons.indices, id: \.self) { idx in ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx]) lessonBlock(lessons[idx])
} }
if Calendar.current.isDate(anchorDate, inSameDayAs: Date()) {
nowIndicator
}
} }
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top) .frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
} }
@@ -344,27 +339,6 @@ struct TimeGridView: View {
} }
} }
private var nowIndicator: some View {
let cal = Calendar.current
let comps = cal.dateComponents([.hour, .minute], from: Date())
let nowMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
let inside: Bool
if let first = periods.first, let last = periods.last {
inside = nowMinutes >= first.startMinutes && nowMinutes <= last.endMinutes
} else {
inside = true
}
let top = realMinutesToVirtual(nowMinutes, periods: periods) * hourHeight / 60.0
return Group {
if inside {
Rectangle()
.fill(Color.red)
.frame(height: 2)
.offset(y: top)
}
}
}
private func subjectLabel(_ lesson: WidgetLesson) -> String { private func subjectLabel(_ lesson: WidgetLesson) -> String {
!lesson.subjectShort.isEmpty !lesson.subjectShort.isEmpty
? lesson.subjectShort ? lesson.subjectShort
@@ -131,7 +131,6 @@ struct TimetableWeekView: View {
return TimeGridView( return TimeGridView(
lessons: lessonsForDay, lessons: lessonsForDay,
periods: data.periods, periods: data.periods,
anchorDate: day,
hourHeight: hourHeight, hourHeight: hourHeight,
showRoom: !subjectOnly, showRoom: !subjectOnly,
showTeacher: !subjectOnly, showTeacher: !subjectOnly,
@@ -0,0 +1,36 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
import 'search_files_response.dart';
/// Wraps the Nextcloud OCS Search Provider API for the `files` provider.
/// Endpoint: `/ocs/v2.php/search/providers/files/search`.
class SearchFiles {
Future<SearchFilesResponse> run({
required String term,
int limit = 50,
int? cursor,
}) async {
final endpoint = NextcloudOcs.uri(
'search/providers/files/search',
queryParameters: {
'term': term,
'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(
'Files search failed with ${response.statusCode}: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final ocs = decoded['ocs'] as Map<String, dynamic>;
final data = ocs['data'] as Map<String, dynamic>;
return SearchFilesResponse.fromJson(data);
}
}
@@ -0,0 +1,91 @@
import 'package:json_annotation/json_annotation.dart';
import '../webdav/queries/list_files/cacheable_file.dart';
part 'search_files_response.g.dart';
/// Subset of the OCS Search Provider API response we actually consume.
/// The provider (`files`) returns one object per match plus pagination state.
@JsonSerializable(explicitToJson: true)
class SearchFilesResponse {
final String name;
final bool isPaginated;
final int? cursor;
final List<SearchFilesEntry> entries;
SearchFilesResponse({
required this.name,
required this.isPaginated,
required this.cursor,
required this.entries,
});
factory SearchFilesResponse.fromJson(Map<String, dynamic> json) =>
_$SearchFilesResponseFromJson(json);
Map<String, dynamic> toJson() => _$SearchFilesResponseToJson(this);
}
@JsonSerializable()
class SearchFilesEntry {
final String title;
final String? subline;
final String? icon;
final String? resourceUrl;
final Map<String, dynamic>? attributes;
SearchFilesEntry({
required this.title,
this.subline,
this.icon,
this.resourceUrl,
this.attributes,
});
factory SearchFilesEntry.fromJson(Map<String, dynamic> json) =>
_$SearchFilesEntryFromJson(json);
Map<String, dynamic> toJson() => _$SearchFilesEntryToJson(this);
/// Heuristic — the files provider sets icon classes containing "folder" for
/// directories. Falls back to false when missing or unrecognised.
bool get isDirectory => (icon ?? '').toLowerCase().contains('folder');
String? _stringAttribute(String key) {
final raw = attributes?[key];
return raw is String && raw.isNotEmpty ? raw : null;
}
String? _dirFromResourceUrl() {
final url = resourceUrl;
if (url == null) return null;
return Uri.tryParse(url)?.queryParameters['dir'];
}
/// Reconstructs the WebDAV-relative path used elsewhere (matching
/// [CacheableFile.path] — no leading slash, trailing slash for
/// directories). Prefers the explicit `path` attribute set by Nextcloud's
/// files search provider (28+); falls back to the `dir` query parameter
/// in [resourceUrl]. Returns `null` when neither is available — `subline`
/// is intentionally **not** parsed because it is localized UI text
/// ("in {folder}"), not a path, and using it produced bogus duplicate
/// folder headers like "/in Alte-Notebooks".
String? get webdavPath {
final attrPath = _stringAttribute('path');
if (attrPath != null) {
final stripped = attrPath.replaceAll(RegExp(r'^/+|/+$'), '');
return isDirectory ? '$stripped/' : stripped;
}
final dir = _dirFromResourceUrl();
if (dir != null) {
final stripped = dir.replaceAll(RegExp(r'^/+|/+$'), '');
final base = stripped.isEmpty ? title : '$stripped/$title';
return isDirectory ? '$base/' : base;
}
return null;
}
CacheableFile? toCacheable() {
final path = webdavPath;
if (path == null) return null;
return CacheableFile(path: path, isDirectory: isDirectory, name: title);
}
}
@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_files_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
SearchFilesResponse(
name: json['name'] as String,
isPaginated: json['isPaginated'] as bool,
cursor: (json['cursor'] as num?)?.toInt(),
entries: (json['entries'] as List<dynamic>)
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SearchFilesResponseToJson(
SearchFilesResponse instance,
) => <String, dynamic>{
'name': instance.name,
'isPaginated': instance.isPaginated,
'cursor': instance.cursor,
'entries': instance.entries.map((e) => e.toJson()).toList(),
};
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
SearchFilesEntry(
title: json['title'] as String,
subline: json['subline'] as String?,
icon: json['icon'] as String?,
resourceUrl: json['resourceUrl'] as String?,
attributes: json['attributes'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
<String, dynamic>{
'title': instance.title,
'subline': instance.subline,
'icon': instance.icon,
'resourceUrl': instance.resourceUrl,
'attributes': instance.attributes,
};
@@ -66,7 +66,7 @@ class GetChatResponseObject {
static GetChatResponseObject getDateDummy(int timestamp) { static GetChatResponseObject getDateDummy(int timestamp) {
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return getTextDummy(elementDate.formatDate()); return getTextDummy(elementDate.formatDateRelativeShort());
} }
static GetChatResponseObject getTextDummy(String text) => static GetChatResponseObject getTextDummy(String text) =>
@@ -0,0 +1,69 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and we need 304 to be a
/// normal "no new messages" outcome. `setReadMarker=on` lets the server
/// move the read cursor whenever the call returns messages.
class LongPollChat {
final String chatToken;
final int lastKnownMessageId;
final int timeoutSeconds;
LongPollChat({
required this.chatToken,
required this.lastKnownMessageId,
this.timeoutSeconds = 30,
});
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
limit: 100,
);
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15));
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
}
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
);
}
}
@@ -26,11 +26,5 @@ class SetReadMarker extends TalkApi {
Uri uri, Uri uri,
Object? body, Object? body,
Map<String, String>? headers, Map<String, String>? headers,
) { ) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers);
if (readState) {
return http.post(uri, headers: headers);
} else {
return http.delete(uri, headers: headers);
}
}
} }
@@ -16,8 +16,9 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
super.onNetworkData, super.onNetworkData,
super.onError, super.onError,
required String path, required String path,
super.renew = false,
}) : super( }) : super(
cacheTime: RequestCache.cacheNothing, cacheTime: _cacheTimeFor(path),
loader: () => ListFiles(ListFilesParams(path)).run(), loader: () => ListFiles(ListFilesParams(path)).run(),
fromJson: ListFilesResponse.fromJson, fromJson: ListFilesResponse.fromJson,
onUpdate: onUpdate, onUpdate: onUpdate,
@@ -25,6 +26,44 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
start(_documentId(path)); start(_documentId(path));
} }
/// The Nextcloud root listing is significantly slower than subfolders on
/// our instance and frequently returns HTTP 500. Since its content rarely
/// changes, the root payload is cached for a full day so app-resume and
/// connectivity-change auto-refetch triggers do not re-hit the slow root
/// endpoint within the same day. To avoid a long wait on the very first
/// open of the Files page, `prefetchRootListing` (called from `main`)
/// kicks off an async warm-up fetch in the background while the user is
/// still on the launch screen / other modules. Subfolders keep the
/// previous "always refetch on visit" TTL because their content changes
/// more often. Explicit user refreshes (rename, delete, copy/move,
/// upload) bypass the TTL via the inherited [renew] flag or via
/// [invalidate].
static int _cacheTimeFor(String path) {
final stripped = path.replaceAll('/', '').trim();
return stripped.isEmpty
? RequestCache.cacheDay
: RequestCache.cacheNothing;
}
/// Triggers a root-listing fetch in the background if no cached payload
/// exists yet. Intended to be called once after login from `main` so the
/// (slow) root listing is already populated by the time the user
/// navigates to the Files module.
///
/// No-ops when a cached root payload is already present in localstore —
/// the regular TTL handling in [RequestCache] takes over from there.
static Future<void> prefetchRootListing() async {
const rootPath = '';
final cached = await Localstore.instance
.collection(RequestCache.collection)
.doc(_documentId(rootPath))
.get();
if (cached != null) return;
// Drive the same code path as a regular fetch so the result lands in
// the cache; we don't care about the in-memory callback here.
ListFilesCache(path: rootPath, onUpdate: (_) {});
}
static String _documentId(String path) { static String _documentId(String path) {
final cacheName = md5 final cacheName = md5
.convert(utf8.encode('MarianumMobile-$path')) .convert(utf8.encode('MarianumMobile-$path'))
+55 -55
View File
@@ -36,17 +36,33 @@ class App extends StatefulWidget {
} }
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _refetchChats;
late Timer _updateTimings; late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync; StreamSubscription<dynamic>? _timetableWidgetSync;
// Tracked via the bottom-nav controller's listener so it always reflects the StreamSubscription<RemoteMessage>? _onMessageSub;
// user's actual position, even between rapid setting emits where the StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
// controller hasn't caught up to a scheduled jump yet. StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1; int _knownTotalTabs = 1;
bool _userOnLastTab = false; bool _userOnLastTab = false;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
void _onTabControllerChanged() { void _onTabControllerChanged() {
_userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1; _userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1;
_syncChatListPolling();
}
void _syncChatListPolling() {
if (!mounted) return;
final modules = AppModule.getBottomBarModules(context);
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
final talkIsActive =
talkSlot >= 0 && Main.bottomNavigator.index == talkSlot;
final bloc = context.read<ChatListBloc>();
bloc.setAutoRefreshInterval(
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
);
if (talkIsActive) bloc.refresh();
} }
@override @override
@@ -65,13 +81,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Future<void> _handlePendingWidgetNavigation() async { Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap(); final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return; if (!pending || !mounted) return;
// Routes pushed with `withNavBar: false` (chat views, file viewers, …) // `withNavBar: false` routes sit on the root navigator above the
// sit on the root navigator above the bottom-nav, so a bare jumpToTab // bottom-nav; pop them so jumpToTab is actually visible. Stop at
// would swap the tab behind them and leave the user staring at the // popups so open dialogs/sheets stay alive.
// previous screen. Reset to the tab root first.
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.goToTab(context, Modules.timetable); AppRoutes.goToTab(context, Modules.timetable);
} }
@@ -80,12 +95,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
final share = ShareIntentListener.pending.value; final share = ShareIntentListener.pending.value;
if (share == null) return; if (share == null) return;
// A second share arriving while a previous share-flow page is still on // A second share would otherwise leave the previous share-flow page
// the stack would otherwise leave the old page sitting on top with stale // on top with stale (already-cleared) file paths.
// (already-cleared) file paths. Reset to the tab root before pushing.
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.openShareTarget(context, share); AppRoutes.openShareTarget(context, share);
} }
@@ -101,15 +115,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
context.read<BreakerBloc>().refresh(); context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
// App is freshly mounted on every login (BlocConsumer in main.dart // Re-mounts on every login, so this also covers post-logout state reset.
// swaps it in for Login), so this also covers the post-logout case
// where the bloc was reset to an empty state and needs a fresh fetch.
final timetable = context.read<TimetableBloc>(); final timetable = context.read<TimetableBloc>();
timetable.refresh(); timetable.refresh();
// Push the freshest timetable state into the home-screen widget any // Mirror BLoC updates into the home-screen widget without waiting
// time the BLoC reports new data — without waiting for the periodic // for the periodic background refresh.
// background refresh. This is the "user just opened the app" path:
// the widget gets the same data the user is looking at on screen.
final settingsCubit = context.read<SettingsCubit>(); final settingsCubit = context.read<SettingsCubit>();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
_timetableWidgetSync = timetable.stream.listen((state) { _timetableWidgetSync = timetable.stream.listen((state) {
@@ -123,8 +133,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
); );
} }
}); });
// Also publish the current state once, in case data is already loaded // Initial publish in case hydrated storage already has data.
// from hydrated storage before the listener attaches.
final initialData = timetable.state.data; final initialData = timetable.state.data;
if (initialData is TimetableState) { if (initialData is TimetableState) {
unawaited( unawaited(
@@ -138,28 +147,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
ShareIntentListener.instance.attach(); ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare(); _handlePendingShare();
_syncChatListPolling();
}); });
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
_refetchChats = Timer.periodic(const Duration(seconds: 60), (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<ChatListBloc>().refresh();
});
});
UpdateUserIndex.index(); UpdateUserIndex.index();
if (context.read<SettingsCubit>().val().notificationSettings.enabled) { if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
void update() => NotifyUpdater.registerToServer(); void update() => NotifyUpdater.registerToServer();
FirebaseMessaging.instance.onTokenRefresh.listen((_) => update()); _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
(_) => update(),
);
update(); update();
} }
FirebaseMessaging.onMessage.listen((message) { _onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return; if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context); NotificationController.onForegroundMessageHandler(message, context);
}); });
@@ -167,7 +172,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationController.onBackgroundMessageHandler, NotificationController.onBackgroundMessageHandler,
); );
FirebaseMessaging.onMessageOpenedApp.listen((message) { _onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
message,
) {
if (!mounted) return; if (!mounted) return;
NotificationController.onAppOpenedByNotification(message, context); NotificationController.onAppOpenedByNotification(message, context);
}); });
@@ -181,9 +188,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_refetchChats.cancel();
_updateTimings.cancel(); _updateTimings.cancel();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach(); ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
@@ -200,17 +209,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final totalTabs = bottomBarModules.length + 1; final totalTabs = bottomBarModules.length + 1;
final currentIndex = Main.bottomNavigator.index; final currentIndex = Main.bottomNavigator.index;
// The bottom-bar layout is identified by the ordered list of module // PersistentTabView caches per-tab navigators by index and only
// names plus the trailing 'more' slot. Whenever this layout changes // appends/trims at the end, so reordering/hiding leaves stale
// — slot count, reordering, or hiding a module — we recreate the // route stacks under the wrong tabs. Re-key on layout to remount.
// entire PersistentTabView via the [layoutKey] below. The package
// caches per-tab navigator state by index in `_navigatorKeys`, and
// its internal `alignLength` only ever appends or trims at the end.
// So when the module sitting at e.g. index 3 changes, the navigator
// at that index still serves the old screen's route stack and the
// user sees stale content. Re-mounting clears those stacks; the
// trade-off (losing in-tab pushed routes on a settings change) is
// acceptable since the user explicitly re-shaped the bar.
final layoutKey = ValueKey( final layoutKey = ValueKey(
'${bottomBarModules.map((m) => m.module.name).join('|')}|more', '${bottomBarModules.map((m) => m.module.name).join('|')}|more',
); );
@@ -222,12 +223,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} else if (currentIndex >= totalTabs) { } else if (currentIndex >= totalTabs) {
targetIndex = totalTabs - 1; targetIndex = totalTabs - 1;
} }
// Re-mounting PTV with a new key constructs fresh internals from // Replace the controller atomically: a stale index past the new
// its controller's current index. If the controller still points // tab list crashes Style6BottomNavBar's initState.
// past the new tab list, Style6BottomNavBar (and others) crash on
// out-of-range access during initState. Replace the controller
// atomically with one initialised at the safe target index so the
// new PTV mounts cleanly.
if (targetIndex != currentIndex) { if (targetIndex != currentIndex) {
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
Main.bottomNavigator = PersistentTabController( Main.bottomNavigator = PersistentTabController(
@@ -263,14 +260,17 @@ class _AppState extends State<App> with WidgetsBindingObserver {
), ),
], ],
navBarBuilder: (config) => Style6BottomNavBar( navBarBuilder: (config) => Style6BottomNavBar(
// Style6BottomNavBar builds its internal animation controller list // Animation controllers are built once in initState and never
// in initState and never grows it on didUpdateWidget. Keying by the // grown — re-key on item count to avoid RangeError on growth.
// item count forces a fresh State whenever the slot count changes,
// which avoids a RangeError when more tabs slide in.
key: ValueKey(config.items.length), key: ValueKey(config.items.length),
navBarConfig: config, navBarConfig: config,
navBarDecoration: NavBarDecoration( navBarDecoration: NavBarDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.grey)), border: Border(
top: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
), ),
), ),
+14
View File
@@ -46,4 +46,18 @@ extension DateTimeFormatting on DateTime {
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow(); String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}'; String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
String formatDateRelativeShort({DateTime? now}) {
final reference = now ?? DateTime.now();
final today = DateTime(reference.year, reference.month, reference.day);
final self = DateTime(year, month, day);
final diff = today.difference(self).inDays;
if (diff == 0) return 'Heute';
if (diff == 1) return 'Gestern';
if (diff > 1 && diff <= 6) {
return Jiffy.parseFromDateTime(this).format(pattern: 'EEEE');
}
return formatDate();
}
} }
+21 -1
View File
@@ -17,11 +17,13 @@ import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
import 'app.dart'; import 'app.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'model/account_data.dart'; import 'model/account_data.dart';
import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/account/bloc/account_state.dart';
@@ -91,6 +93,20 @@ Future<void> main() async {
), ),
); );
// Warm up the Nextcloud root listing in the background while the user is
// still on the launch screen / other modules — the root endpoint is slow
// on our instance, so kicking it off early means the Files page already
// has data ready by the time the user navigates to it. No-op when a
// cached payload is already present, so this does not undo the day-long
// root cache TTL.
if (AccountData().isPopulated()) {
unawaited(
ListFilesCache.prefetchRootListing().onError(
(e, _) => log('Files root prefetch failed: $e'),
),
);
}
if (kReleaseMode) { if (kReleaseMode) {
ErrorWidget.builder = (error) => Material( ErrorWidget.builder = (error) => Material(
color: Colors.white, color: Colors.white,
@@ -138,7 +154,9 @@ Future<void> main() async {
), ),
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()), BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()), BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(create: (_) => ChatBloc()), BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()), BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
], ],
child: const Main(), child: const Main(),
@@ -184,6 +202,8 @@ class _MainState extends State<Main> {
checkerboardRasterCacheImages: checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages, devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
navigatorObservers: [AppRoutes.chatRouteObserver],
localizationsDelegates: const [ localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates, ...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
+26 -2
View File
@@ -1,13 +1,19 @@
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart'; import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart'; import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart'; import '../widget/info_dialog.dart';
import 'notification_tasks.dart'; import 'notification_tasks.dart';
// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM
// background isolate looks the class up by name from native code.
@pragma('vm:entry-point')
class NotificationController { class NotificationController {
// Notification display is handled by the Firebase SDK using server-generated payloads.
@pragma('vm:entry-point') @pragma('vm:entry-point')
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async { static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
NotificationTasks.updateBadgeCount(message); NotificationTasks.updateBadgeCount(message);
@@ -17,8 +23,26 @@ class NotificationController {
RemoteMessage message, RemoteMessage message,
BuildContext context, BuildContext context,
) async { ) async {
NotificationTasks.updateProviders(context); final pushToken = _extractChatToken(message);
final chatBloc = context.read<ChatBloc>();
// hasOpenChat, not currentToken: currentToken sticks around after
// leaveChat so didPopNext can re-claim a stacked chat.
final activeToken = chatBloc.state.data?.currentToken ?? '';
final chatIsOpen =
chatBloc.hasOpenChat &&
pushToken != null &&
pushToken.isNotEmpty &&
pushToken == activeToken;
NotificationTasks.updateBadgeCount(message); NotificationTasks.updateBadgeCount(message);
if (chatIsOpen) {
// Long-poll handles the message; just dismiss any stray tray entry.
unawaited(NotificationTasks.clearNotificationsForChat(pushToken));
return;
}
NotificationTasks.updateProviders(context);
} }
static Future<void> onAppOpenedByNotification( static Future<void> onAppOpenedByNotification(
+40 -2
View File
@@ -1,11 +1,15 @@
import 'dart:developer';
import 'package:eraser/eraser.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'notification_service.dart';
class NotificationTasks { class NotificationTasks {
static void updateBadgeCount(RemoteMessage notification) { static void updateBadgeCount(RemoteMessage notification) {
@@ -14,9 +18,43 @@ class NotificationTasks {
); );
} }
/// Per-chat tag scheme. MUST match the Notify backend, which sets this
/// value on `AndroidNotification.setTag` AND `apns-collapse-id`.
static String chatTag(String chatToken) => 'talk_$chatToken';
/// Removes tray notifications belonging to [chatToken]. Eraser handles
/// iOS (where the plugin's `getActiveNotifications` returns null ids
/// for FCM posts and can't cancel them); the local-notifications sweep
/// handles Android and acts as a fallback while Eraser's native side
/// isn't built in yet.
static Future<void> clearNotificationsForChat(String chatToken) async {
final tag = chatTag(chatToken);
try {
await Eraser.clearAppNotificationsByTag(tag);
} on MissingPluginException {
// Eraser native code not yet linked — needs flutter clean + run.
} on Object catch (e) {
log('Eraser($tag) failed: $e');
}
try {
final plugin = NotificationService().flutterLocalNotificationsPlugin;
final actives = await plugin.getActiveNotifications();
for (final n in actives) {
final id = n.id;
if (id == null) continue;
if (n.tag == tag) await plugin.cancel(id: id, tag: n.tag);
}
} on Object catch (e) {
log('Active-notification sweep failed: $e');
}
}
/// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] —
/// the open chat view manages its own state via long-poll, and refreshing
/// it here would re-fetch the last-opened chat with setReadMarker=on
/// even if the user has already left.
static void updateProviders(BuildContext context) { static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
context.read<ChatBloc>().refresh();
} }
/// Switches to the Talk tab. If [chatToken] is provided, also schedules /// Switches to the Talk tab. If [chatToken] is provided, also schedules
+12
View File
@@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../main.dart'; import '../main.dart';
import '../model/account_data.dart'; import '../model/account_data.dart';
import '../notification/notification_tasks.dart';
import '../share_intent/pending_share.dart'; import '../share_intent/pending_share.dart';
import '../share_intent/remote_file_ref.dart'; import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart'; import '../state/app/modules/app_modules.dart';
@@ -39,6 +40,11 @@ class AppRoutes {
/// by `ChatList` once the matching room is loaded. /// by `ChatList` once the matching room is loaded.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null); static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
/// Root-navigator observer used by [ChatView] to reclaim the global
/// [ChatBloc] on `didPopNext` after a stacked chat is popped.
static final RouteObserver<PageRoute<dynamic>> chatRouteObserver =
RouteObserver<PageRoute<dynamic>>();
static void openFolder(BuildContext context, List<String> path) { static void openFolder(BuildContext context, List<String> path) {
pushScreen(context, withNavBar: false, screen: Files(path: path)); pushScreen(context, withNavBar: false, screen: Files(path: path));
} }
@@ -177,6 +183,12 @@ class AppRoutes {
required UserAvatar avatar, required UserAvatar avatar,
bool overrideToSingleSubScreen = true, bool overrideToSingleSubScreen = true,
}) { }) {
// Local mark only. Server-side mark is sent later from
// ChatBloc._loadChat with the freshly-fetched maxId — sending one
// here too with the chat list's possibly-stale room.lastMessage.id
// would race the fresh one and could regress the server cursor.
context.read<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id);
NotificationTasks.clearNotificationsForChat(room.token);
TalkNavigator.pushSplitView( TalkNavigator.pushSplitView(
context, context,
ChatView(room: room, selfId: selfId, avatar: avatar), ChatView(room: room, selfId: selfId, avatar: avatar),
+208 -11
View File
@@ -1,15 +1,53 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../chat_list/bloc/chat_list_bloc.dart';
import '../repository/chat_repository.dart'; import '../repository/chat_repository.dart';
import 'chat_event.dart'; import 'chat_event.dart';
import 'chat_state.dart'; import 'chat_state.dart';
class ChatBloc class ChatBloc
extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository> { extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository>
with WidgetsBindingObserver {
final ChatListBloc? _chatListBloc;
String? _pollingToken;
int _backoffMs = 0;
int _lastKnownMessageId = 0;
bool _appResumed = true;
/// True only while a ChatView is mounted. Can't reuse `currentToken` —
/// clearing it on leaveChat races with setToken from didPopNext when
/// popping a stacked chat, causing spurious server read-markers on resume.
bool _chatViewActive = false;
bool get hasOpenChat => _chatViewActive;
DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0);
ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc {
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> close() {
WidgetsBinding.instance.removeObserver(this);
_stopLongPoll();
return super.close();
}
@override @override
ChatRepository repository() => ChatRepository(); ChatRepository repository() => ChatRepository();
@@ -33,24 +71,70 @@ class ChatBloc
} }
void setToken(String token) { void setToken(String token) {
_chatViewActive = true;
if (token == (innerState?.currentToken ?? '')) { if (token == (innerState?.currentToken ?? '')) {
refresh(); refresh();
return; return;
} }
_stopLongPoll();
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
add(RefetchStarted<ChatState>()); add(RefetchStarted<ChatState>());
_loadChat(token); _scheduleLoad(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
} }
void refresh() { void refresh() {
final token = innerState?.currentToken ?? ''; final token = innerState?.currentToken ?? '';
if (token.isEmpty) return; if (token.isEmpty) return;
add(RefetchStarted<ChatState>()); add(RefetchStarted<ChatState>());
_loadChat(token); _scheduleLoad(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
}
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
_stopLongPoll();
}
Future<void> sendServerReadMarker(String token, int messageId) async {
try {
await SetReadMarker(
token,
true,
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: messageId),
).run();
} on Object catch (e) {
log('Server read-marker for $token failed: $e');
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasResumed = _appResumed;
_appResumed = state == AppLifecycleState.resumed;
if (!_appResumed) {
_stopLongPoll();
return;
}
if (wasResumed) return;
final token = innerState?.currentToken ?? '';
if (token.isNotEmpty && _chatViewActive) refresh();
}
/// Microtask hop so the Bloc worker drains the preceding Emit before
/// any cache callback fires — a quick cache hit otherwise runs with
/// the previous token in state and fails stillCurrent().
void _scheduleLoad(String token) {
Future<void>.microtask(() {
if (isClosed) return;
_loadChat(token).then((_) => _startLongPoll(token));
});
} }
Future<void> _loadChat(String token) async { Future<void> _loadChat(String token) async {
@@ -69,14 +153,25 @@ class ChatBloc
token: token, token: token,
onCacheData: (data) { onCacheData: (data) {
if (!stillCurrent()) return; if (!stillCurrent()) return;
// Cache hit: show data immediately but preserve lastFetch — the // Skip cache paint over already-merged long-poll data — would
// cached payload may be stale and we don't want the UI to claim a // visibly drop those messages until the network call resolves.
// fresh fetch just happened. if (innerState?.chatResponse != null) return;
add(Emit((s) => s.copyWith(chatResponse: data))); add(Emit((s) => s.copyWith(chatResponse: data)));
}, },
onNetworkData: (data) { onNetworkData: (data) {
// Mark runs even if no longer current — otherwise a quick
// navigation away leaves the server cursor stale. Cache check
// skips the POST when the cursor is already at maxId.
final maxId = _maxMessageId(data);
if (maxId > 0) {
final cached = _chatListBloc?.lastReadMessageFor(token);
if (cached == null || cached < maxId) {
unawaited(sendServerReadMarker(token, maxId));
}
}
if (!stillCurrent()) return; if (!stillCurrent()) return;
add(DataGathered((s) => s.copyWith(chatResponse: data))); _applyChatResponse(data);
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
}, },
onError: (e) => capturedError = e, onError: (e) => capturedError = e,
); );
@@ -98,4 +193,106 @@ class ChatBloc
); );
} }
} }
void _startLongPoll(String token) {
if (!_appResumed) return;
if (_pollingToken == token) return;
_stopLongPoll();
_pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token));
}
void _stopLongPoll() {
_pollingToken = null;
_backoffMs = 0;
}
Future<void> _pollLoop(String token) async {
while (_pollingToken == token && !isClosed) {
try {
final response = await LongPollChat(
chatToken: token,
lastKnownMessageId: _lastKnownMessageId,
).run();
if (_pollingToken != token || isClosed) return;
_backoffMs = 0;
if (response == null) continue;
final headerId = int.tryParse(
response.headers?[_kLongPollLastGivenHeader] ?? '',
);
if (headerId != null && headerId > _lastKnownMessageId) {
_lastKnownMessageId = headerId;
}
if (response.data.isEmpty) continue;
_applyChatResponse(response);
final maxId = _maxMessageId(response);
if (maxId > _lastKnownMessageId) _lastKnownMessageId = maxId;
// Long-poll's setReadMarker=on moved the server cursor; mirror locally.
final preview = _pickDisplayMessage(response);
if (preview != null) {
_chatListBloc?.applyIncomingMessage(token, preview);
} else {
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
}
} on Object catch (e) {
if (_pollingToken != token || isClosed) return;
log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs));
}
}
}
/// Dedups by id with newer-wins so server edits/deletes propagate.
void _applyChatResponse(GetChatResponse incoming) {
final current = innerState?.chatResponse;
if (current == null) {
add(DataGathered((s) => s.copyWith(chatResponse: incoming)));
return;
}
final byId = <int, GetChatResponseObject>{};
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in incoming.data) {
byId[m.id] = m;
}
final merged = GetChatResponse(byId.values.toSet())
..headers = incoming.headers;
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
}
int _maxMessageId(GetChatResponse? response) {
if (response == null) return 0;
var max = 0;
for (final m in response.data) {
if (m.id > max) max = m.id;
}
return max;
}
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
GetChatResponseObject? best;
for (final m in response.data) {
switch (m.messageType) {
case GetRoomResponseObjectMessageType.comment:
case GetRoomResponseObjectMessageType.voiceMessage:
if (best == null || m.id > best.id) best = m;
case GetRoomResponseObjectMessageType.deletedComment:
case GetRoomResponseObjectMessageType.system:
case GetRoomResponseObjectMessageType.command:
break;
}
}
return best;
}
} }
const _kLongPollLastGivenHeader = 'x-chat-last-given';
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
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 '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -15,6 +17,8 @@ class ChatListBloc
extends extends
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> { LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
bool _forceRenew = false; bool _forceRenew = false;
Timer? _autoRefreshTimer;
Duration? _autoRefreshInterval;
@override @override
void retry() { void retry() {
@@ -22,6 +26,25 @@ class ChatListBloc
super.retry(); super.retry();
} }
@override
Future<void> close() {
_autoRefreshTimer?.cancel();
return super.close();
}
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
_autoRefreshInterval = interval;
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
if (interval == null) return;
_autoRefreshTimer = Timer.periodic(interval, (_) {
if (isClosed) return;
refresh(silent: true);
});
}
@override @override
ChatListRepository repository() => ChatListRepository(); ChatListRepository repository() => ChatListRepository();
@@ -51,8 +74,8 @@ class ChatListBloc
if (capturedError != null) throw capturedError!; if (capturedError != null) throw capturedError!;
} }
Future<void> refresh({bool renew = true}) async { Future<void> refresh({bool renew = true, bool silent = false}) async {
add(RefetchStarted<ChatListState>()); if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError; Object? capturedError;
try { try {
final rooms = await repo.data.getRooms( final rooms = await repo.data.getRooms(
@@ -82,6 +105,65 @@ class ChatListBloc
await refresh(); await refresh();
} }
int? lastReadMessageFor(String token) {
final rooms = innerState?.rooms;
if (rooms == null) return null;
for (final room in rooms.data) {
if (room.token == token) return room.lastReadMessage;
}
return null;
}
/// Optimistic — server-side mark-as-read is the caller's job.
void markRoomAsRead(String token, int lastMessageId) {
_mutateRoom(token, (r) {
if (r.unreadMessages == 0 && r.lastReadMessage >= lastMessageId) {
return false;
}
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (lastMessageId > r.lastReadMessage) r.lastReadMessage = lastMessageId;
return true;
});
}
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
final wasRead =
r.unreadMessages == 0 && r.lastReadMessage >= message.id;
final hasNewer = r.lastMessage.id >= message.id;
if (wasRead && hasNewer) return false;
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (message.id > r.lastReadMessage) r.lastReadMessage = message.id;
if (message.id > r.lastMessage.id) r.lastMessage = message;
if (message.timestamp > r.lastActivity) r.lastActivity = message.timestamp;
return true;
});
}
/// Re-wraps in a fresh [GetRoomResponse] so identity-based equality picks it up.
void _mutateRoom(
String token,
bool Function(GetRoomResponseObject room) mutator,
) {
final rooms = innerState?.rooms;
if (rooms == null) return;
var changed = false;
final updated = rooms.data.map((r) {
if (r.token != token) return r;
if (mutator(r)) changed = true;
return r;
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}
void _updateAppBadge(GetRoomResponse rooms) { void _updateAppBadge(GetRoomResponse rooms) {
try { try {
final unread = rooms.data.fold<int>( final unread = rooms.data.fold<int>(
@@ -37,7 +37,9 @@ class FilesBloc
Future<void> refresh() async { Future<void> refresh() async {
add(RefetchStarted<FilesState>()); add(RefetchStarted<FilesState>());
final path = innerState?.currentPath ?? initialPath; final path = innerState?.currentPath ?? initialPath;
await _query(path); // Explicit user action — bypass the cache TTL so the root listing also
// refetches even though it is otherwise cached for a day.
await _query(path, renew: true);
} }
Future<void> setPath(List<String> path) async { Future<void> setPath(List<String> path) async {
@@ -52,7 +54,7 @@ class FilesBloc
await refresh(); await refresh();
} }
Future<void> _query(List<String> path) async { Future<void> _query(List<String> path, {bool renew = false}) async {
final pathString = path.isEmpty ? '/' : path.join('/'); final pathString = path.isEmpty ? '/' : path.join('/');
// Drop late results when [setPath] has navigated elsewhere or when the // Drop late results when [setPath] has navigated elsewhere or when the
@@ -71,6 +73,7 @@ class FilesBloc
try { try {
listing = await repo.data.listFiles( listing = await repo.data.listFiles(
pathString, pathString,
renew: renew,
onCacheData: (cached) { onCacheData: (cached) {
if (isStale()) return; if (isStale()) return;
// Cached payload arrives before the network call settles. Surface it // Cached payload arrives before the network call settles. Surface it
@@ -11,16 +11,23 @@ class FilesDataProvider {
/// network call is still pending. The Future itself resolves once both the /// network call is still pending. The Future itself resolves once both the
/// cache lookup and the network attempt have settled, throwing if no payload /// cache lookup and the network attempt have settled, throwing if no payload
/// could be obtained at all. /// could be obtained at all.
///
/// Pass [renew] for explicit user-triggered reloads (pull-to-refresh, after
/// a rename / delete / move / upload). It bypasses the per-path TTL in
/// [ListFilesCache] so the root listing — which is otherwise cached for a
/// full day — still refetches when the user actively asks for it.
Future<ListFilesResponse> listFiles( Future<ListFilesResponse> listFiles(
String path, { String path, {
void Function(ListFilesResponse)? onCacheData, void Function(ListFilesResponse)? onCacheData,
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<ListFilesResponse>( }) => resolveFromCache<ListFilesResponse>(
(onUpdate, onError) => ListFilesCache( (onUpdate, onError) => ListFilesCache(
path: path, path: path,
onUpdate: onUpdate, onUpdate: onUpdate,
onCacheData: onCacheData, onCacheData: onCacheData,
onError: onError, onError: onError,
renew: renew,
), ),
onError: onError, onError: onError,
operationName: 'listFiles', operationName: 'listFiles',
+10
View File
@@ -14,6 +14,7 @@ import '../../../utils/cache_invalidation_bus.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import 'data/sort_options.dart'; import 'data/sort_options.dart';
import 'files_upload_dialog.dart'; import 'files_upload_dialog.dart';
import 'search/files_search_delegate.dart';
import 'widgets/add_file_menu.dart'; import 'widgets/add_file_menu.dart';
import 'widgets/clipboard_banner.dart'; import 'widgets/clipboard_banner.dart';
import 'widgets/file_element.dart'; import 'widgets/file_element.dart';
@@ -117,6 +118,15 @@ class _FilesViewState extends State<_FilesView> {
}); });
}, },
), ),
IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search),
onPressed: () async {
final delegate = FilesSearchDelegate(pathScope: widget.path);
await showSearch<void>(context: context, delegate: delegate);
delegate.disposeController();
},
),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
@@ -0,0 +1,157 @@
import 'package:flutter/foundation.dart';
import '../../../../api/marianumcloud/search/search_files.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../utils/debouncer.dart';
import 'local_cache_search.dart';
/// Holds the live state of a Files-search session: current query, the latest
/// local-cache hits (synchronous), the latest server hits (asynchronous,
/// debounced), and loading/error flags. Notifies listeners whenever any of
/// these change so the UI can rebuild incrementally as results stream in.
class FilesSearchController extends ChangeNotifier {
FilesSearchController({List<String>? initialPathScope})
: _pathScope = List<String>.from(initialPathScope ?? const []);
static const Duration _serverDebounce = Duration(seconds: 1);
final String _debounceTag =
'files-search-${DateTime.now().microsecondsSinceEpoch}';
final SearchFiles _api = SearchFiles();
String _query = '';
List<String> _pathScope;
List<CacheableFile> _cacheResults = const [];
List<CacheableFile> _serverResults = const [];
bool _serverLoading = false;
Object? _serverError;
int _serverEpoch = 0;
bool _disposed = false;
/// Guards against the race where the search delegate is closed (and the
/// controller disposed) while a debounced cache scan or server call is
/// still in flight: their late `notifyListeners()` would otherwise throw
/// on a disposed `ChangeNotifier`.
void _safeNotify() {
if (_disposed) return;
notifyListeners();
}
String get query => _query;
List<String> get pathScope => List.unmodifiable(_pathScope);
bool get isScoped => _pathScope.isNotEmpty;
List<CacheableFile> get cacheResults => _cacheResults;
List<CacheableFile> get serverResults => _serverResults;
bool get serverLoading => _serverLoading;
Object? get serverError => _serverError;
/// Combined, deduplicated result list (cache hits first, then any
/// server-only hits) — handy for empty-state checks. Dedup key is the
/// WebDAV path.
List<CacheableFile> get combinedResults {
if (_cacheResults.isEmpty) return _serverResults;
if (_serverResults.isEmpty) return _cacheResults;
final seen = <String>{for (final f in _cacheResults) f.path};
return [
..._cacheResults,
..._serverResults.where((f) => seen.add(f.path)),
];
}
Future<void> setQuery(String value) async {
if (value == _query) return;
_query = value;
// Bumping the epoch up front invalidates any in-flight server call from
// a previous query, so its late response cannot toggle `_serverLoading`
// off while a fresh search is queued behind the debounce.
final epoch = ++_serverEpoch;
if (_query.trim().isEmpty) {
Debouncer.cancel(_debounceTag);
_cacheResults = const [];
_serverResults = const [];
_serverLoading = false;
_serverError = null;
_safeNotify();
return;
}
// Show loading immediately — even before the (typically fast) cache
// scan resolves — so the indicator is visible the moment the user
// starts typing rather than after the first await hop.
_serverLoading = true;
_serverError = null;
_safeNotify();
final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope);
if (epoch != _serverEpoch) return;
_cacheResults = cacheHits;
_safeNotify();
_scheduleServerCall();
}
/// Drops the path filter and re-runs the current search globally. Used by
/// the empty-state "Im Hauptverzeichnis suchen" button.
Future<void> searchEverywhere() async {
if (!isScoped) return;
_pathScope = const [];
final epoch = ++_serverEpoch;
if (_query.trim().isEmpty) {
_safeNotify();
return;
}
_serverLoading = true;
_serverError = null;
_safeNotify();
final cacheHits = await searchLocalCaches(_query);
if (epoch != _serverEpoch) return;
_cacheResults = cacheHits;
_safeNotify();
_scheduleServerCall();
}
/// Re-runs the current server query immediately, bypassing the debounce.
/// Wired to the `LoadableStateErrorScreen` "Erneut versuchen" button.
void retry() {
if (_query.trim().isEmpty) return;
++_serverEpoch;
Debouncer.cancel(_debounceTag);
_serverLoading = true;
_serverError = null;
_safeNotify();
_runServerCall();
}
void _scheduleServerCall() {
Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall);
}
Future<void> _runServerCall() async {
final epoch = _serverEpoch;
final term = _query;
final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/';
try {
final response = await _api.run(term: term);
if (epoch != _serverEpoch) return;
_serverResults = response.entries
.map((e) => e.toCacheable())
.whereType<CacheableFile>()
.where((f) => scopePrefix.isEmpty || f.path.startsWith(scopePrefix))
.toList();
_serverLoading = false;
_serverError = null;
_safeNotify();
} on Object catch (e) {
if (epoch != _serverEpoch) return;
_serverResults = const [];
_serverLoading = false;
_serverError = e;
_safeNotify();
}
}
@override
void dispose() {
_disposed = true;
Debouncer.cancel(_debounceTag);
super.dispose();
}
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'files_search_controller.dart';
import 'files_search_results.dart';
/// Material `SearchDelegate` for the Files module — opens via the magnifier
/// in `FilesPage`'s AppBar (mirroring `SearchMarianumMessages`). Owns one
/// [FilesSearchController]; cache + server hits stream into the result list
/// as the user types.
class FilesSearchDelegate extends SearchDelegate<void> {
final FilesSearchController _controller;
FilesSearchDelegate({required List<String> pathScope})
: _controller = FilesSearchController(initialPathScope: pathScope),
super(searchFieldLabel: 'Dateien 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),
onPressed: () => close(context, null),
);
@override
Widget buildResults(BuildContext context) {
_controller.setQuery(query);
return FilesSearchResults(
controller: _controller,
onResultTap: () => close(context, null),
);
}
@override
Widget buildSuggestions(BuildContext context) {
_controller.setQuery(query);
return FilesSearchResults(
controller: _controller,
onResultTap: () => close(context, null),
);
}
}
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../routing/app_routes.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 '../widgets/file_element.dart';
import 'files_search_controller.dart';
/// Renders the live state of a [FilesSearchController]. Wraps everything in a
/// `LoadableStateBloc` module so the search reuses the standard primary /
/// background loading and error views from the rest of the app.
class FilesSearchResults extends StatelessWidget {
final FilesSearchController controller;
final VoidCallback? onResultTap;
const FilesSearchResults({
required this.controller,
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 in Dateien 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,
),
// Background loading sits *outside* the result Stack so the linear
// progress bar is not painted over by the opaque ListView/ListTiles
// when cache hits are already on screen and the server is still
// working. The widget collapses to zero height when invisible.
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
Expanded(
child: Stack(
children: [
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStateErrorScreen(
visible: showErrorScreen,
message: errorMessage,
),
if (showEmpty) _emptyState(context),
if (hasContent) _resultList(context, combined),
],
),
),
],
);
}
Widget _emptyState(BuildContext context) => PlaceholderView(
icon: Icons.search_off_outlined,
text: 'Keine Treffer gefunden.',
button: controller.isScoped
? FilledButton.icon(
onPressed: controller.searchEverywhere,
icon: const Icon(Icons.travel_explore),
label: const Text('Im Hauptverzeichnis suchen'),
)
: null,
);
Widget _resultList(BuildContext context, List<CacheableFile> combined) {
final groups = _groupByParent(combined);
final orderedKeys = groups.keys.toList()..sort();
final items = <Widget>[];
for (final folder in orderedKeys) {
final segments = _segmentsOf(folder);
items.add(
_FolderHeader(
folder: folder,
onOpen: () {
onResultTap?.call();
AppRoutes.openFolder(context, segments);
},
),
);
for (final file in groups[folder]!) {
items.add(
FileElement(
file,
segments,
controller.retry,
highlight: controller.query,
),
);
}
}
return ListView(padding: EdgeInsets.zero, children: items);
}
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
final map = <String, List<CacheableFile>>{};
for (final file in files) {
map.putIfAbsent(_parentOf(file), () => []).add(file);
}
return map;
}
String _parentOf(CacheableFile file) {
final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), '');
final segments = stripped.split('/');
if (segments.length <= 1) return '/';
segments.removeLast();
return '/${segments.join('/')}';
}
List<String> _segmentsOf(String folder) {
final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), '');
if (stripped.isEmpty) return const [];
return stripped.split('/');
}
}
class _FolderHeader extends StatelessWidget {
final String folder;
final VoidCallback onOpen;
const _FolderHeader({required this.folder, required this.onOpen});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
height: 38,
color: theme.colorScheme.surfaceContainer,
padding: const EdgeInsets.only(left: 16),
child: Row(
children: [
Expanded(
child: Text(
folder,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: 'Ordner öffnen',
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.folder_open_outlined),
onPressed: onOpen,
),
],
),
);
}
}
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:localstore/localstore.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../../api/request_cache.dart';
/// Document key prefix used by `ListFilesCache._documentId`.
const String _folderCachePrefix = 'wd-folder-';
/// Scans every cached folder listing in Localstore and returns files/folders
/// whose name contains [query] (case-insensitive).
///
/// [pathScope] restricts results to entries whose WebDAV path starts with
/// the given folder. Pass an empty list (or null) to search globally.
///
/// [docs] is an injection seam for tests — production callers leave it null
/// so the helper reads from the real Localstore.
Future<List<CacheableFile>> searchLocalCaches(
String query, {
List<String>? pathScope,
Map<String, dynamic>? docs,
}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final needle = trimmed.toLowerCase();
final scopePrefix = pathScope == null || pathScope.isEmpty
? ''
: '${pathScope.join('/')}/';
final raw =
docs ??
await Localstore.instance.collection(RequestCache.collection).get();
if (raw == null || raw.isEmpty) return const [];
final results = <String, CacheableFile>{};
for (final entry in raw.entries) {
final docKey = entry.key.split('/').last;
if (!docKey.startsWith(_folderCachePrefix)) continue;
final value = entry.value;
if (value is! Map) continue;
final json = value['json'];
if (json is! String) continue;
final ListFilesResponse listing;
try {
listing = ListFilesResponse.fromJson(
jsonDecode(json) as Map<String, dynamic>,
);
} on Object {
continue;
}
for (final file in listing.files) {
if (!file.name.toLowerCase().contains(needle)) continue;
if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) {
continue;
}
results[file.path] ??= file;
}
}
return results.values.toList();
}
+49 -7
View File
@@ -14,13 +14,25 @@ import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/info_dialog.dart'; import '../../../../widget/info_dialog.dart';
import '../../talk/widgets/highlighted_linkify.dart';
import 'file_details_sheet.dart'; import 'file_details_sheet.dart';
class FileElement extends StatefulWidget { class FileElement extends StatefulWidget {
final CacheableFile file; final CacheableFile file;
final List<String> path; final List<String> path;
final void Function() refetch; final void Function() refetch;
const FileElement(this.file, this.path, this.refetch, {super.key});
/// When non-null, occurrences of this string in the file name are visually
/// highlighted in the tile title. Used by the Files search delegate.
final String? highlight;
const FileElement(
this.file,
this.path,
this.refetch, {
this.highlight,
super.key,
});
@override @override
State<FileElement> createState() => _FileElementState(); State<FileElement> createState() => _FileElementState();
@@ -118,7 +130,7 @@ class _FileElementState extends State<FileElement> {
); );
} }
Widget _subtitle() { Widget? _subtitle() {
final status = _job?.status.value; final status = _job?.status.value;
if (status is DownloadInProgress) { if (status is DownloadInProgress) {
return Row( return Row(
@@ -135,10 +147,16 @@ class _FileElementState extends State<FileElement> {
], ],
); );
} }
final modified = widget.file.modifiedAt ?? DateTime.now(); final modified = widget.file.modifiedAt;
return widget.file.isDirectory final size = widget.file.size;
? Text('geändert ${modified.formatRelative()}') if (widget.file.isDirectory) {
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}'); if (modified == null) return null;
return Text('geändert ${modified.formatRelative()}');
}
if (size == null && modified == null) return null;
if (size == null) return Text(modified!.formatRelative());
if (modified == null) return Text(filesize(size));
return Text('${filesize(size)}, ${modified.formatRelative()}');
} }
void _onTap() { void _onTap() {
@@ -328,12 +346,36 @@ class _FileElementState extends State<FileElement> {
); );
} }
Widget _title(BuildContext context) {
final base =
Theme.of(context).textTheme.bodyLarge ??
DefaultTextStyle.of(context).style;
if (widget.highlight == null || widget.highlight!.trim().isEmpty) {
return Text(
widget.file.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
return Text.rich(
TextSpan(
children: buildHighlightedSpans(
text: widget.file.name,
query: widget.highlight,
baseStyle: base,
),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
@override @override
Widget build(BuildContext context) => ListTile( Widget build(BuildContext context) => ListTile(
leading: CenteredLeading( leading: CenteredLeading(
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
), ),
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), title: _title(context),
subtitle: _subtitle(), subtitle: _subtitle(),
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
onTap: _onTap, onTap: _onTap,
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
initialDescription: event.description, initialDescription: event.description,
initialStart: event.start, initialStart: event.start,
initialEnd: event.end, initialEnd: event.end,
initialAllDay: event.isAllDay,
), ),
barrierDismissible: false, barrierDismissible: false,
), ),
@@ -6,6 +6,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import 'search_marianum_messages.dart';
class MarianumMessageListView extends StatelessWidget { class MarianumMessageListView extends StatelessWidget {
const MarianumMessageListView({super.key}); const MarianumMessageListView({super.key});
@@ -16,7 +17,25 @@ class MarianumMessageListView extends StatelessWidget {
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>( ) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
create: (context) => MarianumMessageBloc(), create: (context) => MarianumMessageBloc(),
child: (context, bloc, state) => Scaffold( child: (context, bloc, state) => Scaffold(
appBar: AppBar(title: const Text('Marianum Message')), appBar: AppBar(
title: const Text('Marianum Message'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
final list = bloc.state.data?.messageList;
if (list == null) return;
showSearch(
context: context,
delegate: SearchMarianumMessages(
base: list.base,
messages: list.messages,
),
);
},
),
],
),
body: LoadableStateConsumer<MarianumMessageBloc, MarianumMessageState>( body: LoadableStateConsumer<MarianumMessageBloc, MarianumMessageState>(
child: (state, loading) => ListView.builder( child: (state, loading) => ListView.builder(
itemCount: state.messageList.messages.length, itemCount: state.messageList.messages.length,
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/placeholder_view.dart';
class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
final String base;
final List<MarianumMessage> messages;
SearchMarianumMessages({required this.base, required this.messages});
List<MarianumMessage> _matches() {
final q = query.trim().toLowerCase();
if (q.isEmpty) return messages;
return messages.where((m) {
return m.name.toLowerCase().contains(q) ||
m.date.toLowerCase().contains(q);
}).toList();
}
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
@override
Widget buildResults(BuildContext context) {
final matches = _matches();
if (matches.isEmpty) {
return const PlaceholderView(
icon: Icons.search_off_outlined,
text: 'Keine Treffer',
);
}
return ListView.builder(
itemCount: matches.length,
itemBuilder: (_, i) {
final message = matches[i];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Text('vom ${message.date}'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
close(context, message);
AppRoutes.openMarianumMessage(context, base, message);
},
);
},
);
}
@override
Widget buildSuggestions(BuildContext context) => buildResults(context);
}
@@ -67,12 +67,12 @@ class AboutSection extends StatelessWidget {
applicationIcon: const Icon(Icons.apps), applicationIcon: const Icon(Icons.apps),
applicationName: 'MarianumMobile', applicationName: 'MarianumMobile',
applicationVersion: applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', '${appInfo.appName}\n\n${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild/Relase-nummer: ${appInfo.buildNumber}',
applicationLegalese: applicationLegalese:
'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' 'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
"${kReleaseMode ? "Production" : "Development"} build\n" "${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', 'Marianum Fulda 2019-2020, 2023-${Jiffy.now().year}\nElias Müller',
); );
} }
@@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget {
), ),
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)), leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'), title: const Text('Infos zu (Web) Untis'),
subtitle: const Text('Für den Stundenplan'), subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo( onTap: () => PrivacyInfo(
@@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget {
Icon(Icons.send_time_extension_outlined), Icon(Icons.send_time_extension_outlined),
), ),
title: const Text('Infos zu mhsl'), title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'), subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo( onTap: () => PrivacyInfo(
providerText: 'mhsl', providerText: 'mhsl',
+11 -15
View File
@@ -7,7 +7,6 @@ import '../../../notification/notify_updater.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -19,15 +18,13 @@ import 'search_chat.dart';
import 'widgets/chat_tile.dart'; import 'widgets/chat_tile.dart';
import 'widgets/split_view_placeholder.dart'; import 'widgets/split_view_placeholder.dart';
// Reads from the global ChatListBloc in main.dart — re-providing a local
// one here would shadow it and split the state in two.
class ChatList extends StatelessWidget { class ChatList extends StatelessWidget {
const ChatList({super.key}); const ChatList({super.key});
@override @override
Widget build(BuildContext context) => Widget build(BuildContext context) => const _ChatListView();
BlocModule<ChatListBloc, LoadableState<ChatListState>>(
create: (_) => ChatListBloc(),
child: (context, bloc, _) => const _ChatListView(),
);
} }
class _ChatListView extends StatefulWidget { class _ChatListView extends StatefulWidget {
@@ -65,14 +62,6 @@ class _ChatListViewState extends State<_ChatListView> {
final resolved = AppRoutes.resolvePendingChat(context); final resolved = AppRoutes.resolvePendingChat(context);
if (resolved == null) return; if (resolved == null) return;
AppRoutes.pendingChatToken.value = null; AppRoutes.pendingChatToken.value = null;
// Replace any chat already pushed on top of the chat list so a freshly
// tapped notification doesn't stack indefinitely on previous chats.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
}
AppRoutes.openChatView( AppRoutes.openChatView(
context, context,
room: resolved.room, room: resolved.room,
@@ -193,7 +182,14 @@ class _ChatListViewState extends State<_ChatListView> {
.talkSettings .talkSettings
.drafts .drafts
.containsKey(room.token); .containsKey(room.token);
return ChatTile(data: room, hasDraft: hasDraft); // Stable key keeps element identity across re-sorts so the
// inner UserAvatar reuses its cached bytes instead of
// flashing on every list update.
return ChatTile(
key: ValueKey(room.token),
data: room,
hasDraft: hasDraft,
);
}).toList(), }).toList(),
); );
}, },
+267 -24
View File
@@ -1,18 +1,27 @@
import 'dart:async';
import 'dart:math' as math;
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:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; 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 '../../../notification/notification_tasks.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/modules/chat/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 '../../../theming/app_theme.dart'; import '../../../theming/app_theme.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 'details/chat_info.dart'; import 'details/chat_info.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';
class ChatView extends StatefulWidget { class ChatView extends StatefulWidget {
@@ -31,15 +40,201 @@ class ChatView extends StatefulWidget {
State<ChatView> createState() => _ChatViewState(); State<ChatView> createState() => _ChatViewState();
} }
class _ChatViewState extends State<ChatView> { class _ChatViewState extends State<ChatView> with RouteAware {
final ScrollController _listController = ScrollController(); final ItemScrollController _itemScrollController = ItemScrollController();
final TextEditingController _searchTextController = TextEditingController();
final Map<int, int> _matchIndices = {};
bool _searchActive = false;
String _searchQuery = '';
List<ChatSearchMatch> _matches = const [];
int _activeMatchIndex = 0;
GetChatResponse? _matchesComputedFor;
String? _matchesComputedQuery;
// Captured in initState because the framework has unmounted us by the
// time dispose runs.
ChatBloc? _chatBlocRef;
ChatListBloc? _chatListBlocRef;
PageRoute<dynamic>? _subscribedRoute;
@override
void initState() {
super.initState();
_chatBlocRef = context.read<ChatBloc>();
_chatListBlocRef = context.read<ChatListBloc>();
NotificationTasks.clearNotificationsForChat(widget.room.token);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is PageRoute && route != _subscribedRoute) {
if (_subscribedRoute != null) {
AppRoutes.chatRouteObserver.unsubscribe(this);
}
AppRoutes.chatRouteObserver.subscribe(this, route);
_subscribedRoute = route;
}
}
@override
void didPopNext() {
super.didPopNext();
// A stacked chat above us was just popped (typical: notification tap
// opened another chat). The global ChatBloc currently points at that
// other chat's token, so our isReady predicate fails until we re-claim.
_chatBlocRef?.setToken(widget.room.token);
}
@override
void dispose() {
if (_subscribedRoute != null) {
AppRoutes.chatRouteObserver.unsubscribe(this);
}
_markAsReadFinal();
_chatBlocRef?.leaveChat(widget.room.token);
_searchTextController.dispose();
super.dispose();
}
/// Defensive final mark-as-read so a back-out before the long-poll
/// could fire doesn't leave the room as unread. Skipped when the bloc
/// has already moved on to another chat — the response data there
/// belongs to a different room, and writing its max-id as our marker
/// would regress our server cursor.
void _markAsReadFinal() {
final state = _chatBlocRef?.state.data;
if (state == null) return;
if (state.currentToken != widget.room.token) return;
final response = state.chatResponse;
if (response == null) return;
var maxId = 0;
for (final m in response.data) {
if (m.id > maxId) maxId = m.id;
}
if (maxId == 0) return;
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId);
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId));
}
@override
void didUpdateWidget(covariant ChatView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.room.token != oldWidget.room.token && _searchActive) {
_exitSearchMode();
}
}
void _refresh() { void _refresh() {
context.read<ChatBloc>().setToken(widget.room.token); context.read<ChatBloc>().setToken(widget.room.token);
} }
void _enterSearchMode() {
setState(() {
_searchActive = true;
_searchQuery = '';
_matches = const [];
_activeMatchIndex = 0;
_matchesComputedFor = null;
_matchesComputedQuery = null;
_searchTextController.clear();
});
}
void _exitSearchMode() {
setState(() {
_searchActive = false;
_searchQuery = '';
_matches = const [];
_activeMatchIndex = 0;
_matchIndices.clear();
_matchesComputedFor = null;
_matchesComputedQuery = null;
_searchTextController.clear();
});
}
void _onSearchChanged(String q) {
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse;
setState(() {
_searchQuery = q;
_activeMatchIndex = 0;
if (chatResponse != null) {
_recomputeMatches(chatResponse);
} else {
_matches = const [];
_matchesComputedFor = null;
_matchesComputedQuery = null;
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _matches.isNotEmpty) _scrollToActiveMatch();
});
}
void _recomputeMatches(GetChatResponse response) {
_matches = ChatSearchController.findMatches(response, _searchQuery);
_activeMatchIndex = _activeMatchIndex.clamp(
0,
math.max(0, _matches.length - 1),
);
_matchesComputedFor = response;
_matchesComputedQuery = _searchQuery;
}
void _goToPreviousMatch() {
if (_matches.isEmpty) return;
setState(() {
_activeMatchIndex = (_activeMatchIndex + 1) % _matches.length;
});
WidgetsBinding.instance.addPostFrameCallback(
(_) => _scrollToActiveMatch(),
);
}
void _goToNextMatch() {
if (_matches.isEmpty) return;
setState(() {
_activeMatchIndex =
(_activeMatchIndex - 1 + _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(
index: idx,
alignment: 0.4,
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
}
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 messages = <Widget>[]; final messages = <Widget>[];
final chronologicalMatchIndex = <int, int>{};
var lastDate = DateTime.now(); var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) { for (final element in response.sortByTimestamp()) {
final elementDate = DateTime.fromMillisecondsSinceEpoch( final elementDate = DateTime.fromMillisecondsSinceEpoch(
@@ -48,6 +243,7 @@ class _ChatViewState extends State<ChatView> {
if (element.systemMessage.contains('reaction')) continue; if (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue; if (element.systemMessage.contains('poll_voted')) continue;
if (element.systemMessage.contains('message_deleted')) continue;
final commonRead = int.parse( final commonRead = int.parse(
response.headers?['x-chat-last-common-read'] ?? '0', response.headers?['x-chat-last-common-read'] ?? '0',
); );
@@ -65,17 +261,31 @@ class _ChatViewState extends State<ChatView> {
); );
} }
final isMatch = matchIds.contains(element.id);
final highlight = isMatch
? (element.id == activeId
? SearchHighlight.active
: SearchHighlight.secondary)
: SearchHighlight.none;
if (isMatch) chronologicalMatchIndex[element.id] = messages.length;
messages.add( messages.add(
ChatBubble( ChatBubble(
context: context, context: context,
isSender: isSender:
element.actorId == widget.selfId && element.actorId == widget.selfId &&
element.messageType == GetRoomResponseObjectMessageType.comment, (element.messageType ==
GetRoomResponseObjectMessageType.comment ||
element.messageType ==
GetRoomResponseObjectMessageType.deletedComment),
bubbleData: element, bubbleData: element,
chatData: widget.room, chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(), refetch: ({bool renew = false}) => _refresh(),
isRead: element.id <= commonRead, isRead: element.id <= commonRead,
selfId: widget.selfId, selfId: widget.selfId,
highlightQuery: highlightQuery,
matchHighlight: highlight,
), ),
); );
} }
@@ -94,32 +304,60 @@ class _ChatViewState extends State<ChatView> {
refetch: ({bool renew = false}) => _refresh(), refetch: ({bool renew = false}) => _refresh(),
), ),
); );
chronologicalMatchIndex.updateAll((_, v) => v + 1);
} }
final total = messages.length;
_matchIndices
..clear()
..addEntries(
chronologicalMatchIndex.entries.map(
(e) => MapEntry(e.key, (total - 1) - e.value),
),
);
return messages; return messages;
} }
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
backgroundColor: const Color(0xffefeae2), backgroundColor: const Color(0xffefeae2),
appBar: ClickableAppBar( appBar: _searchActive
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), ? ChatSearchAppBar(
appBar: AppBar( controller: _searchTextController,
title: Row( matchCount: _matches.length,
children: [ activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
widget.avatar, onChanged: _onSearchChanged,
const SizedBox(width: 10), onClose: _exitSearchMode,
Expanded( onPrevious: _matches.isEmpty ? null : _goToPreviousMatch,
child: Text( onNext: _matches.isEmpty ? null : _goToNextMatch,
widget.room.displayName, )
overflow: TextOverflow.ellipsis, : ClickableAppBar(
maxLines: 1, onTap: () =>
TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
appBar: AppBar(
title: Row(
children: [
widget.avatar,
const SizedBox(width: 10),
Expanded(
child: Text(
widget.room.displayName,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
), ),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'In Chat suchen',
onPressed: _enterSearchMode,
),
],
), ),
], ),
),
),
),
body: DecoratedBox( body: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( image: DecorationImage(
@@ -137,11 +375,16 @@ class _ChatViewState extends State<ChatView> {
isReady: (state) => isReady: (state) =>
state.chatResponse != null && state.chatResponse != null &&
state.currentToken == widget.room.token, state.currentToken == widget.room.token,
child: (state, _) => ListView( child: (state, _) {
reverse: true, final items =
controller: _listController, _buildMessages(state.chatResponse!).reversed.toList();
children: _buildMessages(state.chatResponse!).reversed.toList(), return ScrollablePositionedList.builder(
), reverse: true,
itemScrollController: _itemScrollController,
itemCount: items.length,
itemBuilder: (ctx, idx) => items[idx],
);
},
), ),
), ),
ColoredBox( ColoredBox(
+8 -3
View File
@@ -1,12 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.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/account_data.dart'; import '../../../../model/account_data.dart';
import '../../../../model/endpoint_data.dart'; import '../../../../model/endpoint_data.dart';
import '../../../../utils/url_opener.dart'; import '../../../../utils/url_opener.dart';
import '../widgets/highlighted_linkify.dart';
class ChatMessage { class ChatMessage {
String originalMessage; String originalMessage;
@@ -27,8 +27,13 @@ class ChatMessage {
); );
} }
Widget getWidget() { Widget getWidget({String? highlightQuery, TextStyle? style}) {
var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen); var contentWidget = HighlightedLinkify(
text: content,
onOpen: UrlOpener.onOpen,
highlight: highlightQuery,
style: style,
);
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
return ListTile( return ListTile(
@@ -0,0 +1,50 @@
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 {
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 (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue;
if (element.systemMessage.contains('message_deleted')) 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;
}
}
+15 -1
View File
@@ -27,6 +27,7 @@ class BubbleStyle {
const BubbleStyle({ const BubbleStyle({
this.color, this.color,
this.borderWidth = 0, this.borderWidth = 0,
this.borderColor,
this.elevation = 0, this.elevation = 0,
this.margin = const BubbleEdges.only(), this.margin = const BubbleEdges.only(),
this.padding = const BubbleEdges.all(8), this.padding = const BubbleEdges.all(8),
@@ -37,12 +38,25 @@ class BubbleStyle {
final Color? color; final Color? color;
final double borderWidth; final double borderWidth;
final Color? borderColor;
final double elevation; final double elevation;
final BubbleEdges margin; final BubbleEdges margin;
final BubbleEdges padding; final BubbleEdges padding;
final Alignment alignment; final Alignment alignment;
final BubbleNip nip; final BubbleNip nip;
final double borderRadius; final double borderRadius;
BubbleStyle copyWith({double? borderWidth, Color? borderColor}) => BubbleStyle(
color: color,
borderWidth: borderWidth ?? this.borderWidth,
borderColor: borderColor ?? this.borderColor,
elevation: elevation,
margin: margin,
padding: padding,
alignment: alignment,
nip: nip,
borderRadius: borderRadius,
);
} }
/// The "nip" is faked by flattening one corner so the bubble anchors to /// The "nip" is faked by flattening one corner so the bubble anchors to
@@ -88,7 +102,7 @@ class Bubble extends StatelessWidget {
borderRadius: radius, borderRadius: radius,
border: style.borderWidth > 0 border: style.borderWidth > 0
? Border.all( ? Border.all(
color: Theme.of(context).dividerColor, color: style.borderColor ?? Theme.of(context).dividerColor,
width: style.borderWidth, width: style.borderWidth,
) )
: null, : null,
+93 -18
View File
@@ -18,6 +18,9 @@ import 'bubble.dart';
import 'chat_bubble_poll.dart'; import 'chat_bubble_poll.dart';
import 'chat_bubble_reactions.dart'; import 'chat_bubble_reactions.dart';
import 'chat_message_options_dialog.dart'; import 'chat_message_options_dialog.dart';
import 'highlighted_linkify.dart';
enum SearchHighlight { none, secondary, active }
class ChatBubble extends StatefulWidget { class ChatBubble extends StatefulWidget {
final BuildContext context; final BuildContext context;
@@ -33,6 +36,9 @@ class ChatBubble extends StatefulWidget {
final void Function({bool renew}) refetch; final void Function({bool renew}) refetch;
final String? highlightQuery;
final SearchHighlight matchHighlight;
const ChatBubble({ const ChatBubble({
required this.context, required this.context,
required this.isSender, required this.isSender,
@@ -41,6 +47,8 @@ class ChatBubble extends StatefulWidget {
required this.refetch, required this.refetch,
this.isRead = false, this.isRead = false,
this.selfId, this.selfId,
this.highlightQuery,
this.matchHighlight = SearchHighlight.none,
super.key, super.key,
}); });
@@ -140,15 +148,53 @@ class _ChatBubbleState extends State<ChatBubble>
).asDialog(context); ).asDialog(context);
} }
bool get _rendersAsCommentBubble =>
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment ||
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.deletedComment;
TextStyle? _messageTextStyle(BuildContext context) {
final theme = Theme.of(context);
switch (widget.bubbleData.messageType) {
case GetRoomResponseObjectMessageType.system:
return theme.textTheme.bodySmall;
case GetRoomResponseObjectMessageType.deletedComment:
return theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor,
fontStyle: FontStyle.italic,
);
case GetRoomResponseObjectMessageType.comment:
case GetRoomResponseObjectMessageType.voiceMessage:
case GetRoomResponseObjectMessageType.command:
return null;
}
}
BubbleStyle _getStyle() { BubbleStyle _getStyle() {
final styles = ChatBubbleStyles(context); final styles = ChatBubbleStyles(context);
if (widget.bubbleData.messageType != final BubbleStyle base;
GetRoomResponseObjectMessageType.comment) { if (!_rendersAsCommentBubble) {
return styles.getSystemStyle(); base = styles.getSystemStyle();
} else {
base = widget.isSender
? styles.getSelfStyle(false)
: styles.getRemoteStyle(false);
}
switch (widget.matchHighlight) {
case SearchHighlight.none:
return base;
case SearchHighlight.secondary:
return base.copyWith(
borderWidth: 1.5,
borderColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.45),
);
case SearchHighlight.active:
return base.copyWith(
borderWidth: 3,
borderColor: Theme.of(context).colorScheme.primary,
);
} }
return widget.isSender
? styles.getSelfStyle(false)
: styles.getRemoteStyle(false);
} }
void _showOptionsDialog() => showChatMessageOptionsDialog( void _showOptionsDialog() => showChatMessageOptionsDialog(
@@ -159,6 +205,18 @@ class _ChatBubbleState extends State<ChatBubble>
onRefetch: widget.refetch, onRefetch: widget.refetch,
); );
/// True only for messages whose body has a meaningful tap action (poll
/// dialog or file download/cancel). For plain text messages we leave
/// `onTap: null` on the bubble's `GestureDetector` so its
/// `TapGestureRecognizer` does not enter the gesture arena — otherwise
/// it competes with (and blocks) the per-link `TapGestureRecognizer`s
/// that `HighlightedLinkify` attaches to URL spans.
bool get _hasTapAction {
final obj = message.originalData?['object'];
if (obj?.type == RichObjectStringObjectType.talkPoll) return true;
return message.file != null;
}
void _onTap() { void _onTap() {
final obj = message.originalData?['object']; final obj = message.originalData?['object'];
if (obj?.type == RichObjectStringObjectType.talkPoll) { if (obj?.type == RichObjectStringObjectType.talkPoll) {
@@ -186,25 +244,36 @@ class _ChatBubbleState extends State<ChatBubble>
originalData: widget.bubbleData.messageParameters, originalData: widget.bubbleData.messageParameters,
); );
final showActorDisplayName = final showActorDisplayName =
widget.bubbleData.messageType == _rendersAsCommentBubble &&
GetRoomResponseObjectMessageType.comment &&
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
final showBubbleTime = final showBubbleTime =
widget.bubbleData.messageType != widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.system && GetRoomResponseObjectMessageType.system;
widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.deletedComment;
final parent = widget.bubbleData.parent; final parent = widget.bubbleData.parent;
final actorBaseStyle = TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
);
final actorText = Text( final actorText = Text(
widget.bubbleData.actorDisplayName, widget.bubbleData.actorDisplayName,
textAlign: TextAlign.start, textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: actorBaseStyle,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
); );
final actorWidget = (widget.highlightQuery?.trim().isNotEmpty ?? false)
? Text.rich(
TextSpan(
children: buildHighlightedSpans(
text: widget.bubbleData.actorDisplayName,
query: widget.highlightQuery,
baseStyle: actorBaseStyle,
),
),
textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis,
)
: actorText;
final timeText = Text( final timeText = Text(
DateTime.fromMillisecondsSinceEpoch( DateTime.fromMillisecondsSinceEpoch(
@@ -245,15 +314,19 @@ class _ChatBubbleState extends State<ChatBubble>
}, },
onLongPress: _showOptionsDialog, onLongPress: _showOptionsDialog,
onDoubleTap: _showOptionsDialog, onDoubleTap: _showOptionsDialog,
onTap: _onTap, onTap: _hasTapAction ? _onTap : null,
child: Transform.translate( child: Transform.translate(
offset: _position, offset: _position,
child: Bubble( child: Bubble(
style: _getStyle(), style: _getStyle(),
child: _BubbleContent( child: _BubbleContent(
actorText: actorText, actorText: actorText,
actorWidget: actorWidget,
timeText: timeText, timeText: timeText,
messageWidget: message.getWidget(), messageWidget: message.getWidget(
highlightQuery: widget.highlightQuery,
style: _messageTextStyle(context),
),
parent: parent, parent: parent,
bubbleData: widget.bubbleData, bubbleData: widget.bubbleData,
isSender: widget.isSender, isSender: widget.isSender,
@@ -282,6 +355,7 @@ class _ChatBubbleState extends State<ChatBubble>
class _BubbleContent extends StatelessWidget { class _BubbleContent extends StatelessWidget {
final Text actorText; final Text actorText;
final Widget actorWidget;
final Text timeText; final Text timeText;
final Widget messageWidget; final Widget messageWidget;
final GetChatResponseObject? parent; final GetChatResponseObject? parent;
@@ -298,6 +372,7 @@ class _BubbleContent extends StatelessWidget {
const _BubbleContent({ const _BubbleContent({
required this.actorText, required this.actorText,
required this.actorWidget,
required this.timeText, required this.timeText,
required this.messageWidget, required this.messageWidget,
required this.parent, required this.parent,
@@ -323,7 +398,7 @@ class _BubbleContent extends StatelessWidget {
), ),
child: Stack( child: Stack(
children: [ children: [
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText), if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget),
Padding( Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: showBubbleTime ? 18 : 0, bottom: showBubbleTime ? 18 : 0,
@@ -30,9 +30,6 @@ RichObjectString? _attachedFile(GetChatResponseObject bubbleData) {
return file; return file;
} }
/// Long-press / double-tap options dialog for a single chat message bubble.
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
/// this file owns the modal interactions (react, reply, copy, delete, ...).
void showChatMessageOptionsDialog( void showChatMessageOptionsDialog(
BuildContext context, { BuildContext context, {
required GetRoomResponseObject chatData, required GetRoomResponseObject chatData,
@@ -140,12 +137,22 @@ void showChatMessageOptionsDialog(
}, },
), ),
if (canDelete) if (canDelete)
AsyncListTile( ListTile(
leading: const Icon(Icons.delete_outline), leading: const Icon(Icons.delete_outline),
title: const Text('Nachricht löschen'), title: const Text('Nachricht löschen'),
onPressed: () async { onTap: () {
await DeleteMessage(chatData.token, bubbleData.id).run(); ConfirmDialog(
if (sheetCtx.mounted) sheetCtx.read<ChatBloc>().refresh(); title: 'Nachricht löschen?',
content: 'Die Nachricht wird für alle Teilnehmer gelöscht.',
confirmButton: 'Löschen',
onConfirmAsync: () async {
await DeleteMessage(chatData.token, bubbleData.id).run();
if (!sheetCtx.mounted) return;
final bloc = sheetCtx.read<ChatBloc>();
Navigator.of(sheetCtx).pop();
bloc.refresh();
},
).asDialog(sheetCtx);
}, },
), ),
DebugTile(sheetCtx).jsonData(bubbleData.toJson()), DebugTile(sheetCtx).jsonData(bubbleData.toJson()),
@@ -173,10 +180,12 @@ void _openOrCreateDirectChat(
} }
void switchToChat(GetRoomResponseObject room) { void switchToChat(GetRoomResponseObject room) {
// Pop the current ChatView before swapping the global ChatBloc token — // Pop the previous ChatView first — otherwise it stays in the
// otherwise the previous group chat stays mounted in the back-stack and // back-stack with a now-mismatched currentToken and renders empty
// would render empty after a back-swipe (currentToken no longer matches). // on back-swipe. Stop at popups so an open dialog stays alive.
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(
context,
).popUntil((route) => route.isFirst || route is PopupRoute);
AppRoutes.openChatByToken(context, room.token); AppRoutes.openChatByToken(context, room.token);
} }
@@ -0,0 +1,67 @@
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(
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(
icon: const Icon(Icons.keyboard_arrow_up),
onPressed: onPrevious,
),
IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: onNext,
),
],
);
}
}
+13 -16
View File
@@ -7,9 +7,10 @@ import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../../model/account_data.dart'; import '../../../../model/account_data.dart';
import '../../../../notification/notification_tasks.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart'; import '../../../../widget/async_action_button.dart';
@@ -17,7 +18,6 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart'; import '../../../../widget/user_avatar.dart';
import '../chat_view.dart';
import '../talk_navigator.dart'; import '../talk_navigator.dart';
class ChatTile extends StatefulWidget { class ChatTile extends StatefulWidget {
@@ -61,13 +61,11 @@ class _ChatTileState extends State<ChatTile> {
void _refreshList() => context.read<ChatListBloc>().refresh(); void _refreshList() => context.read<ChatListBloc>().refresh();
Future<void> _setCurrentAsRead() async { Future<void> _setCurrentAsRead() async {
await SetReadMarker( final token = widget.data.token;
widget.data.token, final lastId = widget.data.lastMessage.id;
true, context.read<ChatListBloc>().markRoomAsRead(token, lastId);
setReadMarkerParams: SetReadMarkerParams( unawaited(NotificationTasks.clearNotificationsForChat(token));
lastReadMessage: widget.data.lastMessage.id, await context.read<ChatBloc>().sendServerReadMarker(token, lastId);
),
).run();
if (!mounted) return; if (!mounted) return;
_refreshList(); _refreshList();
} }
@@ -154,18 +152,17 @@ class _ChatTileState extends State<ChatTile> {
return; return;
} }
if (selfUsername == null) return; if (selfUsername == null) return;
unawaited(_setCurrentAsRead()); // openChatView is the single entry point for opening a chat —
final view = ChatView( // it handles optimistic mark-as-read, tray cleanup, push, and
// setToken in one place so the notification-tap path gets the
// same treatment as a tile tap.
AppRoutes.openChatView(
context,
room: widget.data, room: widget.data,
selfId: selfUsername!, selfId: selfUsername!,
avatar: circleAvatar, avatar: circleAvatar,
);
TalkNavigator.pushSplitView(
context,
view,
overrideToSingleSubScreen: true, overrideToSingleSubScreen: true,
); );
context.read<ChatBloc>().setToken(widget.data.token);
}, },
onLongPress: () { onLongPress: () {
if (widget.disableContextActions) return; if (widget.disableContextActions) return;
@@ -0,0 +1,172 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:linkify/linkify.dart' as linkify_pkg;
const TextStyle kSearchHighlightStyle = TextStyle(
backgroundColor: Color(0xFFFFD54F),
color: Colors.black,
);
List<TextSpan> buildHighlightedSpans({
required String text,
required String? query,
required TextStyle baseStyle,
TextStyle highlightStyle = kSearchHighlightStyle,
GestureRecognizer? recognizer,
}) {
final q = query?.trim().toLowerCase();
if (q == null || q.isEmpty) {
return [TextSpan(text: text, style: baseStyle, recognizer: recognizer)];
}
final spans = <TextSpan>[];
final lower = text.toLowerCase();
var cursor = 0;
while (cursor < text.length) {
final hit = lower.indexOf(q, cursor);
if (hit < 0) {
spans.add(
TextSpan(
text: text.substring(cursor),
style: baseStyle,
recognizer: recognizer,
),
);
break;
}
if (hit > cursor) {
spans.add(
TextSpan(
text: text.substring(cursor, hit),
style: baseStyle,
recognizer: recognizer,
),
);
}
final end = hit + q.length;
spans.add(
TextSpan(
text: text.substring(hit, end),
style: baseStyle.merge(highlightStyle),
recognizer: recognizer,
),
);
cursor = end;
}
return spans;
}
class HighlightedLinkify extends StatefulWidget {
final String text;
final String? highlight;
final LinkCallback? onOpen;
final TextStyle? style;
final TextStyle? linkStyle;
const HighlightedLinkify({
required this.text,
this.highlight,
this.onOpen,
this.style,
this.linkStyle,
super.key,
});
@override
State<HighlightedLinkify> createState() => _HighlightedLinkifyState();
}
class _HighlightedLinkifyState extends State<HighlightedLinkify> {
// Cached per link text — search rebuilds keystroke-by-keystroke
// would otherwise churn allocate/dispose. Pruned via [_seenLinkKeys].
final Map<String, TapGestureRecognizer> _recognizers = {};
final Set<String> _seenLinkKeys = {};
@override
void dispose() {
for (final r in _recognizers.values) {
r.dispose();
}
_recognizers.clear();
super.dispose();
}
TapGestureRecognizer _recognizerFor(LinkableElement el) {
final key = el.text;
final existing = _recognizers[key];
if (existing != null) {
// Refresh onTap so a parent rebuild's new closure is picked up.
existing.onTap = () => widget.onOpen?.call(el);
return existing;
}
final created = TapGestureRecognizer()
..onTap = () => widget.onOpen?.call(el);
_recognizers[key] = created;
return created;
}
void _pruneUnseen() {
final stale = _recognizers.keys
.where((k) => !_seenLinkKeys.contains(k))
.toList(growable: false);
for (final k in stale) {
_recognizers.remove(k)?.dispose();
}
}
@override
Widget build(BuildContext context) {
_seenLinkKeys.clear();
final defaultStyle = widget.style ??
Theme.of(context).textTheme.bodyMedium ??
DefaultTextStyle.of(context).style;
// Default first, link style on top — reversing the merge silently
// drops link color/underline because TextStyle.merge treats explicit
// nulls in the overlay as "leave unchanged".
final linkStyle = defaultStyle.merge(
widget.linkStyle ??
const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
),
);
const linkHighlight = TextStyle(
backgroundColor: Color(0xFFFFD54F),
color: Colors.black,
decoration: TextDecoration.underline,
);
final elements = linkify_pkg.linkify(widget.text);
final spans = <InlineSpan>[];
for (final el in elements) {
if (el is LinkableElement) {
_seenLinkKeys.add(el.text);
final recognizer = _recognizerFor(el);
spans.addAll(
buildHighlightedSpans(
text: el.text,
query: widget.highlight,
baseStyle: linkStyle,
highlightStyle: linkHighlight,
recognizer: recognizer,
),
);
} else {
spans.addAll(
buildHighlightedSpans(
text: el.text,
query: widget.highlight,
baseStyle: defaultStyle,
),
);
}
}
_pruneUnseen();
return Text.rich(TextSpan(children: spans));
}
}
@@ -19,6 +19,7 @@ class CustomEventEditDialog extends StatefulWidget {
final DateTime? initialEnd; final DateTime? initialEnd;
final String? initialTitle; final String? initialTitle;
final String? initialDescription; final String? initialDescription;
final bool? initialAllDay;
const CustomEventEditDialog({ const CustomEventEditDialog({
this.existingEvent, this.existingEvent,
@@ -26,6 +27,7 @@ class CustomEventEditDialog extends StatefulWidget {
this.initialEnd, this.initialEnd,
this.initialTitle, this.initialTitle,
this.initialDescription, this.initialDescription,
this.initialAllDay,
super.key, super.key,
}); });
@@ -78,12 +80,17 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
} }
return; return;
} }
_isAllDay = false; _isAllDay = widget.initialAllDay ?? false;
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; if (_isAllDay) {
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; _startTime = _defaultStart;
final clamped = _clampToVisibleWindow(rawStart, rawEnd); _endTime = _defaultEnd;
_startTime = clamped.$1; } else {
_endTime = clamped.$2; final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd;
final clamped = _clampToVisibleWindow(rawStart, rawEnd);
_startTime = clamped.$1;
_endTime = clamped.$2;
}
} }
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow( static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart';
@@ -11,7 +10,6 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/unimplemented_dialog.dart';
class WebuntisLessonSheet { class WebuntisLessonSheet {
static void show( static void show(
@@ -72,7 +70,7 @@ class WebuntisLessonSheet {
}).toList(), }).toList(),
), ),
_roomTile(context, state, lesson), _roomTile(context, state, lesson),
_teacherTile(context, lesson), _teacherTile(lesson),
if ((lesson.activityType ?? '').trim().isNotEmpty) if ((lesson.activityType ?? '').trim().isNotEmpty)
ListTile( ListTile(
leading: const Icon(Icons.abc), leading: const Icon(Icons.abc),
@@ -120,14 +118,15 @@ class WebuntisLessonSheet {
final name = firstNonEmpty([resolved.name, r.name, '?']); final name = firstNonEmpty([resolved.name, r.name, '?']);
final longname = firstNonEmpty([resolved.longName, r.longname, '']); final longname = firstNonEmpty([resolved.longName, r.longname, '']);
final building = resolved.building.trim(); final building = resolved.building.trim();
return LessonFormatter.formatLine( final main = LessonFormatter.formatLine(
name, name,
longname: longname,
extra: (building.isNotEmpty && building != '?') ? building : null, extra: (building.isNotEmpty && building != '?') ? building : null,
); );
final sub = (longname.isNotEmpty && longname != name) ? longname : null;
return (main: main, sub: sub);
}).toList(); }).toList();
return _listTile( return _listTileWithSubs(
icon: Icons.room, icon: Icons.room,
label: lesson.ro.length == 1 ? 'Raum' : 'Räume', label: lesson.ro.length == 1 ? 'Raum' : 'Räume',
entries: entries, entries: entries,
@@ -135,39 +134,63 @@ class WebuntisLessonSheet {
); );
} }
static Widget _teacherTile( static Widget _teacherTile(GetTimetableResponseObject lesson) {
BuildContext context,
GetTimetableResponseObject lesson,
) {
final trailing = Visibility(
visible: !kReleaseMode,
child: IconButton(
icon: const Icon(Icons.textsms_outlined),
onPressed: () => UnimplementedDialog.show(context),
),
);
if (lesson.te.isEmpty) { if (lesson.te.isEmpty) {
return ListTile( return const ListTile(
leading: const Icon(Icons.person), leading: Icon(Icons.person),
title: const Text('Lehrkraft: ?'), title: Text('Lehrkraft: ?'),
trailing: trailing,
); );
} }
final entries = lesson.te.map((t) { final entries = lesson.te.map((t) {
final base = LessonFormatter.formatLine( final main = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?', t.name.isNotEmpty ? t.name : '?',
longname: t.longname, longname: t.longname,
); );
final orgname = (t.orgname ?? '').trim(); final orgname = (t.orgname ?? '').trim();
return orgname.isEmpty ? base : '$base · ehemals $orgname'; return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname');
}).toList(); }).toList();
return _listTile( return _listTileWithSubs(
icon: Icons.person, icon: Icons.person,
label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
entries: entries, entries: entries,
);
}
static Widget _listTileWithSubs({
required IconData icon,
required String label,
required List<({String main, String? sub})> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
final e = entries.first;
return ListTile(
leading: Icon(icon),
title: Text('$label: ${e.main}'),
subtitle: e.sub != null ? Text(e.sub!) : null,
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries
.expand<Widget>(
(e) => [
Text(e.main),
if (e.sub != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(e.sub!),
),
],
)
.toList(),
),
trailing: trailing, trailing: trailing,
); );
} }
@@ -37,40 +37,10 @@ class AppointmentTile extends StatelessWidget {
borderRadius: _radius, borderRadius: _radius,
color: color, color: color,
), ),
child: Column( child: _TileContent(
crossAxisAlignment: CrossAxisAlignment.stretch, title: appointment.subject,
mainAxisSize: MainAxisSize.max, description: description,
children: [ isCustom: isCustom,
_AdaptiveTitle(
text: appointment.subject,
fontSize: kAppointmentTitleFontSize,
minFontSize: kAppointmentTitleMinFontSize,
fontWeight: FontWeight.w500,
),
if (isCustom) ...[
if (description.isNotEmpty)
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: _WrappingBody(
text: description,
fontSize: kAppointmentBodyFontSize,
lineHeight: kAppointmentBodyLineHeight,
),
),
),
] else ...[
for (final line
in description
.split('\n')
.where((p) => p.isNotEmpty)
.take(2))
_ScaledLine(
text: line,
fontSize: kAppointmentBodyFontSize,
),
],
],
), ),
), ),
), ),
@@ -96,6 +66,91 @@ class AppointmentTile extends StatelessWidget {
} }
} }
/// Picks how many lines fit into the calendar slot's height. Title gets
/// first dibs; if not even one minimum-size title line fits, the column
/// collapses to keep the slot from overflowing.
class _TileContent extends StatelessWidget {
final String title;
final String description;
final bool isCustom;
const _TileContent({
required this.title,
required this.description,
required this.isCustom,
});
@override
Widget build(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final titleLineHeight = scaler.scale(kAppointmentTitleMinFontSize) * 1.1;
final bodyLineHeight = scaler.scale(kAppointmentBodyFontSize) * 1.1;
final titleWidget = _AdaptiveTitle(
text: title,
fontSize: kAppointmentTitleFontSize,
minFontSize: kAppointmentTitleMinFontSize,
fontWeight: FontWeight.w500,
);
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxHeight;
// Slot too short for even one min-size title line — drop text
// entirely; the coloured rectangle is enough.
if (available < titleLineHeight) return const SizedBox.shrink();
final remaining =
(available - titleLineHeight).clamp(0.0, double.infinity);
final bodyLineCapacity = (remaining / bodyLineHeight).floor();
if (isCustom) {
if (description.isEmpty || bodyLineCapacity <= 0) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [titleWidget],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
titleWidget,
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: _WrappingBody(
text: description,
fontSize: kAppointmentBodyFontSize,
lineHeight: kAppointmentBodyLineHeight,
),
),
),
],
);
}
final maxBodyLines = bodyLineCapacity.clamp(0, 2);
final lines = description
.split('\n')
.where((p) => p.isNotEmpty)
.take(maxBodyLines)
.toList(growable: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
titleWidget,
for (final line in lines)
_ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
],
);
},
);
}
}
/// Renders the appointment title. Scales down to fit the available width via /// Renders the appointment title. Scales down to fit the available width via
/// [FittedBox], but never below [minFontSize] — when even the minimum size /// [FittedBox], but never below [minFontSize] — when even the minimum size
/// overflows, the text is rendered at [minFontSize] with an ellipsis. /// overflows, the text is rendered at [minFontSize] with an ellipsis.
+702 -159
View File
@@ -1,29 +1,33 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:chewie/chewie.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.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: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 'package:share_plus/share_plus.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:video_player/video_player.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';
import '../state/app/modules/settings/bloc/settings_cubit.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart';
import 'app_progress_indicator.dart';
import 'centered_leading.dart';
import 'info_dialog.dart'; import 'info_dialog.dart';
import 'placeholder_view.dart';
import 'share_position_origin.dart'; import 'share_position_origin.dart';
class FileViewer extends StatefulWidget { class FileViewer extends StatefulWidget {
final String path; final String path;
final bool openExternal; final bool openExternal;
/// When set, enables the in-app actions "An Chat senden" and "In Dateien /// Enables in-app "An Chat senden" / "In Dateien speichern" — these
/// speichern" — these need a server-side reference, not the local cache /// need a server-side reference instead of the local cache path.
/// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer).
final RemoteFileRef? remoteFile; final RemoteFileRef? remoteFile;
const FileViewer({ const FileViewer({
@@ -39,10 +43,82 @@ class FileViewer extends StatefulWidget {
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal enum _FileKind { image, svg, pdf, text, video, audio, unknown }
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
/// ancestor RenderTransform (from the page-push animation) is still mid-layout. const Set<String> _imageExtensions = {
/// We wait for the route's enter animation to complete before mounting it. 'png',
'jpg',
'jpeg',
'webp',
'gif',
'bmp',
'wbmp',
};
const Set<String> _videoExtensions = {
'mp4',
'm4v',
'mov',
'webm',
'mkv',
'3gp',
};
/// ogg/opus/flac are Android-only; iOS init errors fall through to the
/// "format not supported" message.
const Set<String> _audioExtensions = {
'mp3',
'm4a',
'aac',
'wav',
'flac',
'ogg',
'oga',
'opus',
};
/// Unknown extensions still get a content sniff via [_looksLikeText].
const Set<String> _textExtensions = {
'txt', 'md', 'markdown', 'rst', 'log',
'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
'csv', 'tsv', 'tab',
'ini', 'conf', 'cfg', 'env', 'properties',
'html', 'htm', 'xhtml',
'css', 'scss', 'sass', 'less',
'js', 'mjs', 'cjs', 'ts', 'jsx', 'tsx',
'dart', 'java', 'kt', 'kts', 'groovy', 'scala', 'swift',
'py', 'rb', 'pl', 'lua', 'r',
'go', 'rs', 'zig',
'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'cs', 'm', 'mm',
'php', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
'sql', 'graphql', 'gql',
'gitignore', 'gitattributes', 'editorconfig', 'dockerignore',
'dockerfile', 'makefile', 'cmake',
'tex', 'bib',
'srt', 'vtt',
};
/// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
Future<bool> _looksLikeText(String path) async {
final file = File(path);
RandomAccessFile? raf;
try {
final length = await file.length();
if (length == 0) return true;
raf = await file.open();
final sample = await raf.read(min(length, 8192));
if (sample.contains(0)) return false;
utf8.decode(sample);
return true;
} on Object {
return false;
} finally {
await raf?.close();
}
}
/// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
/// animation. Defer until the route enter animation completes.
class _DeferredPdfViewer extends StatefulWidget { class _DeferredPdfViewer extends StatefulWidget {
const _DeferredPdfViewer({required this.path}); const _DeferredPdfViewer({required this.path});
final String path; final String path;
@@ -82,7 +158,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_ready) { if (!_ready) {
return const Center(child: CircularProgressIndicator()); return const Center(child: AppProgressIndicator.large());
} }
return SfPdfViewer.file(File(widget.path)); return SfPdfViewer.file(File(widget.path));
} }
@@ -93,13 +169,30 @@ class _FileViewerState extends State<FileViewer> {
late SettingsCubit settings = context.read<SettingsCubit>(); late SettingsCubit settings = context.read<SettingsCubit>();
late bool openExternal; late bool openExternal;
Future<_FileKind>? _fileKind;
@override @override
void initState() { void initState() {
super.initState();
openExternal = openExternal =
settings.val().fileViewSettings.alwaysOpenExternally || settings.val().fileViewSettings.alwaysOpenExternally ||
widget.openExternal; widget.openExternal;
super.initState(); if (openExternal) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _openExternallyAndPop(),
);
} else {
_fileKind = _detectKind();
}
}
Future<void> _openExternallyAndPop() async {
final result = await OpenFilex.open(widget.path);
if (!mounted) return;
Navigator.of(context).pop();
if (result.type != ResultType.done) {
InfoDialog.show(context, result.message);
}
} }
@override @override
@@ -108,167 +201,617 @@ class _FileViewerState extends State<FileViewer> {
super.dispose(); super.dispose();
} }
Future<_FileKind> _detectKind() async {
final ext = widget.path.split('.').last.toLowerCase();
if (_imageExtensions.contains(ext)) return _FileKind.image;
if (ext == 'svg') return _FileKind.svg;
if (ext == 'pdf') return _FileKind.pdf;
if (_videoExtensions.contains(ext)) return _FileKind.video;
if (_audioExtensions.contains(ext)) return _FileKind.audio;
if (_textExtensions.contains(ext)) return _FileKind.text;
if (await _looksLikeText(widget.path)) return _FileKind.text;
return _FileKind.unknown;
}
Future<void> _handleAction(FileViewingActions value) async {
switch (value) {
case FileViewingActions.openExternal:
AppRoutes.openFileViewer(
context,
widget.path,
openExternal: true,
remoteFile: widget.remoteFile,
);
break;
case FileViewingActions.sendToChat:
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
break;
case FileViewingActions.saveToCloud:
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
break;
case FileViewingActions.share:
unawaited(
SharePlus.instance.share(
ShareParams(
files: [XFile(widget.path)],
sharePositionOrigin: SharePositionOrigin.get(context),
),
),
);
break;
case FileViewingActions.save:
try {
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: widget.path.split('/').last,
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;
}
}
List<_ActionDescriptor> _availableActions() => [
_ActionDescriptor(
action: FileViewingActions.openExternal,
icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
),
if (widget.remoteFile != null) ...[
const _ActionDescriptor(
action: FileViewingActions.sendToChat,
icon: Icons.chat_bubble_outline,
label: 'An Talk-Chat senden',
),
const _ActionDescriptor(
action: FileViewingActions.saveToCloud,
icon: Icons.cloud_outlined,
label: 'In Cloud speichern',
),
],
const _ActionDescriptor(
action: FileViewingActions.share,
icon: Icons.share_outlined,
label: 'Teilen',
),
const _ActionDescriptor(
action: FileViewingActions.save,
icon: Icons.save_alt_outlined,
label: 'Speichern',
),
];
AppBar _appbar({
List<Widget> actions = const [],
bool showActionsMenu = true,
}) => AppBar(
title: Text(widget.path.split('/').last),
actions: [
...actions,
if (showActionsMenu)
PopupMenuButton<FileViewingActions>(
onSelected: _handleAction,
itemBuilder: (context) => _availableActions()
.map(
(a) => PopupMenuItem(
value: a.action,
child: ListTile(
leading: Icon(a.icon),
title: Text(a.label),
dense: true,
),
),
)
.toList(),
),
],
);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
AppBar appbar({List<Widget> actions = const []}) => AppBar( if (openExternal) {
title: Text(widget.path.split('/').last), return Scaffold(
appBar: AppBar(title: Text(widget.path.split('/').last)),
body: const Center(child: AppProgressIndicator.large()),
);
}
return FutureBuilder<_FileKind>(
future: _fileKind,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Scaffold(
appBar: _appbar(),
body: const Center(child: AppProgressIndicator.large()),
);
}
switch (snapshot.data!) {
case _FileKind.image:
return _buildImageView();
case _FileKind.svg:
return _buildSvgView();
case _FileKind.pdf:
return _buildPdfView();
case _FileKind.video:
return _buildVideoView();
case _FileKind.audio:
return _buildAudioView();
case _FileKind.text:
return _buildTextView();
case _FileKind.unknown:
return _buildUnknownView();
}
},
);
}
Widget _buildImageView() => Scaffold(
appBar: _appbar(
actions: [ actions: [
...actions, IconButton(
PopupMenuButton<FileViewingActions>( onPressed: () {
onSelected: (value) async { setState(() {
switch (value) { photoViewController.rotation += pi / 2;
case FileViewingActions.openExternal: });
AppRoutes.openFileViewer(
context,
widget.path,
openExternal: true,
remoteFile: widget.remoteFile,
);
break;
case FileViewingActions.sendToChat:
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
break;
case FileViewingActions.saveToCloud:
AppRoutes.openInternalSaveToFolder(
context,
widget.remoteFile!,
);
break;
case FileViewingActions.share:
unawaited(
SharePlus.instance.share(
ShareParams(
files: [XFile(widget.path)],
sharePositionOrigin: SharePositionOrigin.get(context),
),
),
);
break;
case FileViewingActions.save:
try {
final bytes = await File(widget.path).readAsBytes();
final saved = await FilePicker.saveFile(
fileName: widget.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',
);
}
break;
}
}, },
itemBuilder: (context) => <PopupMenuEntry<FileViewingActions>>[ icon: const Icon(Icons.rotate_right),
const PopupMenuItem(
value: FileViewingActions.openExternal,
child: ListTile(
leading: Icon(Icons.open_in_new),
title: Text('Extern öffnen'),
dense: true,
),
),
if (widget.remoteFile != null) ...[
const PopupMenuItem(
value: FileViewingActions.sendToChat,
child: ListTile(
leading: Icon(Icons.chat_bubble_outline),
title: Text('An Talk-Chat senden'),
dense: true,
),
),
const PopupMenuItem(
value: FileViewingActions.saveToCloud,
child: ListTile(
leading: Icon(Icons.cloud_outlined),
title: Text('In Cloud speichern'),
dense: true,
),
),
],
const PopupMenuItem(
value: FileViewingActions.share,
child: ListTile(
leading: Icon(Icons.share_outlined),
title: Text('Teilen'),
dense: true,
),
),
const PopupMenuItem(
value: FileViewingActions.save,
child: ListTile(
leading: Icon(Icons.save_alt_outlined),
title: Text('Speichern'),
dense: true,
),
),
],
), ),
], ],
); ),
backgroundColor: Colors.white,
body: PhotoView(
controller: photoViewController,
maxScale: 3.0,
minScale: 0.1,
imageProvider: Image.file(File(widget.path)).image,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
),
);
switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) { Widget _buildSvgView() => Scaffold(
case 'png': appBar: _appbar(),
case 'jpg': backgroundColor: Colors.white,
case 'jpeg': body: InteractiveViewer(
case 'webp': minScale: 0.5,
case 'gif': maxScale: 8,
return Scaffold( child: Center(
appBar: appbar( child: SvgPicture.file(
actions: [ File(widget.path),
IconButton( placeholderBuilder: (_) =>
onPressed: () { const Center(child: AppProgressIndicator.large()),
setState(() { ),
photoViewController.rotation += pi / 2; ),
}); ),
}, );
icon: const Icon(Icons.rotate_right),
), Widget _buildPdfView() =>
], Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
),
backgroundColor: Colors.white, Widget _buildVideoView() => Scaffold(
body: PhotoView( appBar: _appbar(),
controller: photoViewController, backgroundColor: Colors.black,
maxScale: 3.0, body: _MediaPlayer(path: widget.path, isAudio: false),
minScale: 0.1, );
imageProvider: Image.file(File(widget.path)).image,
backgroundDecoration: BoxDecoration( Widget _buildAudioView() => Scaffold(
color: Theme.of(context).colorScheme.surface, appBar: _appbar(),
body: _MediaPlayer(
path: widget.path,
isAudio: true,
filename: widget.path.split('/').last,
),
);
Widget _buildTextView() => Scaffold(
appBar: _appbar(),
body: FutureBuilder<_TextPayload>(
future: _readTextPayload(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: AppProgressIndicator.large());
}
final payload = snapshot.data!;
final lines = const LineSplitter().convert(payload.content);
// Stable gutter width — sized by the highest line number's digit count.
final gutterWidth = (lines.length.toString().length * 9.0) + 16;
return SelectionArea(
child: Scrollbar(
child: CustomScrollView(
slivers: [
if (payload.truncated)
SliverToBoxAdapter(
child: SelectionContainer.disabled(
child: Container(
width: double.infinity,
color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'Datei ist groß — Anzeige auf die ersten ${(_textViewMaxBytes / 1024).round()} KB begrenzt.',
style: Theme.of(context).textTheme.bodySmall,
),
),
),
),
SliverList.builder(
itemCount: lines.length,
itemBuilder: (context, i) => _CodeLine(
number: i + 1,
text: lines[i],
gutterWidth: gutterWidth,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
), ),
), ),
); );
},
),
);
case 'pdf': Widget _buildUnknownView() {
return Scaffold( final theme = Theme.of(context);
appBar: appbar(), final descriptors = _availableActions();
body: _DeferredPdfViewer(path: widget.path), return Scaffold(
); appBar: _appbar(showActionsMenu: false),
body: ListView(
default: padding: const EdgeInsets.symmetric(vertical: 24),
OpenFilex.open(widget.path).then((result) { children: [
if (!context.mounted) return; Padding(
Navigator.of(context).pop(); padding: const EdgeInsets.symmetric(horizontal: 24),
if (result.type != ResultType.done) { child: Column(
InfoDialog.show(context, result.message); children: [
} const Icon(Icons.insert_drive_file_outlined, size: 60),
}); const SizedBox(height: 16),
Text(
return PlaceholderView( 'Vorschau nicht verfügbar',
text: 'Datei extern geöffnet', style: theme.textTheme.titleMedium,
icon: Icons.open_in_new, textAlign: TextAlign.center,
button: TextButton( ),
onPressed: () => Navigator.of(context).pop(), const SizedBox(height: 6),
child: const Text('Zurück'), Text(
widget.path.split('/').last,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Wähle eine Aktion, um mit der Datei weiterzuarbeiten.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
), ),
); const SizedBox(height: 24),
...descriptors.map(
(d) => ListTile(
leading: CenteredLeading(Icon(d.icon)),
title: Text(d.label),
onTap: () => _handleAction(d.action),
),
),
],
),
);
}
static const int _textViewMaxBytes = 5 * 1024 * 1024;
Future<_TextPayload> _readTextPayload() async {
final file = File(widget.path);
final size = await file.length();
final ext = widget.path.split('.').last.toLowerCase();
if (size <= _textViewMaxBytes) {
final raw = await file.readAsString();
return _TextPayload(content: _maybePrettify(raw, ext), truncated: false);
}
final raf = await file.open();
try {
final bytes = await raf.read(_textViewMaxBytes);
// Truncated payloads stay raw — a parser would choke on the dangling tail.
return _TextPayload(
content: utf8.decode(bytes, allowMalformed: true),
truncated: true,
);
} finally {
await raf.close();
}
}
/// Falls through to the original text on parse errors.
String _maybePrettify(String content, String ext) {
if (ext != 'json') return content;
try {
final parsed = jsonDecode(content);
return const JsonEncoder.withIndent(' ').convert(parsed);
} on Object {
return content;
} }
} }
} }
class _ActionDescriptor {
final FileViewingActions action;
final IconData icon;
final String label;
const _ActionDescriptor({
required this.action,
required this.icon,
required this.label,
});
}
class _TextPayload {
final String content;
final bool truncated;
const _TextPayload({required this.content, required this.truncated});
}
class _MediaPlayer extends StatefulWidget {
final String path;
final bool isAudio;
final String? filename;
const _MediaPlayer({
required this.path,
required this.isAudio,
this.filename,
});
@override
State<_MediaPlayer> createState() => _MediaPlayerState();
}
class _MediaPlayerState extends State<_MediaPlayer> {
VideoPlayerController? _video;
ChewieController? _chewie;
Object? _initError;
@override
void initState() {
super.initState();
_initialize();
}
Future<void> _initialize() async {
final controller = VideoPlayerController.file(File(widget.path));
try {
await controller.initialize();
} on Object catch (e) {
await controller.dispose();
if (!mounted) return;
setState(() => _initError = e);
return;
}
if (!mounted) {
await controller.dispose();
return;
}
if (widget.isAudio) {
controller.addListener(_onAudioTick);
setState(() => _video = controller);
} else {
setState(() {
_video = controller;
_chewie = ChewieController(
videoPlayerController: controller,
autoPlay: false,
looping: false,
allowFullScreen: true,
allowPlaybackSpeedChanging: true,
);
});
}
}
void _onAudioTick() {
if (mounted) setState(() {});
}
@override
void dispose() {
_video?.removeListener(_onAudioTick);
_chewie?.dispose();
_video?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_initError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48),
const SizedBox(height: 12),
Text(
widget.isAudio
? 'Audio kann nicht abgespielt werden'
: 'Video kann nicht abgespielt werden',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Format wird auf diesem Gerät nicht unterstützt. Über das Menü kannst du die Datei in einer anderen App öffnen.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
);
}
if (_video == null) {
return const Center(child: AppProgressIndicator.large());
}
if (widget.isAudio) {
return _AudioControls(
controller: _video!,
filename: widget.filename ?? '',
);
}
return Chewie(controller: _chewie!);
}
}
class _AudioControls extends StatelessWidget {
final VideoPlayerController controller;
final String filename;
const _AudioControls({required this.controller, required this.filename});
String _format(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
if (d.inHours > 0) return '${d.inHours}:$m:$s';
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final value = controller.value;
final duration = value.duration;
final position = value.position;
final maxMs = duration.inMilliseconds == 0 ? 1 : duration.inMilliseconds;
final posMs = position.inMilliseconds.clamp(0, maxMs).toDouble();
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.audiotrack,
size: 96,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
filename,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Slider(
min: 0,
max: maxMs.toDouble(),
value: posMs,
onChanged: (v) =>
controller.seekTo(Duration(milliseconds: v.toInt())),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(position),
style: Theme.of(context).textTheme.bodySmall,
),
Text(
_format(duration),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(height: 24),
FloatingActionButton(
heroTag: 'audioPlayPause',
onPressed: () {
if (value.isPlaying) {
controller.pause();
} else {
controller.play();
}
},
child: Icon(value.isPlaying ? Icons.pause : Icons.play_arrow),
),
],
),
),
);
}
}
class _CodeLine extends StatelessWidget {
final int number;
final String text;
final double gutterWidth;
const _CodeLine({
required this.number,
required this.text,
required this.gutterWidth,
});
static const TextStyle _codeStyle = TextStyle(
fontFamily: 'monospace',
fontSize: 13,
height: 1.4,
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isEven = number.isEven;
return Container(
color: isEven ? theme.colorScheme.surfaceContainerLow : null,
padding: const EdgeInsets.only(left: 4, right: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectionContainer.disabled(
child: SizedBox(
width: gutterWidth,
child: Text(
'$number',
textAlign: TextAlign.right,
style: _codeStyle.copyWith(color: theme.hintColor),
),
),
),
const SizedBox(width: 8),
Expanded(child: Text(text.isEmpty ? ' ' : text, style: _codeStyle)),
],
),
);
}
}
+89 -47
View File
@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -29,15 +30,48 @@ class _AvatarPayload {
_AvatarPayload(this.bytes, this.isSvg); _AvatarPayload(this.bytes, this.isSvg);
} }
final Map<String, Future<_AvatarPayload?>> _avatarCache = {}; class _AvatarCacheEntry {
final _AvatarPayload? payload;
final DateTime fetchedAt;
_AvatarCacheEntry(this.payload, this.fetchedAt);
}
// LRU via LinkedHashMap insertion order + remove-on-hit. TTL so
// server-side avatar updates become visible within a session.
const int _kAvatarCacheMax = 256;
const Duration _kAvatarCacheTtl = Duration(minutes: 30);
// Pending map dedups concurrent mounts onto a single HTTP call.
final LinkedHashMap<String, _AvatarCacheEntry> _resolvedAvatars =
LinkedHashMap<String, _AvatarCacheEntry>();
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
_AvatarCacheEntry? _readAvatarCache(String url) {
final entry = _resolvedAvatars.remove(url);
if (entry == null) return null;
if (DateTime.now().difference(entry.fetchedAt) > _kAvatarCacheTtl) {
return null;
}
// Re-insert at the tail so it counts as most-recently-used.
_resolvedAvatars[url] = entry;
return entry;
}
void _writeAvatarCache(String url, _AvatarPayload? payload) {
_resolvedAvatars.remove(url);
_resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now());
while (_resolvedAvatars.length > _kAvatarCacheMax) {
_resolvedAvatars.remove(_resolvedAvatars.keys.first);
}
}
class _UserAvatarState extends State<UserAvatar> { class _UserAvatarState extends State<UserAvatar> {
late Future<_AvatarPayload?> _payload; _AvatarPayload? _payload;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_payload = _load(); _attach();
} }
@override @override
@@ -46,7 +80,7 @@ class _UserAvatarState extends State<UserAvatar> {
if (oldWidget.id != widget.id || if (oldWidget.id != widget.id ||
oldWidget.isGroup != widget.isGroup || oldWidget.isGroup != widget.isGroup ||
oldWidget.size != widget.size) { oldWidget.size != widget.size) {
_payload = _load(); _attach();
} }
} }
@@ -58,9 +92,21 @@ class _UserAvatarState extends State<UserAvatar> {
return 'https://$host/avatar/${widget.id}/${widget.size}'; return 'https://$host/avatar/${widget.id}/${widget.size}';
} }
Future<_AvatarPayload?> _load() { void _attach() {
final url = _url(); final url = _url();
return _avatarCache.putIfAbsent(url, () => _fetch(url)); final cached = _readAvatarCache(url);
if (cached != null) {
_payload = cached.payload;
return;
}
_payload = null;
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
pending.then((p) {
_writeAvatarCache(url, p);
_pendingAvatars.remove(url);
if (!mounted || _url() != url) return;
setState(() => _payload = p);
});
} }
Future<_AvatarPayload?> _fetch(String url) async { Future<_AvatarPayload?> _fetch(String url) async {
@@ -97,49 +143,45 @@ class _UserAvatarState extends State<UserAvatar> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final radius = widget.size.toDouble(); final radius = widget.size.toDouble();
final theme = Theme.of(context); final theme = Theme.of(context);
final payload = _payload;
return FutureBuilder<_AvatarPayload?>( Widget content;
future: _payload, if (payload != null) {
builder: (context, snapshot) { if (payload.isSvg) {
final payload = snapshot.data; content = SvgPicture.memory(
payload.bytes,
Widget content; width: radius * 2,
if (payload == null) { height: radius * 2,
content = Icon( fit: BoxFit.cover,
widget.isGroup ? Icons.group : Icons.person,
size: radius,
color: Colors.white,
);
} else if (payload.isSvg) {
content = SvgPicture.memory(
payload.bytes,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
);
} else {
content = Image.memory(
payload.bytes,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
gaplessPlayback: true,
);
}
return CircleAvatar(
radius: radius,
backgroundColor: theme.primaryColor,
foregroundColor: Colors.white,
child: ClipOval(
child: SizedBox(
width: radius * 2,
height: radius * 2,
child: content,
),
),
); );
}, } else {
content = Image.memory(
payload.bytes,
width: radius * 2,
height: radius * 2,
fit: BoxFit.cover,
gaplessPlayback: true,
);
}
} else {
content = Icon(
widget.isGroup ? Icons.group : Icons.person,
size: radius,
color: Colors.white,
);
}
return CircleAvatar(
radius: radius,
backgroundColor: theme.primaryColor,
foregroundColor: Colors.white,
child: ClipOval(
child: SizedBox(
width: radius * 2,
height: radius * 2,
child: content,
),
),
); );
} }
} }
+9
View File
@@ -38,7 +38,14 @@ dependencies:
workmanager: ^0.9.0+3 workmanager: ^0.9.0+3
intl: ^0.20.2 intl: ^0.20.2
flutter_linkify: ^6.0.0 flutter_linkify: ^6.0.0
linkify: ^5.0.0
flutter_local_notifications: ^21.0.0 flutter_local_notifications: ^21.0.0
# Cancels FCM-rendered notifications by their server-set tag
# (Android NotificationManager.cancel, iOS removeDeliveredNotifications via
# apns-collapse-id). Used to dismiss a chat's notification when the user
# opens or marks the chat read.
eraser: ^3.0.0
scrollable_positioned_list: ^0.3.8
flutter_split_view: ^0.1.2 flutter_split_view: ^0.1.2
flutter_svg: ^2.0.10 flutter_svg: ^2.0.10
freezed_annotation: ^3.1.0 freezed_annotation: ^3.1.0
@@ -72,6 +79,8 @@ dependencies:
url_launcher: ^6.3.1 url_launcher: ^6.3.1
enough_icalendar: ^0.17.0 enough_icalendar: ^0.17.0
receive_sharing_intent: ^1.8.1 receive_sharing_intent: ^1.8.1
video_player: ^2.9.0
chewie: ^1.8.5
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+49
View File
@@ -58,4 +58,53 @@ void main() {
expect(dt.timeRangeTo(end), '09:07 - 09:52'); expect(dt.timeRangeTo(end), '09:07 - 09:52');
}); });
}); });
group('formatDateRelativeShort', () {
final now = DateTime(2026, 5, 9, 14, 0); // Saturday
test('today returns "Heute"', () {
expect(
DateTime(2026, 5, 9, 8, 0).formatDateRelativeShort(now: now),
'Heute',
);
});
test('yesterday returns "Gestern"', () {
expect(
DateTime(2026, 5, 8, 23, 30).formatDateRelativeShort(now: now),
'Gestern',
);
});
test('2 to 6 days ago returns the German weekday name', () {
// 2026-05-07 is a Thursday
expect(
DateTime(2026, 5, 7).formatDateRelativeShort(now: now),
'Donnerstag',
);
// 2026-05-03 is a Sunday (6 days before Saturday 9th)
expect(
DateTime(2026, 5, 3).formatDateRelativeShort(now: now),
'Sonntag',
);
});
test('7 days or more ago falls back to dd.MM.yyyy', () {
expect(
DateTime(2026, 5, 2).formatDateRelativeShort(now: now),
'02.05.2026',
);
expect(
DateTime(2026, 1, 1).formatDateRelativeShort(now: now),
'01.01.2026',
);
});
test('future dates fall back to dd.MM.yyyy', () {
expect(
DateTime(2026, 5, 10).formatDateRelativeShort(now: now),
'10.05.2026',
);
});
});
} }
@@ -0,0 +1,77 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import 'package:marianum_mobile/view/pages/files/search/local_cache_search.dart';
CacheableFile _file({
required String path,
required String name,
bool isDirectory = false,
}) => CacheableFile(path: path, isDirectory: isDirectory, name: name);
Map<String, dynamic> _doc(ListFilesResponse listing) => {
'json': jsonEncode(listing.toJson()),
'lastupdate': 0,
};
void main() {
group('searchLocalCaches', () {
final root = ListFilesResponse({
_file(path: 'Documents/', name: 'Documents', isDirectory: true),
_file(path: 'Photos/', name: 'Photos', isDirectory: true),
_file(path: 'Reports.pdf', name: 'Reports.pdf'),
});
final documents = ListFilesResponse({
_file(path: 'Documents/Tax-Report.pdf', name: 'Tax-Report.pdf'),
_file(path: 'Documents/Notes.txt', name: 'Notes.txt'),
});
final docs = {
'/MarianumMobile/wd-folder-aaa': _doc(root),
'/MarianumMobile/wd-folder-bbb': _doc(documents),
'/MarianumMobile/get-room-ccc': {'json': '{}', 'lastupdate': 0},
};
test('matches by name case-insensitively across all caches', () async {
final hits = await searchLocalCaches('report', docs: docs);
final paths = hits.map((f) => f.path).toSet();
expect(paths, {'Reports.pdf', 'Documents/Tax-Report.pdf'});
});
test('returns empty list for empty query', () async {
expect(await searchLocalCaches(' ', docs: docs), isEmpty);
});
test('respects pathScope prefix', () async {
final hits = await searchLocalCaches(
'report',
pathScope: ['Documents'],
docs: docs,
);
expect(hits.map((f) => f.path), ['Documents/Tax-Report.pdf']);
});
test('ignores non-folder cache documents', () async {
final hits = await searchLocalCaches('anything', docs: docs);
// Only documents starting with `wd-folder-` are scanned. The unrelated
// `get-room-ccc` doc must not crash the helper.
expect(hits, isEmpty);
});
test('deduplicates entries that appear in multiple cached folders',
() async {
final shared = _file(
path: 'Documents/Tax-Report.pdf',
name: 'Tax-Report.pdf',
);
final dedupRoot = ListFilesResponse({shared});
final dedupDocs = {
'/MarianumMobile/wd-folder-aaa': _doc(dedupRoot),
'/MarianumMobile/wd-folder-bbb': _doc(dedupRoot),
};
final hits = await searchLocalCaches('tax', docs: dedupDocs);
expect(hits, hasLength(1));
});
});
}
@@ -0,0 +1,173 @@
import 'package:flutter_test/flutter_test.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/view/pages/talk/data/chat_search_controller.dart';
GetChatResponseObject _msg({
required int id,
required int timestamp,
String actorDisplayName = 'Anyone',
String message = '',
String systemMessage = '',
GetRoomResponseObjectMessageType type =
GetRoomResponseObjectMessageType.comment,
Map<String, RichObjectString>? params,
}) =>
GetChatResponseObject(
id,
'token',
GetRoomResponseObjectMessageActorType.user,
'actor-id',
actorDisplayName,
timestamp,
systemMessage,
type,
true,
'',
message,
params,
null,
null,
null,
);
GetChatResponse _response(List<GetChatResponseObject> messages) =>
GetChatResponse(messages.toSet());
void main() {
group('ChatSearchController.findMatches', () {
test('empty query returns no matches', () {
final response = _response([
_msg(id: 1, timestamp: 100, message: 'Hallo'),
]);
expect(ChatSearchController.findMatches(response, ''), isEmpty);
expect(ChatSearchController.findMatches(response, ' '), isEmpty);
});
test('matches message text case-insensitively', () {
final response = _response([
_msg(id: 1, timestamp: 100, message: 'Hallo Welt'),
_msg(id: 2, timestamp: 200, message: 'nichts hier'),
]);
final matches = ChatSearchController.findMatches(response, 'WELT');
expect(matches.length, 1);
expect(matches.first.messageId, 1);
});
test('matches actor display name', () {
final response = _response([
_msg(
id: 1,
timestamp: 100,
actorDisplayName: 'Lisa Maier',
message: 'irgendwas',
),
_msg(
id: 2,
timestamp: 200,
actorDisplayName: 'Tom Weber',
message: 'auch was',
),
]);
final matches = ChatSearchController.findMatches(response, 'lisa');
expect(matches.length, 1);
expect(matches.first.messageId, 1);
});
test('system messages match on text but not on actor', () {
final response = _response([
_msg(
id: 1,
timestamp: 100,
actorDisplayName: 'Lisa',
message: 'Lisa ist beigetreten',
type: GetRoomResponseObjectMessageType.system,
),
]);
// Match on text content
expect(
ChatSearchController.findMatches(response, 'beigetreten').length,
1,
);
// Actor name alone (not in text) should not match for system messages
final actorOnlyResponse = _response([
_msg(
id: 1,
timestamp: 100,
actorDisplayName: 'Lisa',
message: 'jemand ist beigetreten',
type: GetRoomResponseObjectMessageType.system,
),
]);
expect(
ChatSearchController.findMatches(actorOnlyResponse, 'lisa'),
isEmpty,
);
});
test(
'reaction, poll_voted and message_deleted system messages are filtered out',
() {
final response = _response([
_msg(
id: 1,
timestamp: 100,
message: 'Treffer',
systemMessage: 'reaction',
type: GetRoomResponseObjectMessageType.system,
),
_msg(
id: 2,
timestamp: 200,
message: 'Treffer',
systemMessage: 'poll_voted',
type: GetRoomResponseObjectMessageType.system,
),
_msg(
id: 4,
timestamp: 250,
message: 'Treffer',
systemMessage: 'message_deleted',
type: GetRoomResponseObjectMessageType.system,
),
_msg(id: 3, timestamp: 300, message: 'Treffer'),
]);
final matches = ChatSearchController.findMatches(response, 'Treffer');
expect(matches.length, 1);
expect(matches.first.messageId, 3);
},
);
test('rich object parameters are searchable (e.g. file names)', () {
final response = _response([
_msg(
id: 1,
timestamp: 100,
message: '{file}',
params: {
'file': RichObjectString(
RichObjectStringObjectType.file,
'42',
'hausaufgaben.pdf',
null,
null,
),
},
),
]);
final matches = ChatSearchController.findMatches(response, 'hausaufgab');
expect(matches.length, 1);
expect(matches.first.messageId, 1);
});
test('matches are sorted newest first', () {
final response = _response([
_msg(id: 1, timestamp: 100, message: 'X'),
_msg(id: 2, timestamp: 300, message: 'X'),
_msg(id: 3, timestamp: 200, message: 'X'),
]);
final matches = ChatSearchController.findMatches(response, 'x');
expect(matches.map((m) => m.messageId).toList(), [2, 3, 1]);
});
});
}