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" />
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<!-- Required so url_launcher / can_launch can actually see browsers,
mail clients and dialers under Android 11+ package-visibility rules
(otherwise UrlLauncher logs "component name for ... is null" and
link taps in Talk silently do nothing). The PROCESS_TEXT intent is
needed by io.flutter.plugin.text.ProcessTextPlugin (selection
menu).
See https://developer.android.com/training/package-visibility -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</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>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device
@@ -167,14 +167,6 @@ object WidgetRenderer {
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))
@@ -283,16 +275,6 @@ object WidgetRenderer {
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))
@@ -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,
/// mirroring CustomTimetableColors).
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 "com.android.application" 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'
}
@@ -82,7 +82,6 @@ struct TimetableDayView: View {
TimeGridView(
lessons: data.lessons,
periods: data.periods,
anchorDate: data.anchorDate,
hourHeight: max(
MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
@@ -135,7 +134,6 @@ struct TimetableDayView: View {
struct TimeGridView: View {
let lessons: [WidgetLesson]
let periods: [WidgetPeriod]
let anchorDate: Date
let hourHeight: CGFloat
let showRoom: Bool
let showTeacher: Bool
@@ -170,9 +168,6 @@ struct TimeGridView: View {
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx])
}
if Calendar.current.isDate(anchorDate, inSameDayAs: Date()) {
nowIndicator
}
}
.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 {
!lesson.subjectShort.isEmpty
? lesson.subjectShort
@@ -131,7 +131,6 @@ struct TimetableWeekView: View {
return TimeGridView(
lessons: lessonsForDay,
periods: data.periods,
anchorDate: day,
hourHeight: hourHeight,
showRoom: !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) {
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return getTextDummy(elementDate.formatDate());
return getTextDummy(elementDate.formatDateRelativeShort());
}
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,
Object? body,
Map<String, String>? headers,
) {
if (readState) {
return http.post(uri, headers: headers);
} else {
return http.delete(uri, headers: headers);
}
}
) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers);
}
@@ -16,8 +16,9 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
super.onNetworkData,
super.onError,
required String path,
super.renew = false,
}) : super(
cacheTime: RequestCache.cacheNothing,
cacheTime: _cacheTimeFor(path),
loader: () => ListFiles(ListFilesParams(path)).run(),
fromJson: ListFilesResponse.fromJson,
onUpdate: onUpdate,
@@ -25,6 +26,44 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
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) {
final cacheName = md5
.convert(utf8.encode('MarianumMobile-$path'))
+55 -55
View File
@@ -36,17 +36,33 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _refetchChats;
late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync;
// Tracked via the bottom-nav controller's listener so it always reflects the
// user's actual position, even between rapid setting emits where the
// controller hasn't caught up to a scheduled jump yet.
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1;
bool _userOnLastTab = false;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
void _onTabControllerChanged() {
_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
@@ -65,13 +81,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return;
// Routes pushed with `withNavBar: false` (chat views, file viewers, …)
// sit on the root navigator above the bottom-nav, so a bare jumpToTab
// would swap the tab behind them and leave the user staring at the
// previous screen. Reset to the tab root first.
// `withNavBar: false` routes sit on the root navigator above the
// bottom-nav; pop them so jumpToTab is actually visible. Stop at
// popups so open dialogs/sheets stay alive.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
AppRoutes.goToTab(context, Modules.timetable);
}
@@ -80,12 +95,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return;
final share = ShareIntentListener.pending.value;
if (share == null) return;
// A second share arriving while a previous share-flow page is still on
// the stack would otherwise leave the old page sitting on top with stale
// (already-cleared) file paths. Reset to the tab root before pushing.
// A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
AppRoutes.openShareTarget(context, share);
}
@@ -101,15 +115,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return;
context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh();
// App is freshly mounted on every login (BlocConsumer in main.dart
// 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.
// Re-mounts on every login, so this also covers post-logout state reset.
final timetable = context.read<TimetableBloc>();
timetable.refresh();
// Push the freshest timetable state into the home-screen widget any
// time the BLoC reports new data — without waiting for the periodic
// background refresh. This is the "user just opened the app" path:
// the widget gets the same data the user is looking at on screen.
// Mirror BLoC updates into the home-screen widget without waiting
// for the periodic background refresh.
final settingsCubit = context.read<SettingsCubit>();
_timetableWidgetSync?.cancel();
_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
// from hydrated storage before the listener attaches.
// Initial publish in case hydrated storage already has data.
final initialData = timetable.state.data;
if (initialData is TimetableState) {
unawaited(
@@ -138,28 +147,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare();
_syncChatListPolling();
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {});
});
_refetchChats = Timer.periodic(const Duration(seconds: 60), (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<ChatListBloc>().refresh();
});
});
UpdateUserIndex.index();
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
void update() => NotifyUpdater.registerToServer();
FirebaseMessaging.instance.onTokenRefresh.listen((_) => update());
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
(_) => update(),
);
update();
}
FirebaseMessaging.onMessage.listen((message) {
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context);
});
@@ -167,7 +172,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationController.onBackgroundMessageHandler,
);
FirebaseMessaging.onMessageOpenedApp.listen((message) {
_onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
message,
) {
if (!mounted) return;
NotificationController.onAppOpenedByNotification(message, context);
});
@@ -181,9 +188,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override
void dispose() {
_refetchChats.cancel();
_updateTimings.cancel();
_timetableWidgetSync?.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
@@ -200,17 +209,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final totalTabs = bottomBarModules.length + 1;
final currentIndex = Main.bottomNavigator.index;
// The bottom-bar layout is identified by the ordered list of module
// names plus the trailing 'more' slot. Whenever this layout changes
// — slot count, reordering, or hiding a module — we recreate the
// 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.
// PersistentTabView caches per-tab navigators by index and only
// appends/trims at the end, so reordering/hiding leaves stale
// route stacks under the wrong tabs. Re-key on layout to remount.
final layoutKey = ValueKey(
'${bottomBarModules.map((m) => m.module.name).join('|')}|more',
);
@@ -222,12 +223,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} else if (currentIndex >= totalTabs) {
targetIndex = totalTabs - 1;
}
// Re-mounting PTV with a new key constructs fresh internals from
// its controller's current index. If the controller still points
// 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.
// Replace the controller atomically: a stale index past the new
// tab list crashes Style6BottomNavBar's initState.
if (targetIndex != currentIndex) {
Main.bottomNavigator.removeListener(_onTabControllerChanged);
Main.bottomNavigator = PersistentTabController(
@@ -263,14 +260,17 @@ class _AppState extends State<App> with WidgetsBindingObserver {
),
],
navBarBuilder: (config) => Style6BottomNavBar(
// Style6BottomNavBar builds its internal animation controller list
// in initState and never grows it on didUpdateWidget. Keying by the
// item count forces a fresh State whenever the slot count changes,
// which avoids a RangeError when more tabs slide in.
// Animation controllers are built once in initState and never
// grown — re-key on item count to avoid RangeError on growth.
key: ValueKey(config.items.length),
navBarConfig: config,
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,
),
),
+14
View File
@@ -46,4 +46,18 @@ extension DateTimeFormatting on DateTime {
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
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: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 'app.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
import 'model/account_data.dart';
import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart';
@@ -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) {
ErrorWidget.builder = (error) => Material(
color: Colors.white,
@@ -138,7 +154,9 @@ Future<void> main() async {
),
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(create: (_) => ChatBloc()),
BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
],
child: const Main(),
@@ -184,6 +202,8 @@ class _MainState extends State<Main> {
checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
navigatorObservers: [AppRoutes.chatRouteObserver],
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
+26 -2
View File
@@ -1,13 +1,19 @@
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.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/json_viewer.dart';
import '../widget/info_dialog.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 {
// Notification display is handled by the Firebase SDK using server-generated payloads.
@pragma('vm:entry-point')
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
NotificationTasks.updateBadgeCount(message);
@@ -17,8 +23,26 @@ class NotificationController {
RemoteMessage message,
BuildContext context,
) 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);
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(
+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:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.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 'notification_service.dart';
class NotificationTasks {
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) {
context.read<ChatListBloc>().refresh();
context.read<ChatBloc>().refresh();
}
/// 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 '../main.dart';
import '../model/account_data.dart';
import '../notification/notification_tasks.dart';
import '../share_intent/pending_share.dart';
import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart';
@@ -39,6 +40,11 @@ class AppRoutes {
/// by `ChatList` once the matching room is loaded.
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) {
pushScreen(context, withNavBar: false, screen: Files(path: path));
}
@@ -177,6 +183,12 @@ class AppRoutes {
required UserAvatar avatar,
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(
context,
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/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/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.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 'chat_event.dart';
import 'chat_state.dart';
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);
ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc {
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> close() {
WidgetsBinding.instance.removeObserver(this);
_stopLongPoll();
return super.close();
}
@override
ChatRepository repository() => ChatRepository();
@@ -33,24 +71,70 @@ class ChatBloc
}
void setToken(String token) {
_chatViewActive = true;
if (token == (innerState?.currentToken ?? '')) {
refresh();
return;
}
_stopLongPoll();
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
add(RefetchStarted<ChatState>());
_loadChat(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
_scheduleLoad(token);
}
void refresh() {
final token = innerState?.currentToken ?? '';
if (token.isEmpty) return;
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 {
@@ -69,14 +153,25 @@ class ChatBloc
token: token,
onCacheData: (data) {
if (!stillCurrent()) return;
// Cache hit: show data immediately but preserve lastFetch — the
// cached payload may be stale and we don't want the UI to claim a
// fresh fetch just happened.
// Skip cache paint over already-merged long-poll data — would
// visibly drop those messages until the network call resolves.
if (innerState?.chatResponse != null) return;
add(Emit((s) => s.copyWith(chatResponse: 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;
add(DataGathered((s) => s.copyWith(chatResponse: data)));
_applyChatResponse(data);
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
},
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 'package:flutter_app_badge/flutter_app_badge.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 '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -15,6 +17,8 @@ class ChatListBloc
extends
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
bool _forceRenew = false;
Timer? _autoRefreshTimer;
Duration? _autoRefreshInterval;
@override
void retry() {
@@ -22,6 +26,25 @@ class ChatListBloc
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
ChatListRepository repository() => ChatListRepository();
@@ -51,8 +74,8 @@ class ChatListBloc
if (capturedError != null) throw capturedError!;
}
Future<void> refresh({bool renew = true}) async {
add(RefetchStarted<ChatListState>());
Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError;
try {
final rooms = await repo.data.getRooms(
@@ -82,6 +105,65 @@ class ChatListBloc
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) {
try {
final unread = rooms.data.fold<int>(
@@ -37,7 +37,9 @@ class FilesBloc
Future<void> refresh() async {
add(RefetchStarted<FilesState>());
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 {
@@ -52,7 +54,7 @@ class FilesBloc
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('/');
// Drop late results when [setPath] has navigated elsewhere or when the
@@ -71,6 +73,7 @@ class FilesBloc
try {
listing = await repo.data.listFiles(
pathString,
renew: renew,
onCacheData: (cached) {
if (isStale()) return;
// 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
/// cache lookup and the network attempt have settled, throwing if no payload
/// 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(
String path, {
void Function(ListFilesResponse)? onCacheData,
void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<ListFilesResponse>(
(onUpdate, onError) => ListFilesCache(
path: path,
onUpdate: onUpdate,
onCacheData: onCacheData,
onError: onError,
renew: renew,
),
onError: onError,
operationName: 'listFiles',
+10
View File
@@ -14,6 +14,7 @@ import '../../../utils/cache_invalidation_bus.dart';
import '../../../widget/placeholder_view.dart';
import 'data/sort_options.dart';
import 'files_upload_dialog.dart';
import 'search/files_search_delegate.dart';
import 'widgets/add_file_menu.dart';
import 'widgets/clipboard_banner.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(
@@ -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/details_bottom_sheet.dart';
import '../../../../widget/info_dialog.dart';
import '../../talk/widgets/highlighted_linkify.dart';
import 'file_details_sheet.dart';
class FileElement extends StatefulWidget {
final CacheableFile file;
final List<String> path;
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
State<FileElement> createState() => _FileElementState();
@@ -118,7 +130,7 @@ class _FileElementState extends State<FileElement> {
);
}
Widget _subtitle() {
Widget? _subtitle() {
final status = _job?.status.value;
if (status is DownloadInProgress) {
return Row(
@@ -135,10 +147,16 @@ class _FileElementState extends State<FileElement> {
],
);
}
final modified = widget.file.modifiedAt ?? DateTime.now();
return widget.file.isDirectory
? Text('geändert ${modified.formatRelative()}')
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
final modified = widget.file.modifiedAt;
final size = widget.file.size;
if (widget.file.isDirectory) {
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() {
@@ -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
Widget build(BuildContext context) => ListTile(
leading: CenteredLeading(
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
),
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
title: _title(context),
subtitle: _subtitle(),
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
onTap: _onTap,
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
initialDescription: event.description,
initialStart: event.start,
initialEnd: event.end,
initialAllDay: event.isAllDay,
),
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/modules/marianum_message/bloc/marianum_message_bloc.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import 'search_marianum_messages.dart';
class MarianumMessageListView extends StatelessWidget {
const MarianumMessageListView({super.key});
@@ -16,7 +17,25 @@ class MarianumMessageListView extends StatelessWidget {
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
create: (context) => MarianumMessageBloc(),
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>(
child: (state, loading) => ListView.builder(
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),
applicationName: 'MarianumMobile',
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:
'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'
"${kReleaseMode ? "Production" : "Development"} build\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
"${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
'Marianum Fulda 2019-2020, 2023-${Jiffy.now().year}\nElias Müller',
);
}
@@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget {
),
ListTile(
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'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
@@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget {
Icon(Icons.send_time_extension_outlined),
),
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),
onTap: () => PrivacyInfo(
providerText: 'mhsl',
+11 -15
View File
@@ -7,7 +7,6 @@ import '../../../notification/notify_updater.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -19,15 +18,13 @@ import 'search_chat.dart';
import 'widgets/chat_tile.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 {
const ChatList({super.key});
@override
Widget build(BuildContext context) =>
BlocModule<ChatListBloc, LoadableState<ChatListState>>(
create: (_) => ChatListBloc(),
child: (context, bloc, _) => const _ChatListView(),
);
Widget build(BuildContext context) => const _ChatListView();
}
class _ChatListView extends StatefulWidget {
@@ -65,14 +62,6 @@ class _ChatListViewState extends State<_ChatListView> {
final resolved = AppRoutes.resolvePendingChat(context);
if (resolved == null) return;
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(
context,
room: resolved.room,
@@ -193,7 +182,14 @@ class _ChatListViewState extends State<_ChatListView> {
.talkSettings
.drafts
.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(),
);
},
+252 -9
View File
@@ -1,18 +1,27 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.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/room/get_room_response.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/modules/chat/bloc/chat_bloc.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 '../../../widget/clickable_app_bar.dart';
import '../../../widget/user_avatar.dart';
import 'data/chat_search_controller.dart';
import 'details/chat_info.dart';
import 'talk_navigator.dart';
import 'widgets/chat_bubble.dart';
import 'widgets/chat_search_app_bar.dart';
import 'widgets/chat_textfield.dart';
class ChatView extends StatefulWidget {
@@ -31,15 +40,201 @@ class ChatView extends StatefulWidget {
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> {
final ScrollController _listController = ScrollController();
class _ChatViewState extends State<ChatView> with RouteAware {
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() {
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) {
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 chronologicalMatchIndex = <int, int>{};
var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) {
final elementDate = DateTime.fromMillisecondsSinceEpoch(
@@ -48,6 +243,7 @@ class _ChatViewState extends State<ChatView> {
if (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue;
if (element.systemMessage.contains('message_deleted')) continue;
final commonRead = int.parse(
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(
ChatBubble(
context: context,
isSender:
element.actorId == widget.selfId &&
element.messageType == GetRoomResponseObjectMessageType.comment,
(element.messageType ==
GetRoomResponseObjectMessageType.comment ||
element.messageType ==
GetRoomResponseObjectMessageType.deletedComment),
bubbleData: element,
chatData: widget.room,
refetch: ({bool renew = false}) => _refresh(),
isRead: element.id <= commonRead,
selfId: widget.selfId,
highlightQuery: highlightQuery,
matchHighlight: highlight,
),
);
}
@@ -94,16 +304,37 @@ class _ChatViewState extends State<ChatView> {
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;
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: const Color(0xffefeae2),
appBar: ClickableAppBar(
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
appBar: _searchActive
? ChatSearchAppBar(
controller: _searchTextController,
matchCount: _matches.length,
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
onChanged: _onSearchChanged,
onClose: _exitSearchMode,
onPrevious: _matches.isEmpty ? null : _goToPreviousMatch,
onNext: _matches.isEmpty ? null : _goToNextMatch,
)
: ClickableAppBar(
onTap: () =>
TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
appBar: AppBar(
title: Row(
children: [
@@ -118,6 +349,13 @@ class _ChatViewState extends State<ChatView> {
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'In Chat suchen',
onPressed: _enterSearchMode,
),
],
),
),
body: DecoratedBox(
@@ -137,11 +375,16 @@ class _ChatViewState extends State<ChatView> {
isReady: (state) =>
state.chatResponse != null &&
state.currentToken == widget.room.token,
child: (state, _) => ListView(
child: (state, _) {
final items =
_buildMessages(state.chatResponse!).reversed.toList();
return ScrollablePositionedList.builder(
reverse: true,
controller: _listController,
children: _buildMessages(state.chatResponse!).reversed.toList(),
),
itemScrollController: _itemScrollController,
itemCount: items.length,
itemBuilder: (ctx, idx) => items[idx],
);
},
),
),
ColoredBox(
+8 -3
View File
@@ -1,12 +1,12 @@
import 'package:cached_network_image/cached_network_image.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/rich_object_string_processor.dart';
import '../../../../model/account_data.dart';
import '../../../../model/endpoint_data.dart';
import '../../../../utils/url_opener.dart';
import '../widgets/highlighted_linkify.dart';
class ChatMessage {
String originalMessage;
@@ -27,8 +27,13 @@ class ChatMessage {
);
}
Widget getWidget() {
var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen);
Widget getWidget({String? highlightQuery, TextStyle? style}) {
var contentWidget = HighlightedLinkify(
text: content,
onOpen: UrlOpener.onOpen,
highlight: highlightQuery,
style: style,
);
if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) {
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({
this.color,
this.borderWidth = 0,
this.borderColor,
this.elevation = 0,
this.margin = const BubbleEdges.only(),
this.padding = const BubbleEdges.all(8),
@@ -37,12 +38,25 @@ class BubbleStyle {
final Color? color;
final double borderWidth;
final Color? borderColor;
final double elevation;
final BubbleEdges margin;
final BubbleEdges padding;
final Alignment alignment;
final BubbleNip nip;
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
@@ -88,7 +102,7 @@ class Bubble extends StatelessWidget {
borderRadius: radius,
border: style.borderWidth > 0
? Border.all(
color: Theme.of(context).dividerColor,
color: style.borderColor ?? Theme.of(context).dividerColor,
width: style.borderWidth,
)
: null,
+92 -17
View File
@@ -18,6 +18,9 @@ import 'bubble.dart';
import 'chat_bubble_poll.dart';
import 'chat_bubble_reactions.dart';
import 'chat_message_options_dialog.dart';
import 'highlighted_linkify.dart';
enum SearchHighlight { none, secondary, active }
class ChatBubble extends StatefulWidget {
final BuildContext context;
@@ -33,6 +36,9 @@ class ChatBubble extends StatefulWidget {
final void Function({bool renew}) refetch;
final String? highlightQuery;
final SearchHighlight matchHighlight;
const ChatBubble({
required this.context,
required this.isSender,
@@ -41,6 +47,8 @@ class ChatBubble extends StatefulWidget {
required this.refetch,
this.isRead = false,
this.selfId,
this.highlightQuery,
this.matchHighlight = SearchHighlight.none,
super.key,
});
@@ -140,16 +148,54 @@ class _ChatBubbleState extends State<ChatBubble>
).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() {
final styles = ChatBubbleStyles(context);
if (widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.comment) {
return styles.getSystemStyle();
}
return widget.isSender
final BubbleStyle base;
if (!_rendersAsCommentBubble) {
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,
);
}
}
void _showOptionsDialog() => showChatMessageOptionsDialog(
context,
@@ -159,6 +205,18 @@ class _ChatBubbleState extends State<ChatBubble>
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() {
final obj = message.originalData?['object'];
if (obj?.type == RichObjectStringObjectType.talkPoll) {
@@ -186,25 +244,36 @@ class _ChatBubbleState extends State<ChatBubble>
originalData: widget.bubbleData.messageParameters,
);
final showActorDisplayName =
widget.bubbleData.messageType ==
GetRoomResponseObjectMessageType.comment &&
_rendersAsCommentBubble &&
widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne;
final showBubbleTime =
widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.system &&
widget.bubbleData.messageType !=
GetRoomResponseObjectMessageType.deletedComment;
GetRoomResponseObjectMessageType.system;
final parent = widget.bubbleData.parent;
final actorBaseStyle = TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
);
final actorText = Text(
widget.bubbleData.actorDisplayName,
textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
style: actorBaseStyle,
);
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(
DateTime.fromMillisecondsSinceEpoch(
@@ -245,15 +314,19 @@ class _ChatBubbleState extends State<ChatBubble>
},
onLongPress: _showOptionsDialog,
onDoubleTap: _showOptionsDialog,
onTap: _onTap,
onTap: _hasTapAction ? _onTap : null,
child: Transform.translate(
offset: _position,
child: Bubble(
style: _getStyle(),
child: _BubbleContent(
actorText: actorText,
actorWidget: actorWidget,
timeText: timeText,
messageWidget: message.getWidget(),
messageWidget: message.getWidget(
highlightQuery: widget.highlightQuery,
style: _messageTextStyle(context),
),
parent: parent,
bubbleData: widget.bubbleData,
isSender: widget.isSender,
@@ -282,6 +355,7 @@ class _ChatBubbleState extends State<ChatBubble>
class _BubbleContent extends StatelessWidget {
final Text actorText;
final Widget actorWidget;
final Text timeText;
final Widget messageWidget;
final GetChatResponseObject? parent;
@@ -298,6 +372,7 @@ class _BubbleContent extends StatelessWidget {
const _BubbleContent({
required this.actorText,
required this.actorWidget,
required this.timeText,
required this.messageWidget,
required this.parent,
@@ -323,7 +398,7 @@ class _BubbleContent extends StatelessWidget {
),
child: Stack(
children: [
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText),
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget),
Padding(
padding: EdgeInsets.only(
bottom: showBubbleTime ? 18 : 0,
@@ -30,9 +30,6 @@ RichObjectString? _attachedFile(GetChatResponseObject bubbleData) {
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(
BuildContext context, {
required GetRoomResponseObject chatData,
@@ -140,12 +137,22 @@ void showChatMessageOptionsDialog(
},
),
if (canDelete)
AsyncListTile(
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Nachricht löschen'),
onPressed: () async {
onTap: () {
ConfirmDialog(
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) sheetCtx.read<ChatBloc>().refresh();
if (!sheetCtx.mounted) return;
final bloc = sheetCtx.read<ChatBloc>();
Navigator.of(sheetCtx).pop();
bloc.refresh();
},
).asDialog(sheetCtx);
},
),
DebugTile(sheetCtx).jsonData(bubbleData.toJson()),
@@ -173,10 +180,12 @@ void _openOrCreateDirectChat(
}
void switchToChat(GetRoomResponseObject room) {
// Pop the current ChatView before swapping the global ChatBloc token —
// otherwise the previous group chat stays mounted in the back-stack and
// would render empty after a back-swipe (currentToken no longer matches).
Navigator.of(context).popUntil((route) => route.isFirst);
// Pop the previous ChatView first — otherwise it stays in the
// back-stack with a now-mismatched currentToken and renders empty
// on back-swipe. Stop at popups so an open dialog stays alive.
Navigator.of(
context,
).popUntil((route) => route.isFirst || route is PopupRoute);
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/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 '../../../../extensions/date_time.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_list/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart';
@@ -17,7 +18,6 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart';
import '../chat_view.dart';
import '../talk_navigator.dart';
class ChatTile extends StatefulWidget {
@@ -61,13 +61,11 @@ class _ChatTileState extends State<ChatTile> {
void _refreshList() => context.read<ChatListBloc>().refresh();
Future<void> _setCurrentAsRead() async {
await SetReadMarker(
widget.data.token,
true,
setReadMarkerParams: SetReadMarkerParams(
lastReadMessage: widget.data.lastMessage.id,
),
).run();
final token = widget.data.token;
final lastId = widget.data.lastMessage.id;
context.read<ChatListBloc>().markRoomAsRead(token, lastId);
unawaited(NotificationTasks.clearNotificationsForChat(token));
await context.read<ChatBloc>().sendServerReadMarker(token, lastId);
if (!mounted) return;
_refreshList();
}
@@ -154,18 +152,17 @@ class _ChatTileState extends State<ChatTile> {
return;
}
if (selfUsername == null) return;
unawaited(_setCurrentAsRead());
final view = ChatView(
// openChatView is the single entry point for opening a chat —
// 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,
selfId: selfUsername!,
avatar: circleAvatar,
);
TalkNavigator.pushSplitView(
context,
view,
overrideToSingleSubScreen: true,
);
context.read<ChatBloc>().setToken(widget.data.token);
},
onLongPress: () {
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 String? initialTitle;
final String? initialDescription;
final bool? initialAllDay;
const CustomEventEditDialog({
this.existingEvent,
@@ -26,6 +27,7 @@ class CustomEventEditDialog extends StatefulWidget {
this.initialEnd,
this.initialTitle,
this.initialDescription,
this.initialAllDay,
super.key,
});
@@ -78,13 +80,18 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
}
return;
}
_isAllDay = false;
_isAllDay = widget.initialAllDay ?? false;
if (_isAllDay) {
_startTime = _defaultStart;
_endTime = _defaultEnd;
} else {
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(
TimeOfDay rawStart,
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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 '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/unimplemented_dialog.dart';
class WebuntisLessonSheet {
static void show(
@@ -72,7 +70,7 @@ class WebuntisLessonSheet {
}).toList(),
),
_roomTile(context, state, lesson),
_teacherTile(context, lesson),
_teacherTile(lesson),
if ((lesson.activityType ?? '').trim().isNotEmpty)
ListTile(
leading: const Icon(Icons.abc),
@@ -120,14 +118,15 @@ class WebuntisLessonSheet {
final name = firstNonEmpty([resolved.name, r.name, '?']);
final longname = firstNonEmpty([resolved.longName, r.longname, '']);
final building = resolved.building.trim();
return LessonFormatter.formatLine(
final main = LessonFormatter.formatLine(
name,
longname: longname,
extra: (building.isNotEmpty && building != '?') ? building : null,
);
final sub = (longname.isNotEmpty && longname != name) ? longname : null;
return (main: main, sub: sub);
}).toList();
return _listTile(
return _listTileWithSubs(
icon: Icons.room,
label: lesson.ro.length == 1 ? 'Raum' : 'Räume',
entries: entries,
@@ -135,39 +134,63 @@ class WebuntisLessonSheet {
);
}
static Widget _teacherTile(
BuildContext context,
GetTimetableResponseObject lesson,
) {
final trailing = Visibility(
visible: !kReleaseMode,
child: IconButton(
icon: const Icon(Icons.textsms_outlined),
onPressed: () => UnimplementedDialog.show(context),
),
);
static Widget _teacherTile(GetTimetableResponseObject lesson) {
if (lesson.te.isEmpty) {
return ListTile(
leading: const Icon(Icons.person),
title: const Text('Lehrkraft: ?'),
trailing: trailing,
return const ListTile(
leading: Icon(Icons.person),
title: Text('Lehrkraft: ?'),
);
}
final entries = lesson.te.map((t) {
final base = LessonFormatter.formatLine(
final main = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?',
longname: t.longname,
);
final orgname = (t.orgname ?? '').trim();
return orgname.isEmpty ? base : '$base · ehemals $orgname';
return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname');
}).toList();
return _listTile(
return _listTileWithSubs(
icon: Icons.person,
label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
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,
);
}
@@ -37,40 +37,10 @@ class AppointmentTile extends StatelessWidget {
borderRadius: _radius,
color: color,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
_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,
),
],
],
child: _TileContent(
title: appointment.subject,
description: description,
isCustom: isCustom,
),
),
),
@@ -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
/// [FittedBox], but never below [minFontSize] — when even the minimum size
/// overflows, the text is rendered at [minFontSize] with an ellipsis.
+629 -86
View File
@@ -1,29 +1,33 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:chewie/chewie.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:open_filex/open_filex.dart';
import 'package:photo_view/photo_view.dart';
import 'package:share_plus/share_plus.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:video_player/video_player.dart';
import '../routing/app_routes.dart';
import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart';
import 'app_progress_indicator.dart';
import 'centered_leading.dart';
import 'info_dialog.dart';
import 'placeholder_view.dart';
import 'share_position_origin.dart';
class FileViewer extends StatefulWidget {
final String path;
final bool openExternal;
/// When set, enables the in-app actions "An Chat senden" and "In Dateien
/// speichern" — these need a server-side reference, not the local cache
/// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer).
/// Enables in-app "An Chat senden" / "In Dateien speichern" — these
/// need a server-side reference instead of the local cache path.
final RemoteFileRef? remoteFile;
const FileViewer({
@@ -39,10 +43,82 @@ class FileViewer extends StatefulWidget {
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
/// ancestor RenderTransform (from the page-push animation) is still mid-layout.
/// We wait for the route's enter animation to complete before mounting it.
enum _FileKind { image, svg, pdf, text, video, audio, unknown }
const Set<String> _imageExtensions = {
'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 {
const _DeferredPdfViewer({required this.path});
final String path;
@@ -82,7 +158,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
@override
Widget build(BuildContext context) {
if (!_ready) {
return const Center(child: CircularProgressIndicator());
return const Center(child: AppProgressIndicator.large());
}
return SfPdfViewer.file(File(widget.path));
}
@@ -93,13 +169,30 @@ class _FileViewerState extends State<FileViewer> {
late SettingsCubit settings = context.read<SettingsCubit>();
late bool openExternal;
Future<_FileKind>? _fileKind;
@override
void initState() {
super.initState();
openExternal =
settings.val().fileViewSettings.alwaysOpenExternally ||
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
@@ -108,14 +201,19 @@ class _FileViewerState extends State<FileViewer> {
super.dispose();
}
@override
Widget build(BuildContext context) {
AppBar appbar({List<Widget> actions = const []}) => AppBar(
title: Text(widget.path.split('/').last),
actions: [
...actions,
PopupMenuButton<FileViewingActions>(
onSelected: (value) async {
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(
@@ -129,10 +227,7 @@ class _FileViewerState extends State<FileViewer> {
AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
break;
case FileViewingActions.saveToCloud:
AppRoutes.openInternalSaveToFolder(
context,
widget.remoteFile!,
);
AppRoutes.openInternalSaveToFolder(context, widget.remoteFile!);
break;
case FileViewingActions.share:
unawaited(
@@ -146,17 +241,32 @@ class _FileViewerState extends State<FileViewer> {
break;
case FileViewingActions.save:
try {
final bytes = await File(widget.path).readAsBytes();
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 (!context.mounted) return;
if (!mounted) return;
if (saved != null) {
InfoDialog.show(context, 'Datei gespeichert.');
}
} on Object catch (e) {
if (!context.mounted) return;
if (!mounted) return;
InfoDialog.show(
context,
'Speichern fehlgeschlagen: $e',
@@ -166,63 +276,103 @@ class _FileViewerState extends State<FileViewer> {
}
break;
}
},
itemBuilder: (context) => <PopupMenuEntry<FileViewingActions>>[
const PopupMenuItem(
value: FileViewingActions.openExternal,
child: ListTile(
leading: Icon(Icons.open_in_new),
title: Text('Extern öffnen'),
dense: true,
),
}
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 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 _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 PopupMenuItem(
value: FileViewingActions.share,
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(Icons.share_outlined),
title: Text('Teilen'),
leading: Icon(a.icon),
title: Text(a.label),
dense: true,
),
),
const PopupMenuItem(
value: FileViewingActions.save,
child: ListTile(
leading: Icon(Icons.save_alt_outlined),
title: Text('Speichern'),
dense: true,
),
),
],
)
.toList(),
),
],
);
switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) {
case 'png':
case 'jpg':
case 'jpeg':
case 'webp':
case 'gif':
@override
Widget build(BuildContext context) {
if (openExternal) {
return Scaffold(
appBar: appbar(
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: [
IconButton(
onPressed: () {
@@ -246,29 +396,422 @@ class _FileViewerState extends State<FileViewer> {
),
);
case 'pdf':
return Scaffold(
appBar: appbar(),
body: _DeferredPdfViewer(path: widget.path),
Widget _buildSvgView() => Scaffold(
appBar: _appbar(),
backgroundColor: Colors.white,
body: InteractiveViewer(
minScale: 0.5,
maxScale: 8,
child: Center(
child: SvgPicture.file(
File(widget.path),
placeholderBuilder: (_) =>
const Center(child: AppProgressIndicator.large()),
),
),
),
);
default:
OpenFilex.open(widget.path).then((result) {
if (!context.mounted) return;
Navigator.of(context).pop();
if (result.type != ResultType.done) {
InfoDialog.show(context, result.message);
}
});
Widget _buildPdfView() =>
Scaffold(appBar: _appbar(), body: _DeferredPdfViewer(path: widget.path));
return PlaceholderView(
text: 'Datei extern geöffnet',
icon: Icons.open_in_new,
button: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Zurück'),
Widget _buildVideoView() => Scaffold(
appBar: _appbar(),
backgroundColor: Colors.black,
body: _MediaPlayer(path: widget.path, isAudio: false),
);
Widget _buildAudioView() => Scaffold(
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)),
],
),
),
);
},
),
);
Widget _buildUnknownView() {
final theme = Theme.of(context);
final descriptors = _availableActions();
return Scaffold(
appBar: _appbar(showActionsMenu: false),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 24),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const Icon(Icons.insert_drive_file_outlined, size: 60),
const SizedBox(height: 16),
Text(
'Vorschau nicht verfügbar',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
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)),
],
),
);
}
}
+62 -20
View File
@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:typed_data';
@@ -29,15 +30,48 @@ class _AvatarPayload {
_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> {
late Future<_AvatarPayload?> _payload;
_AvatarPayload? _payload;
@override
void initState() {
super.initState();
_payload = _load();
_attach();
}
@override
@@ -46,7 +80,7 @@ class _UserAvatarState extends State<UserAvatar> {
if (oldWidget.id != widget.id ||
oldWidget.isGroup != widget.isGroup ||
oldWidget.size != widget.size) {
_payload = _load();
_attach();
}
}
@@ -58,9 +92,21 @@ class _UserAvatarState extends State<UserAvatar> {
return 'https://$host/avatar/${widget.id}/${widget.size}';
}
Future<_AvatarPayload?> _load() {
void _attach() {
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 {
@@ -97,20 +143,11 @@ class _UserAvatarState extends State<UserAvatar> {
Widget build(BuildContext context) {
final radius = widget.size.toDouble();
final theme = Theme.of(context);
return FutureBuilder<_AvatarPayload?>(
future: _payload,
builder: (context, snapshot) {
final payload = snapshot.data;
final payload = _payload;
Widget content;
if (payload == null) {
content = Icon(
widget.isGroup ? Icons.group : Icons.person,
size: radius,
color: Colors.white,
);
} else if (payload.isSvg) {
if (payload != null) {
if (payload.isSvg) {
content = SvgPicture.memory(
payload.bytes,
width: radius * 2,
@@ -126,6 +163,13 @@ class _UserAvatarState extends State<UserAvatar> {
gaplessPlayback: true,
);
}
} else {
content = Icon(
widget.isGroup ? Icons.group : Icons.person,
size: radius,
color: Colors.white,
);
}
return CircleAvatar(
radius: radius,
@@ -139,7 +183,5 @@ class _UserAvatarState extends State<UserAvatar> {
),
),
);
},
);
}
}
+9
View File
@@ -38,7 +38,14 @@ dependencies:
workmanager: ^0.9.0+3
intl: ^0.20.2
flutter_linkify: ^6.0.0
linkify: ^5.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_svg: ^2.0.10
freezed_annotation: ^3.1.0
@@ -72,6 +79,8 @@ dependencies:
url_launcher: ^6.3.1
enough_icalendar: ^0.17.0
receive_sharing_intent: ^1.8.1
video_player: ^2.9.0
chewie: ^1.8.5
dev_dependencies:
flutter_test:
+49
View File
@@ -58,4 +58,53 @@ void main() {
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]);
});
});
}