6 Commits

28 changed files with 912 additions and 378 deletions
@@ -167,14 +167,6 @@ object WidgetRenderer {
horizontalPaddingDp = 7, horizontalPaddingDp = 7,
) )
} }
maybeAddNowIndicator(
packageName,
views,
R.id.widget_day_grid,
hourHeightDp,
anchorDate = data.anchorDate,
periods = data.periods,
)
} }
views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
@@ -283,16 +275,6 @@ object WidgetRenderer {
horizontalPaddingDp = 3, horizontalPaddingDp = 3,
) )
} }
if (WidgetDateUtils.isSameDay(day, Date())) {
maybeAddNowIndicator(
packageName,
views,
columnId,
hourHeightDp,
anchorDate = day,
periods = data.periods,
)
}
} }
views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
@@ -644,34 +626,6 @@ object WidgetRenderer {
} }
} }
private fun maybeAddNowIndicator(
packageName: String,
parent: RemoteViews,
containerId: Int,
hourHeightDp: Float,
anchorDate: Date,
periods: List<WidgetPeriod>,
) {
if (!WidgetDateUtils.isSameDay(anchorDate, Date())) return
val now = Calendar.getInstance()
val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
if (periods.isNotEmpty()) {
if (nowMinutes < periods.first().startMinutes ||
nowMinutes > periods.last().endMinutes
) return
}
val virtualNow = realMinutesToVirtual(nowMinutes, periods)
val topDp = virtualNow * hourHeightDp / 60.0f
val indicator = RemoteViews(packageName, R.layout.widget_now_indicator)
indicator.setViewLayoutMargin(
R.id.widget_now_indicator_root,
RemoteViews.MARGIN_TOP,
topDp,
TypedValue.COMPLEX_UNIT_DIP,
)
parent.addView(containerId, indicator)
}
/// Custom-events use the user-picked palette (orange/red/green/blue, /// Custom-events use the user-picked palette (orange/red/green/blue,
/// mirroring CustomTimetableColors). /// mirroring CustomTimetableColors).
private fun statusDrawable(lesson: WidgetLesson): Int { private fun statusDrawable(lesson: WidgetLesson): Int {
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFE53935" />
</shape>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_now_indicator_root"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="0dp"
android:background="@drawable/widget_now_indicator" />
+1 -1
View File
@@ -21,7 +21,7 @@ plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.13.2' apply false id "com.android.application" version '8.13.2' apply false
id "com.android.library" version '8.13.2' apply false id "com.android.library" version '8.13.2' apply false
id "org.jetbrains.kotlin.android" version "2.1.10" apply false id "org.jetbrains.kotlin.android" version "2.2.20" apply false
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0' id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
} }
@@ -82,7 +82,6 @@ struct TimetableDayView: View {
TimeGridView( TimeGridView(
lessons: data.lessons, lessons: data.lessons,
periods: data.periods, periods: data.periods,
anchorDate: data.anchorDate,
hourHeight: max( hourHeight: max(
MIN_HOUR_HEIGHT, MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
@@ -135,7 +134,6 @@ struct TimetableDayView: View {
struct TimeGridView: View { struct TimeGridView: View {
let lessons: [WidgetLesson] let lessons: [WidgetLesson]
let periods: [WidgetPeriod] let periods: [WidgetPeriod]
let anchorDate: Date
let hourHeight: CGFloat let hourHeight: CGFloat
let showRoom: Bool let showRoom: Bool
let showTeacher: Bool let showTeacher: Bool
@@ -170,9 +168,6 @@ struct TimeGridView: View {
ForEach(lessons.indices, id: \.self) { idx in ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx]) lessonBlock(lessons[idx])
} }
if Calendar.current.isDate(anchorDate, inSameDayAs: Date()) {
nowIndicator
}
} }
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top) .frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
} }
@@ -344,27 +339,6 @@ struct TimeGridView: View {
} }
} }
private var nowIndicator: some View {
let cal = Calendar.current
let comps = cal.dateComponents([.hour, .minute], from: Date())
let nowMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
let inside: Bool
if let first = periods.first, let last = periods.last {
inside = nowMinutes >= first.startMinutes && nowMinutes <= last.endMinutes
} else {
inside = true
}
let top = realMinutesToVirtual(nowMinutes, periods: periods) * hourHeight / 60.0
return Group {
if inside {
Rectangle()
.fill(Color.red)
.frame(height: 2)
.offset(y: top)
}
}
}
private func subjectLabel(_ lesson: WidgetLesson) -> String { private func subjectLabel(_ lesson: WidgetLesson) -> String {
!lesson.subjectShort.isEmpty !lesson.subjectShort.isEmpty
? lesson.subjectShort ? lesson.subjectShort
@@ -131,7 +131,6 @@ struct TimetableWeekView: View {
return TimeGridView( return TimeGridView(
lessons: lessonsForDay, lessons: lessonsForDay,
periods: data.periods, periods: data.periods,
anchorDate: day,
hourHeight: hourHeight, hourHeight: hourHeight,
showRoom: !subjectOnly, showRoom: !subjectOnly,
showTeacher: !subjectOnly, showTeacher: !subjectOnly,
@@ -0,0 +1,69 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and we need 304 to be a
/// normal "no new messages" outcome. `setReadMarker=on` lets the server
/// move the read cursor whenever the call returns messages.
class LongPollChat {
final String chatToken;
final int lastKnownMessageId;
final int timeoutSeconds;
LongPollChat({
required this.chatToken,
required this.lastKnownMessageId,
this.timeoutSeconds = 30,
});
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
limit: 100,
);
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15));
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
}
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
);
}
}
@@ -26,11 +26,5 @@ class SetReadMarker extends TalkApi {
Uri uri, Uri uri,
Object? body, Object? body,
Map<String, String>? headers, Map<String, String>? headers,
) { ) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers);
if (readState) {
return http.post(uri, headers: headers);
} else {
return http.delete(uri, headers: headers);
}
}
} }
+55 -55
View File
@@ -36,17 +36,33 @@ class App extends StatefulWidget {
} }
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _refetchChats;
late Timer _updateTimings; late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync; StreamSubscription<dynamic>? _timetableWidgetSync;
// Tracked via the bottom-nav controller's listener so it always reflects the StreamSubscription<RemoteMessage>? _onMessageSub;
// user's actual position, even between rapid setting emits where the StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
// controller hasn't caught up to a scheduled jump yet. StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1; int _knownTotalTabs = 1;
bool _userOnLastTab = false; bool _userOnLastTab = false;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
void _onTabControllerChanged() { void _onTabControllerChanged() {
_userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1; _userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1;
_syncChatListPolling();
}
void _syncChatListPolling() {
if (!mounted) return;
final modules = AppModule.getBottomBarModules(context);
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
final talkIsActive =
talkSlot >= 0 && Main.bottomNavigator.index == talkSlot;
final bloc = context.read<ChatListBloc>();
bloc.setAutoRefreshInterval(
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
);
if (talkIsActive) bloc.refresh();
} }
@override @override
@@ -65,13 +81,12 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Future<void> _handlePendingWidgetNavigation() async { Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap(); final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return; if (!pending || !mounted) return;
// Routes pushed with `withNavBar: false` (chat views, file viewers, …) // `withNavBar: false` routes sit on the root navigator above the
// sit on the root navigator above the bottom-nav, so a bare jumpToTab // bottom-nav; pop them so jumpToTab is actually visible. Stop at
// would swap the tab behind them and leave the user staring at the // popups so open dialogs/sheets stay alive.
// previous screen. Reset to the tab root first.
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.goToTab(context, Modules.timetable); AppRoutes.goToTab(context, Modules.timetable);
} }
@@ -80,12 +95,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
final share = ShareIntentListener.pending.value; final share = ShareIntentListener.pending.value;
if (share == null) return; if (share == null) return;
// A second share arriving while a previous share-flow page is still on // A second share would otherwise leave the previous share-flow page
// the stack would otherwise leave the old page sitting on top with stale // on top with stale (already-cleared) file paths.
// (already-cleared) file paths. Reset to the tab root before pushing.
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.openShareTarget(context, share); AppRoutes.openShareTarget(context, share);
} }
@@ -101,15 +115,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return; if (!mounted) return;
context.read<BreakerBloc>().refresh(); context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
// App is freshly mounted on every login (BlocConsumer in main.dart // Re-mounts on every login, so this also covers post-logout state reset.
// swaps it in for Login), so this also covers the post-logout case
// where the bloc was reset to an empty state and needs a fresh fetch.
final timetable = context.read<TimetableBloc>(); final timetable = context.read<TimetableBloc>();
timetable.refresh(); timetable.refresh();
// Push the freshest timetable state into the home-screen widget any // Mirror BLoC updates into the home-screen widget without waiting
// time the BLoC reports new data — without waiting for the periodic // for the periodic background refresh.
// background refresh. This is the "user just opened the app" path:
// the widget gets the same data the user is looking at on screen.
final settingsCubit = context.read<SettingsCubit>(); final settingsCubit = context.read<SettingsCubit>();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
_timetableWidgetSync = timetable.stream.listen((state) { _timetableWidgetSync = timetable.stream.listen((state) {
@@ -123,8 +133,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
); );
} }
}); });
// Also publish the current state once, in case data is already loaded // Initial publish in case hydrated storage already has data.
// from hydrated storage before the listener attaches.
final initialData = timetable.state.data; final initialData = timetable.state.data;
if (initialData is TimetableState) { if (initialData is TimetableState) {
unawaited( unawaited(
@@ -138,28 +147,24 @@ class _AppState extends State<App> with WidgetsBindingObserver {
ShareIntentListener.instance.attach(); ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare(); _handlePendingShare();
_syncChatListPolling();
}); });
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
_refetchChats = Timer.periodic(const Duration(seconds: 60), (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<ChatListBloc>().refresh();
});
});
UpdateUserIndex.index(); UpdateUserIndex.index();
if (context.read<SettingsCubit>().val().notificationSettings.enabled) { if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
void update() => NotifyUpdater.registerToServer(); void update() => NotifyUpdater.registerToServer();
FirebaseMessaging.instance.onTokenRefresh.listen((_) => update()); _fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
(_) => update(),
);
update(); update();
} }
FirebaseMessaging.onMessage.listen((message) { _onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return; if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context); NotificationController.onForegroundMessageHandler(message, context);
}); });
@@ -167,7 +172,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationController.onBackgroundMessageHandler, NotificationController.onBackgroundMessageHandler,
); );
FirebaseMessaging.onMessageOpenedApp.listen((message) { _onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
message,
) {
if (!mounted) return; if (!mounted) return;
NotificationController.onAppOpenedByNotification(message, context); NotificationController.onAppOpenedByNotification(message, context);
}); });
@@ -181,9 +188,11 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
_refetchChats.cancel();
_updateTimings.cancel(); _updateTimings.cancel();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach(); ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
@@ -200,17 +209,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final totalTabs = bottomBarModules.length + 1; final totalTabs = bottomBarModules.length + 1;
final currentIndex = Main.bottomNavigator.index; final currentIndex = Main.bottomNavigator.index;
// The bottom-bar layout is identified by the ordered list of module // PersistentTabView caches per-tab navigators by index and only
// names plus the trailing 'more' slot. Whenever this layout changes // appends/trims at the end, so reordering/hiding leaves stale
// — slot count, reordering, or hiding a module — we recreate the // route stacks under the wrong tabs. Re-key on layout to remount.
// entire PersistentTabView via the [layoutKey] below. The package
// caches per-tab navigator state by index in `_navigatorKeys`, and
// its internal `alignLength` only ever appends or trims at the end.
// So when the module sitting at e.g. index 3 changes, the navigator
// at that index still serves the old screen's route stack and the
// user sees stale content. Re-mounting clears those stacks; the
// trade-off (losing in-tab pushed routes on a settings change) is
// acceptable since the user explicitly re-shaped the bar.
final layoutKey = ValueKey( final layoutKey = ValueKey(
'${bottomBarModules.map((m) => m.module.name).join('|')}|more', '${bottomBarModules.map((m) => m.module.name).join('|')}|more',
); );
@@ -222,12 +223,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} else if (currentIndex >= totalTabs) { } else if (currentIndex >= totalTabs) {
targetIndex = totalTabs - 1; targetIndex = totalTabs - 1;
} }
// Re-mounting PTV with a new key constructs fresh internals from // Replace the controller atomically: a stale index past the new
// its controller's current index. If the controller still points // tab list crashes Style6BottomNavBar's initState.
// past the new tab list, Style6BottomNavBar (and others) crash on
// out-of-range access during initState. Replace the controller
// atomically with one initialised at the safe target index so the
// new PTV mounts cleanly.
if (targetIndex != currentIndex) { if (targetIndex != currentIndex) {
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
Main.bottomNavigator = PersistentTabController( Main.bottomNavigator = PersistentTabController(
@@ -263,14 +260,17 @@ class _AppState extends State<App> with WidgetsBindingObserver {
), ),
], ],
navBarBuilder: (config) => Style6BottomNavBar( navBarBuilder: (config) => Style6BottomNavBar(
// Style6BottomNavBar builds its internal animation controller list // Animation controllers are built once in initState and never
// in initState and never grows it on didUpdateWidget. Keying by the // grown — re-key on item count to avoid RangeError on growth.
// item count forces a fresh State whenever the slot count changes,
// which avoids a RangeError when more tabs slide in.
key: ValueKey(config.items.length), key: ValueKey(config.items.length),
navBarConfig: config, navBarConfig: config,
navBarDecoration: NavBarDecoration( navBarDecoration: NavBarDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.grey)), border: Border(
top: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
), ),
), ),
+6 -1
View File
@@ -23,6 +23,7 @@ import 'app.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'model/account_data.dart'; import 'model/account_data.dart';
import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart'; import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/account/bloc/account_state.dart';
@@ -153,7 +154,9 @@ Future<void> main() async {
), ),
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()), BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()), BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(create: (_) => ChatBloc()), BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()), BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
], ],
child: const Main(), child: const Main(),
@@ -199,6 +202,8 @@ class _MainState extends State<Main> {
checkerboardRasterCacheImages: checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages, devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
navigatorObservers: [AppRoutes.chatRouteObserver],
localizationsDelegates: const [ localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates, ...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
+26 -2
View File
@@ -1,13 +1,19 @@
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart'; import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart'; import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart'; import '../widget/info_dialog.dart';
import 'notification_tasks.dart'; import 'notification_tasks.dart';
// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM
// background isolate looks the class up by name from native code.
@pragma('vm:entry-point')
class NotificationController { class NotificationController {
// Notification display is handled by the Firebase SDK using server-generated payloads.
@pragma('vm:entry-point') @pragma('vm:entry-point')
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async { static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
NotificationTasks.updateBadgeCount(message); NotificationTasks.updateBadgeCount(message);
@@ -17,8 +23,26 @@ class NotificationController {
RemoteMessage message, RemoteMessage message,
BuildContext context, BuildContext context,
) async { ) async {
NotificationTasks.updateProviders(context); final pushToken = _extractChatToken(message);
final chatBloc = context.read<ChatBloc>();
// hasOpenChat, not currentToken: currentToken sticks around after
// leaveChat so didPopNext can re-claim a stacked chat.
final activeToken = chatBloc.state.data?.currentToken ?? '';
final chatIsOpen =
chatBloc.hasOpenChat &&
pushToken != null &&
pushToken.isNotEmpty &&
pushToken == activeToken;
NotificationTasks.updateBadgeCount(message); NotificationTasks.updateBadgeCount(message);
if (chatIsOpen) {
// Long-poll handles the message; just dismiss any stray tray entry.
unawaited(NotificationTasks.clearNotificationsForChat(pushToken));
return;
}
NotificationTasks.updateProviders(context);
} }
static Future<void> onAppOpenedByNotification( static Future<void> onAppOpenedByNotification(
+40 -2
View File
@@ -1,11 +1,15 @@
import 'dart:developer';
import 'package:eraser/eraser.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../routing/app_routes.dart'; import '../routing/app_routes.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'notification_service.dart';
class NotificationTasks { class NotificationTasks {
static void updateBadgeCount(RemoteMessage notification) { static void updateBadgeCount(RemoteMessage notification) {
@@ -14,9 +18,43 @@ class NotificationTasks {
); );
} }
/// Per-chat tag scheme. MUST match the Notify backend, which sets this
/// value on `AndroidNotification.setTag` AND `apns-collapse-id`.
static String chatTag(String chatToken) => 'talk_$chatToken';
/// Removes tray notifications belonging to [chatToken]. Eraser handles
/// iOS (where the plugin's `getActiveNotifications` returns null ids
/// for FCM posts and can't cancel them); the local-notifications sweep
/// handles Android and acts as a fallback while Eraser's native side
/// isn't built in yet.
static Future<void> clearNotificationsForChat(String chatToken) async {
final tag = chatTag(chatToken);
try {
await Eraser.clearAppNotificationsByTag(tag);
} on MissingPluginException {
// Eraser native code not yet linked — needs flutter clean + run.
} on Object catch (e) {
log('Eraser($tag) failed: $e');
}
try {
final plugin = NotificationService().flutterLocalNotificationsPlugin;
final actives = await plugin.getActiveNotifications();
for (final n in actives) {
final id = n.id;
if (id == null) continue;
if (n.tag == tag) await plugin.cancel(id: id, tag: n.tag);
}
} on Object catch (e) {
log('Active-notification sweep failed: $e');
}
}
/// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] —
/// the open chat view manages its own state via long-poll, and refreshing
/// it here would re-fetch the last-opened chat with setReadMarker=on
/// even if the user has already left.
static void updateProviders(BuildContext context) { static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
context.read<ChatBloc>().refresh();
} }
/// Switches to the Talk tab. If [chatToken] is provided, also schedules /// Switches to the Talk tab. If [chatToken] is provided, also schedules
+12
View File
@@ -6,6 +6,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../main.dart'; import '../main.dart';
import '../model/account_data.dart'; import '../model/account_data.dart';
import '../notification/notification_tasks.dart';
import '../share_intent/pending_share.dart'; import '../share_intent/pending_share.dart';
import '../share_intent/remote_file_ref.dart'; import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart'; import '../state/app/modules/app_modules.dart';
@@ -39,6 +40,11 @@ class AppRoutes {
/// by `ChatList` once the matching room is loaded. /// by `ChatList` once the matching room is loaded.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null); static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
/// Root-navigator observer used by [ChatView] to reclaim the global
/// [ChatBloc] on `didPopNext` after a stacked chat is popped.
static final RouteObserver<PageRoute<dynamic>> chatRouteObserver =
RouteObserver<PageRoute<dynamic>>();
static void openFolder(BuildContext context, List<String> path) { static void openFolder(BuildContext context, List<String> path) {
pushScreen(context, withNavBar: false, screen: Files(path: path)); pushScreen(context, withNavBar: false, screen: Files(path: path));
} }
@@ -177,6 +183,12 @@ class AppRoutes {
required UserAvatar avatar, required UserAvatar avatar,
bool overrideToSingleSubScreen = true, bool overrideToSingleSubScreen = true,
}) { }) {
// Local mark only. Server-side mark is sent later from
// ChatBloc._loadChat with the freshly-fetched maxId — sending one
// here too with the chat list's possibly-stale room.lastMessage.id
// would race the fresh one and could regress the server cursor.
context.read<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id);
NotificationTasks.clearNotificationsForChat(room.token);
TalkNavigator.pushSplitView( TalkNavigator.pushSplitView(
context, context,
ChatView(room: room, selfId: selfId, avatar: avatar), ChatView(room: room, selfId: selfId, avatar: avatar),
+208 -11
View File
@@ -1,15 +1,53 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../chat_list/bloc/chat_list_bloc.dart';
import '../repository/chat_repository.dart'; import '../repository/chat_repository.dart';
import 'chat_event.dart'; import 'chat_event.dart';
import 'chat_state.dart'; import 'chat_state.dart';
class ChatBloc class ChatBloc
extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository> { extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository>
with WidgetsBindingObserver {
final ChatListBloc? _chatListBloc;
String? _pollingToken;
int _backoffMs = 0;
int _lastKnownMessageId = 0;
bool _appResumed = true;
/// True only while a ChatView is mounted. Can't reuse `currentToken` —
/// clearing it on leaveChat races with setToken from didPopNext when
/// popping a stacked chat, causing spurious server read-markers on resume.
bool _chatViewActive = false;
bool get hasOpenChat => _chatViewActive;
DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0);
ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc {
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> close() {
WidgetsBinding.instance.removeObserver(this);
_stopLongPoll();
return super.close();
}
@override @override
ChatRepository repository() => ChatRepository(); ChatRepository repository() => ChatRepository();
@@ -33,24 +71,70 @@ class ChatBloc
} }
void setToken(String token) { void setToken(String token) {
_chatViewActive = true;
if (token == (innerState?.currentToken ?? '')) { if (token == (innerState?.currentToken ?? '')) {
refresh(); refresh();
return; return;
} }
_stopLongPoll();
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
add(RefetchStarted<ChatState>()); add(RefetchStarted<ChatState>());
_loadChat(token); _scheduleLoad(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
} }
void refresh() { void refresh() {
final token = innerState?.currentToken ?? ''; final token = innerState?.currentToken ?? '';
if (token.isEmpty) return; if (token.isEmpty) return;
add(RefetchStarted<ChatState>()); add(RefetchStarted<ChatState>());
_loadChat(token); _scheduleLoad(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
}
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
_stopLongPoll();
}
Future<void> sendServerReadMarker(String token, int messageId) async {
try {
await SetReadMarker(
token,
true,
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: messageId),
).run();
} on Object catch (e) {
log('Server read-marker for $token failed: $e');
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasResumed = _appResumed;
_appResumed = state == AppLifecycleState.resumed;
if (!_appResumed) {
_stopLongPoll();
return;
}
if (wasResumed) return;
final token = innerState?.currentToken ?? '';
if (token.isNotEmpty && _chatViewActive) refresh();
}
/// Microtask hop so the Bloc worker drains the preceding Emit before
/// any cache callback fires — a quick cache hit otherwise runs with
/// the previous token in state and fails stillCurrent().
void _scheduleLoad(String token) {
Future<void>.microtask(() {
if (isClosed) return;
_loadChat(token).then((_) => _startLongPoll(token));
});
} }
Future<void> _loadChat(String token) async { Future<void> _loadChat(String token) async {
@@ -69,14 +153,25 @@ class ChatBloc
token: token, token: token,
onCacheData: (data) { onCacheData: (data) {
if (!stillCurrent()) return; if (!stillCurrent()) return;
// Cache hit: show data immediately but preserve lastFetch — the // Skip cache paint over already-merged long-poll data — would
// cached payload may be stale and we don't want the UI to claim a // visibly drop those messages until the network call resolves.
// fresh fetch just happened. if (innerState?.chatResponse != null) return;
add(Emit((s) => s.copyWith(chatResponse: data))); add(Emit((s) => s.copyWith(chatResponse: data)));
}, },
onNetworkData: (data) { onNetworkData: (data) {
// Mark runs even if no longer current — otherwise a quick
// navigation away leaves the server cursor stale. Cache check
// skips the POST when the cursor is already at maxId.
final maxId = _maxMessageId(data);
if (maxId > 0) {
final cached = _chatListBloc?.lastReadMessageFor(token);
if (cached == null || cached < maxId) {
unawaited(sendServerReadMarker(token, maxId));
}
}
if (!stillCurrent()) return; if (!stillCurrent()) return;
add(DataGathered((s) => s.copyWith(chatResponse: data))); _applyChatResponse(data);
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
}, },
onError: (e) => capturedError = e, onError: (e) => capturedError = e,
); );
@@ -98,4 +193,106 @@ class ChatBloc
); );
} }
} }
void _startLongPoll(String token) {
if (!_appResumed) return;
if (_pollingToken == token) return;
_stopLongPoll();
_pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token));
} }
void _stopLongPoll() {
_pollingToken = null;
_backoffMs = 0;
}
Future<void> _pollLoop(String token) async {
while (_pollingToken == token && !isClosed) {
try {
final response = await LongPollChat(
chatToken: token,
lastKnownMessageId: _lastKnownMessageId,
).run();
if (_pollingToken != token || isClosed) return;
_backoffMs = 0;
if (response == null) continue;
final headerId = int.tryParse(
response.headers?[_kLongPollLastGivenHeader] ?? '',
);
if (headerId != null && headerId > _lastKnownMessageId) {
_lastKnownMessageId = headerId;
}
if (response.data.isEmpty) continue;
_applyChatResponse(response);
final maxId = _maxMessageId(response);
if (maxId > _lastKnownMessageId) _lastKnownMessageId = maxId;
// Long-poll's setReadMarker=on moved the server cursor; mirror locally.
final preview = _pickDisplayMessage(response);
if (preview != null) {
_chatListBloc?.applyIncomingMessage(token, preview);
} else {
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
}
} on Object catch (e) {
if (_pollingToken != token || isClosed) return;
log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs));
}
}
}
/// Dedups by id with newer-wins so server edits/deletes propagate.
void _applyChatResponse(GetChatResponse incoming) {
final current = innerState?.chatResponse;
if (current == null) {
add(DataGathered((s) => s.copyWith(chatResponse: incoming)));
return;
}
final byId = <int, GetChatResponseObject>{};
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in incoming.data) {
byId[m.id] = m;
}
final merged = GetChatResponse(byId.values.toSet())
..headers = incoming.headers;
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
}
int _maxMessageId(GetChatResponse? response) {
if (response == null) return 0;
var max = 0;
for (final m in response.data) {
if (m.id > max) max = m.id;
}
return max;
}
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
GetChatResponseObject? best;
for (final m in response.data) {
switch (m.messageType) {
case GetRoomResponseObjectMessageType.comment:
case GetRoomResponseObjectMessageType.voiceMessage:
if (best == null || m.id > best.id) best = m;
case GetRoomResponseObjectMessageType.deletedComment:
case GetRoomResponseObjectMessageType.system:
case GetRoomResponseObjectMessageType.command:
break;
}
}
return best;
}
}
const _kLongPollLastGivenHeader = 'x-chat-last-given';
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -15,6 +17,8 @@ class ChatListBloc
extends extends
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> { LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
bool _forceRenew = false; bool _forceRenew = false;
Timer? _autoRefreshTimer;
Duration? _autoRefreshInterval;
@override @override
void retry() { void retry() {
@@ -22,6 +26,25 @@ class ChatListBloc
super.retry(); super.retry();
} }
@override
Future<void> close() {
_autoRefreshTimer?.cancel();
return super.close();
}
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
_autoRefreshInterval = interval;
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
if (interval == null) return;
_autoRefreshTimer = Timer.periodic(interval, (_) {
if (isClosed) return;
refresh(silent: true);
});
}
@override @override
ChatListRepository repository() => ChatListRepository(); ChatListRepository repository() => ChatListRepository();
@@ -51,8 +74,8 @@ class ChatListBloc
if (capturedError != null) throw capturedError!; if (capturedError != null) throw capturedError!;
} }
Future<void> refresh({bool renew = true}) async { Future<void> refresh({bool renew = true, bool silent = false}) async {
add(RefetchStarted<ChatListState>()); if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError; Object? capturedError;
try { try {
final rooms = await repo.data.getRooms( final rooms = await repo.data.getRooms(
@@ -82,6 +105,65 @@ class ChatListBloc
await refresh(); await refresh();
} }
int? lastReadMessageFor(String token) {
final rooms = innerState?.rooms;
if (rooms == null) return null;
for (final room in rooms.data) {
if (room.token == token) return room.lastReadMessage;
}
return null;
}
/// Optimistic — server-side mark-as-read is the caller's job.
void markRoomAsRead(String token, int lastMessageId) {
_mutateRoom(token, (r) {
if (r.unreadMessages == 0 && r.lastReadMessage >= lastMessageId) {
return false;
}
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (lastMessageId > r.lastReadMessage) r.lastReadMessage = lastMessageId;
return true;
});
}
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
final wasRead =
r.unreadMessages == 0 && r.lastReadMessage >= message.id;
final hasNewer = r.lastMessage.id >= message.id;
if (wasRead && hasNewer) return false;
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (message.id > r.lastReadMessage) r.lastReadMessage = message.id;
if (message.id > r.lastMessage.id) r.lastMessage = message;
if (message.timestamp > r.lastActivity) r.lastActivity = message.timestamp;
return true;
});
}
/// Re-wraps in a fresh [GetRoomResponse] so identity-based equality picks it up.
void _mutateRoom(
String token,
bool Function(GetRoomResponseObject room) mutator,
) {
final rooms = innerState?.rooms;
if (rooms == null) return;
var changed = false;
final updated = rooms.data.map((r) {
if (r.token != token) return r;
if (mutator(r)) changed = true;
return r;
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}
void _updateAppBadge(GetRoomResponse rooms) { void _updateAppBadge(GetRoomResponse rooms) {
try { try {
final unread = rooms.data.fold<int>( final unread = rooms.data.fold<int>(
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
initialDescription: event.description, initialDescription: event.description,
initialStart: event.start, initialStart: event.start,
initialEnd: event.end, initialEnd: event.end,
initialAllDay: event.isAllDay,
), ),
barrierDismissible: false, barrierDismissible: false,
), ),
@@ -67,12 +67,12 @@ class AboutSection extends StatelessWidget {
applicationIcon: const Icon(Icons.apps), applicationIcon: const Icon(Icons.apps),
applicationName: 'MarianumMobile', applicationName: 'MarianumMobile',
applicationVersion: applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', '${appInfo.appName}\n\n${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild/Relase-nummer: ${appInfo.buildNumber}',
applicationLegalese: applicationLegalese:
'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' 'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
"${kReleaseMode ? "Production" : "Development"} build\n" "${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', 'Marianum Fulda 2019-2020, 2023-${Jiffy.now().year}\nElias Müller',
); );
} }
@@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget {
), ),
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)), leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'), title: const Text('Infos zu (Web) Untis'),
subtitle: const Text('Für den Stundenplan'), subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo( onTap: () => PrivacyInfo(
@@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget {
Icon(Icons.send_time_extension_outlined), Icon(Icons.send_time_extension_outlined),
), ),
title: const Text('Infos zu mhsl'), title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'), subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo( onTap: () => PrivacyInfo(
providerText: 'mhsl', providerText: 'mhsl',
+11 -15
View File
@@ -7,7 +7,6 @@ import '../../../notification/notify_updater.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -19,15 +18,13 @@ import 'search_chat.dart';
import 'widgets/chat_tile.dart'; import 'widgets/chat_tile.dart';
import 'widgets/split_view_placeholder.dart'; import 'widgets/split_view_placeholder.dart';
// Reads from the global ChatListBloc in main.dart — re-providing a local
// one here would shadow it and split the state in two.
class ChatList extends StatelessWidget { class ChatList extends StatelessWidget {
const ChatList({super.key}); const ChatList({super.key});
@override @override
Widget build(BuildContext context) => Widget build(BuildContext context) => const _ChatListView();
BlocModule<ChatListBloc, LoadableState<ChatListState>>(
create: (_) => ChatListBloc(),
child: (context, bloc, _) => const _ChatListView(),
);
} }
class _ChatListView extends StatefulWidget { class _ChatListView extends StatefulWidget {
@@ -65,14 +62,6 @@ class _ChatListViewState extends State<_ChatListView> {
final resolved = AppRoutes.resolvePendingChat(context); final resolved = AppRoutes.resolvePendingChat(context);
if (resolved == null) return; if (resolved == null) return;
AppRoutes.pendingChatToken.value = null; AppRoutes.pendingChatToken.value = null;
// Replace any chat already pushed on top of the chat list so a freshly
// tapped notification doesn't stack indefinitely on previous chats.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst);
}
AppRoutes.openChatView( AppRoutes.openChatView(
context, context,
room: resolved.room, room: resolved.room,
@@ -193,7 +182,14 @@ class _ChatListViewState extends State<_ChatListView> {
.talkSettings .talkSettings
.drafts .drafts
.containsKey(room.token); .containsKey(room.token);
return ChatTile(data: room, hasDraft: hasDraft); // Stable key keeps element identity across re-sorts so the
// inner UserAvatar reuses its cached bytes instead of
// flashing on every list update.
return ChatTile(
key: ValueKey(room.token),
data: room,
hasDraft: hasDraft,
);
}).toList(), }).toList(),
); );
}, },
+66 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -7,9 +8,12 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../extensions/date_time.dart'; import '../../../extensions/date_time.dart';
import '../../../notification/notification_tasks.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../theming/app_theme.dart'; import '../../../theming/app_theme.dart';
import '../../../widget/clickable_app_bar.dart'; import '../../../widget/clickable_app_bar.dart';
import '../../../widget/user_avatar.dart'; import '../../../widget/user_avatar.dart';
@@ -36,7 +40,7 @@ class ChatView extends StatefulWidget {
State<ChatView> createState() => _ChatViewState(); State<ChatView> createState() => _ChatViewState();
} }
class _ChatViewState extends State<ChatView> { class _ChatViewState extends State<ChatView> with RouteAware {
final ItemScrollController _itemScrollController = ItemScrollController(); final ItemScrollController _itemScrollController = ItemScrollController();
final TextEditingController _searchTextController = TextEditingController(); final TextEditingController _searchTextController = TextEditingController();
final Map<int, int> _matchIndices = {}; final Map<int, int> _matchIndices = {};
@@ -48,12 +52,73 @@ class _ChatViewState extends State<ChatView> {
GetChatResponse? _matchesComputedFor; GetChatResponse? _matchesComputedFor;
String? _matchesComputedQuery; 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 @override
void dispose() { void dispose() {
if (_subscribedRoute != null) {
AppRoutes.chatRouteObserver.unsubscribe(this);
}
_markAsReadFinal();
_chatBlocRef?.leaveChat(widget.room.token);
_searchTextController.dispose(); _searchTextController.dispose();
super.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 @override
void didUpdateWidget(covariant ChatView oldWidget) { void didUpdateWidget(covariant ChatView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -30,9 +30,6 @@ RichObjectString? _attachedFile(GetChatResponseObject bubbleData) {
return file; return file;
} }
/// Long-press / double-tap options dialog for a single chat message bubble.
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
/// this file owns the modal interactions (react, reply, copy, delete, ...).
void showChatMessageOptionsDialog( void showChatMessageOptionsDialog(
BuildContext context, { BuildContext context, {
required GetRoomResponseObject chatData, required GetRoomResponseObject chatData,
@@ -183,10 +180,12 @@ void _openOrCreateDirectChat(
} }
void switchToChat(GetRoomResponseObject room) { void switchToChat(GetRoomResponseObject room) {
// Pop the current ChatView before swapping the global ChatBloc token — // Pop the previous ChatView first — otherwise it stays in the
// otherwise the previous group chat stays mounted in the back-stack and // back-stack with a now-mismatched currentToken and renders empty
// would render empty after a back-swipe (currentToken no longer matches). // on back-swipe. Stop at popups so an open dialog stays alive.
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(
context,
).popUntil((route) => route.isFirst || route is PopupRoute);
AppRoutes.openChatByToken(context, room.token); AppRoutes.openChatByToken(context, room.token);
} }
+13 -16
View File
@@ -7,9 +7,10 @@ import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../../extensions/date_time.dart'; import '../../../../extensions/date_time.dart';
import '../../../../model/account_data.dart'; import '../../../../model/account_data.dart';
import '../../../../notification/notification_tasks.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart'; import '../../../../widget/async_action_button.dart';
@@ -17,7 +18,6 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/user_avatar.dart'; import '../../../../widget/user_avatar.dart';
import '../chat_view.dart';
import '../talk_navigator.dart'; import '../talk_navigator.dart';
class ChatTile extends StatefulWidget { class ChatTile extends StatefulWidget {
@@ -61,13 +61,11 @@ class _ChatTileState extends State<ChatTile> {
void _refreshList() => context.read<ChatListBloc>().refresh(); void _refreshList() => context.read<ChatListBloc>().refresh();
Future<void> _setCurrentAsRead() async { Future<void> _setCurrentAsRead() async {
await SetReadMarker( final token = widget.data.token;
widget.data.token, final lastId = widget.data.lastMessage.id;
true, context.read<ChatListBloc>().markRoomAsRead(token, lastId);
setReadMarkerParams: SetReadMarkerParams( unawaited(NotificationTasks.clearNotificationsForChat(token));
lastReadMessage: widget.data.lastMessage.id, await context.read<ChatBloc>().sendServerReadMarker(token, lastId);
),
).run();
if (!mounted) return; if (!mounted) return;
_refreshList(); _refreshList();
} }
@@ -154,18 +152,17 @@ class _ChatTileState extends State<ChatTile> {
return; return;
} }
if (selfUsername == null) return; if (selfUsername == null) return;
unawaited(_setCurrentAsRead()); // openChatView is the single entry point for opening a chat —
final view = ChatView( // it handles optimistic mark-as-read, tray cleanup, push, and
// setToken in one place so the notification-tap path gets the
// same treatment as a tile tap.
AppRoutes.openChatView(
context,
room: widget.data, room: widget.data,
selfId: selfUsername!, selfId: selfUsername!,
avatar: circleAvatar, avatar: circleAvatar,
);
TalkNavigator.pushSplitView(
context,
view,
overrideToSingleSubScreen: true, overrideToSingleSubScreen: true,
); );
context.read<ChatBloc>().setToken(widget.data.token);
}, },
onLongPress: () { onLongPress: () {
if (widget.disableContextActions) return; if (widget.disableContextActions) return;
@@ -78,34 +78,53 @@ class HighlightedLinkify extends StatefulWidget {
} }
class _HighlightedLinkifyState extends State<HighlightedLinkify> { class _HighlightedLinkifyState extends State<HighlightedLinkify> {
final List<TapGestureRecognizer> _recognizers = []; // 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 @override
void dispose() { void dispose() {
for (final r in _recognizers) { for (final r in _recognizers.values) {
r.dispose(); r.dispose();
} }
_recognizers.clear();
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
for (final r in _recognizers) { _seenLinkKeys.clear();
r.dispose();
}
_recognizers.clear();
final defaultStyle = widget.style ?? final defaultStyle = widget.style ??
Theme.of(context).textTheme.bodyMedium ?? Theme.of(context).textTheme.bodyMedium ??
DefaultTextStyle.of(context).style; DefaultTextStyle.of(context).style;
// Start from the surrounding text style so links inherit font family, // Default first, link style on top — reversing the merge silently
// size, weight, etc., then layer the link-specific color and underline // drops link color/underline because TextStyle.merge treats explicit
// on top. (Going the other way around — link style as base — used to // nulls in the overlay as "leave unchanged".
// work because TextStyle.copyWith treats `null` as "leave unchanged",
// so the explicit `color: null, decoration: null` were silently
// ignored and the merge pulled defaultStyle's color/decoration over
// the blue + underline. Result: links rendered in body-text color
// with no underline.)
final linkStyle = defaultStyle.merge( final linkStyle = defaultStyle.merge(
widget.linkStyle ?? widget.linkStyle ??
const TextStyle( const TextStyle(
@@ -124,9 +143,8 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
for (final el in elements) { for (final el in elements) {
if (el is LinkableElement) { if (el is LinkableElement) {
final recognizer = TapGestureRecognizer() _seenLinkKeys.add(el.text);
..onTap = () => widget.onOpen?.call(el); final recognizer = _recognizerFor(el);
_recognizers.add(recognizer);
spans.addAll( spans.addAll(
buildHighlightedSpans( buildHighlightedSpans(
text: el.text, text: el.text,
@@ -147,6 +165,8 @@ class _HighlightedLinkifyState extends State<HighlightedLinkify> {
} }
} }
_pruneUnseen();
return Text.rich(TextSpan(children: spans)); return Text.rich(TextSpan(children: spans));
} }
} }
@@ -19,6 +19,7 @@ class CustomEventEditDialog extends StatefulWidget {
final DateTime? initialEnd; final DateTime? initialEnd;
final String? initialTitle; final String? initialTitle;
final String? initialDescription; final String? initialDescription;
final bool? initialAllDay;
const CustomEventEditDialog({ const CustomEventEditDialog({
this.existingEvent, this.existingEvent,
@@ -26,6 +27,7 @@ class CustomEventEditDialog extends StatefulWidget {
this.initialEnd, this.initialEnd,
this.initialTitle, this.initialTitle,
this.initialDescription, this.initialDescription,
this.initialAllDay,
super.key, super.key,
}); });
@@ -78,13 +80,18 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
} }
return; return;
} }
_isAllDay = false; _isAllDay = widget.initialAllDay ?? false;
if (_isAllDay) {
_startTime = _defaultStart;
_endTime = _defaultEnd;
} else {
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd;
final clamped = _clampToVisibleWindow(rawStart, rawEnd); final clamped = _clampToVisibleWindow(rawStart, rawEnd);
_startTime = clamped.$1; _startTime = clamped.$1;
_endTime = clamped.$2; _endTime = clamped.$2;
} }
}
static (TimeOfDay, TimeOfDay) _clampToVisibleWindow( static (TimeOfDay, TimeOfDay) _clampToVisibleWindow(
TimeOfDay rawStart, TimeOfDay rawStart,
@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart';
@@ -11,7 +10,6 @@ import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/unimplemented_dialog.dart';
class WebuntisLessonSheet { class WebuntisLessonSheet {
static void show( static void show(
@@ -72,7 +70,7 @@ class WebuntisLessonSheet {
}).toList(), }).toList(),
), ),
_roomTile(context, state, lesson), _roomTile(context, state, lesson),
_teacherTile(context, lesson), _teacherTile(lesson),
if ((lesson.activityType ?? '').trim().isNotEmpty) if ((lesson.activityType ?? '').trim().isNotEmpty)
ListTile( ListTile(
leading: const Icon(Icons.abc), leading: const Icon(Icons.abc),
@@ -120,14 +118,15 @@ class WebuntisLessonSheet {
final name = firstNonEmpty([resolved.name, r.name, '?']); final name = firstNonEmpty([resolved.name, r.name, '?']);
final longname = firstNonEmpty([resolved.longName, r.longname, '']); final longname = firstNonEmpty([resolved.longName, r.longname, '']);
final building = resolved.building.trim(); final building = resolved.building.trim();
return LessonFormatter.formatLine( final main = LessonFormatter.formatLine(
name, name,
longname: longname,
extra: (building.isNotEmpty && building != '?') ? building : null, extra: (building.isNotEmpty && building != '?') ? building : null,
); );
final sub = (longname.isNotEmpty && longname != name) ? longname : null;
return (main: main, sub: sub);
}).toList(); }).toList();
return _listTile( return _listTileWithSubs(
icon: Icons.room, icon: Icons.room,
label: lesson.ro.length == 1 ? 'Raum' : 'Räume', label: lesson.ro.length == 1 ? 'Raum' : 'Räume',
entries: entries, entries: entries,
@@ -135,39 +134,63 @@ class WebuntisLessonSheet {
); );
} }
static Widget _teacherTile( static Widget _teacherTile(GetTimetableResponseObject lesson) {
BuildContext context,
GetTimetableResponseObject lesson,
) {
final trailing = Visibility(
visible: !kReleaseMode,
child: IconButton(
icon: const Icon(Icons.textsms_outlined),
onPressed: () => UnimplementedDialog.show(context),
),
);
if (lesson.te.isEmpty) { if (lesson.te.isEmpty) {
return ListTile( return const ListTile(
leading: const Icon(Icons.person), leading: Icon(Icons.person),
title: const Text('Lehrkraft: ?'), title: Text('Lehrkraft: ?'),
trailing: trailing,
); );
} }
final entries = lesson.te.map((t) { final entries = lesson.te.map((t) {
final base = LessonFormatter.formatLine( final main = LessonFormatter.formatLine(
t.name.isNotEmpty ? t.name : '?', t.name.isNotEmpty ? t.name : '?',
longname: t.longname, longname: t.longname,
); );
final orgname = (t.orgname ?? '').trim(); final orgname = (t.orgname ?? '').trim();
return orgname.isEmpty ? base : '$base · ehemals $orgname'; return (main: main, sub: orgname.isEmpty ? null : 'ehemals $orgname');
}).toList(); }).toList();
return _listTile( return _listTileWithSubs(
icon: Icons.person, icon: Icons.person,
label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte',
entries: entries, entries: entries,
);
}
static Widget _listTileWithSubs({
required IconData icon,
required String label,
required List<({String main, String? sub})> entries,
Widget? trailing,
}) {
if (entries.length == 1) {
final e = entries.first;
return ListTile(
leading: Icon(icon),
title: Text('$label: ${e.main}'),
subtitle: e.sub != null ? Text(e.sub!) : null,
trailing: trailing,
);
}
return ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: entries
.expand<Widget>(
(e) => [
Text(e.main),
if (e.sub != null)
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(e.sub!),
),
],
)
.toList(),
),
trailing: trailing, trailing: trailing,
); );
} }
@@ -37,40 +37,10 @@ class AppointmentTile extends StatelessWidget {
borderRadius: _radius, borderRadius: _radius,
color: color, color: color,
), ),
child: Column( child: _TileContent(
crossAxisAlignment: CrossAxisAlignment.stretch, title: appointment.subject,
mainAxisSize: MainAxisSize.max, description: description,
children: [ isCustom: isCustom,
_AdaptiveTitle(
text: appointment.subject,
fontSize: kAppointmentTitleFontSize,
minFontSize: kAppointmentTitleMinFontSize,
fontWeight: FontWeight.w500,
),
if (isCustom) ...[
if (description.isNotEmpty)
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: _WrappingBody(
text: description,
fontSize: kAppointmentBodyFontSize,
lineHeight: kAppointmentBodyLineHeight,
),
),
),
] else ...[
for (final line
in description
.split('\n')
.where((p) => p.isNotEmpty)
.take(2))
_ScaledLine(
text: line,
fontSize: kAppointmentBodyFontSize,
),
],
],
), ),
), ),
), ),
@@ -96,6 +66,91 @@ class AppointmentTile extends StatelessWidget {
} }
} }
/// Picks how many lines fit into the calendar slot's height. Title gets
/// first dibs; if not even one minimum-size title line fits, the column
/// collapses to keep the slot from overflowing.
class _TileContent extends StatelessWidget {
final String title;
final String description;
final bool isCustom;
const _TileContent({
required this.title,
required this.description,
required this.isCustom,
});
@override
Widget build(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final titleLineHeight = scaler.scale(kAppointmentTitleMinFontSize) * 1.1;
final bodyLineHeight = scaler.scale(kAppointmentBodyFontSize) * 1.1;
final titleWidget = _AdaptiveTitle(
text: title,
fontSize: kAppointmentTitleFontSize,
minFontSize: kAppointmentTitleMinFontSize,
fontWeight: FontWeight.w500,
);
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxHeight;
// Slot too short for even one min-size title line — drop text
// entirely; the coloured rectangle is enough.
if (available < titleLineHeight) return const SizedBox.shrink();
final remaining =
(available - titleLineHeight).clamp(0.0, double.infinity);
final bodyLineCapacity = (remaining / bodyLineHeight).floor();
if (isCustom) {
if (description.isEmpty || bodyLineCapacity <= 0) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [titleWidget],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max,
children: [
titleWidget,
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: _WrappingBody(
text: description,
fontSize: kAppointmentBodyFontSize,
lineHeight: kAppointmentBodyLineHeight,
),
),
),
],
);
}
final maxBodyLines = bodyLineCapacity.clamp(0, 2);
final lines = description
.split('\n')
.where((p) => p.isNotEmpty)
.take(maxBodyLines)
.toList(growable: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
titleWidget,
for (final line in lines)
_ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
],
);
},
);
}
}
/// Renders the appointment title. Scales down to fit the available width via /// Renders the appointment title. Scales down to fit the available width via
/// [FittedBox], but never below [minFontSize] — when even the minimum size /// [FittedBox], but never below [minFontSize] — when even the minimum size
/// overflows, the text is rendered at [minFontSize] with an ellipsis. /// overflows, the text is rendered at [minFontSize] with an ellipsis.
+27 -39
View File
@@ -26,9 +26,8 @@ class FileViewer extends StatefulWidget {
final String path; final String path;
final bool openExternal; final bool openExternal;
/// When set, enables the in-app actions "An Chat senden" and "In Dateien /// Enables in-app "An Chat senden" / "In Dateien speichern" — these
/// speichern" — these need a server-side reference, not the local cache /// need a server-side reference instead of the local cache path.
/// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer).
final RemoteFileRef? remoteFile; final RemoteFileRef? remoteFile;
const FileViewer({ const FileViewer({
@@ -56,8 +55,6 @@ const Set<String> _imageExtensions = {
'wbmp', 'wbmp',
}; };
/// Video container formats whose playback the platform decoders (ExoPlayer
/// on Android, AVPlayer on iOS) handle out of the box.
const Set<String> _videoExtensions = { const Set<String> _videoExtensions = {
'mp4', 'mp4',
'm4v', 'm4v',
@@ -67,9 +64,8 @@ const Set<String> _videoExtensions = {
'3gp', '3gp',
}; };
/// Audio formats playable through the same `video_player` pipeline. Some /// ogg/opus/flac are Android-only; iOS init errors fall through to the
/// (ogg/opus/flac) work on Android only — iOS will surface an init error /// "format not supported" message.
/// which we catch and surface as a friendly fallback.
const Set<String> _audioExtensions = { const Set<String> _audioExtensions = {
'mp3', 'mp3',
'm4a', 'm4a',
@@ -81,9 +77,7 @@ const Set<String> _audioExtensions = {
'opus', 'opus',
}; };
/// Extensions whose contents we render directly as plain text. Anything /// Unknown extensions still get a content sniff via [_looksLikeText].
/// outside this list still gets a content-based fallback check (see
/// [_looksLikeText]) so generic "what is this file" cases work too.
const Set<String> _textExtensions = { const Set<String> _textExtensions = {
'txt', 'md', 'markdown', 'rst', 'log', 'txt', 'md', 'markdown', 'rst', 'log',
'json', 'json5', 'xml', 'yaml', 'yml', 'toml', 'json', 'json5', 'xml', 'yaml', 'yml', 'toml',
@@ -104,10 +98,7 @@ const Set<String> _textExtensions = {
'srt', 'vtt', 'srt', 'vtt',
}; };
/// Reads up to 8 KB and decides whether the bytes look like UTF-8 text. /// 8 KB sniff: NUL bytes or non-UTF-8 sequences disqualify.
/// NUL bytes and non-decodable sequences disqualify the file. Used as a
/// fallback for unknown extensions so plain text files without a familiar
/// suffix still open in the in-app viewer.
Future<bool> _looksLikeText(String path) async { Future<bool> _looksLikeText(String path) async {
final file = File(path); final file = File(path);
RandomAccessFile? raf; RandomAccessFile? raf;
@@ -126,10 +117,8 @@ Future<bool> _looksLikeText(String path) async {
} }
} }
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal /// SfPdfViewer asserts on `localToGlobal` if mounted during the page-push
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an /// animation. Defer until the route enter animation completes.
/// ancestor RenderTransform (from the page-push animation) is still mid-layout.
/// We wait for the route's enter animation to complete before mounting it.
class _DeferredPdfViewer extends StatefulWidget { class _DeferredPdfViewer extends StatefulWidget {
const _DeferredPdfViewer({required this.path}); const _DeferredPdfViewer({required this.path});
final String path; final String path;
@@ -189,8 +178,6 @@ class _FileViewerState extends State<FileViewer> {
settings.val().fileViewSettings.alwaysOpenExternally || settings.val().fileViewSettings.alwaysOpenExternally ||
widget.openExternal; widget.openExternal;
if (openExternal) { if (openExternal) {
// Settings or popup explicitly chose "open externally" — fire and
// forget, then pop back. Same one-shot behaviour as the old viewer.
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => _openExternallyAndPop(), (_) => _openExternallyAndPop(),
); );
@@ -254,7 +241,22 @@ class _FileViewerState extends State<FileViewer> {
break; break;
case FileViewingActions.save: case FileViewingActions.save:
try { 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( final saved = await FilePicker.saveFile(
fileName: widget.path.split('/').last, fileName: widget.path.split('/').last,
bytes: bytes, bytes: bytes,
@@ -279,8 +281,6 @@ class _FileViewerState extends State<FileViewer> {
List<_ActionDescriptor> _availableActions() => [ List<_ActionDescriptor> _availableActions() => [
_ActionDescriptor( _ActionDescriptor(
action: FileViewingActions.openExternal, action: FileViewingActions.openExternal,
// iOS opens the system share sheet (square-with-arrow icon), Android
// the standard app picker; mirror that visually and verbally.
icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new, icon: Platform.isIOS ? Icons.ios_share : Icons.open_in_new,
label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit', label: Platform.isIOS ? 'Extern öffnen' : 'Öffnen mit',
), ),
@@ -440,8 +440,7 @@ class _FileViewerState extends State<FileViewer> {
} }
final payload = snapshot.data!; final payload = snapshot.data!;
final lines = const LineSplitter().convert(payload.content); final lines = const LineSplitter().convert(payload.content);
// Reserve gutter width by the digit count of the highest line number, // Stable gutter width — sized by the highest line number's digit count.
// so the gutter stays stable as the user scrolls down.
final gutterWidth = (lines.length.toString().length * 9.0) + 16; final gutterWidth = (lines.length.toString().length * 9.0) + 16;
return SelectionArea( return SelectionArea(
child: Scrollbar( child: Scrollbar(
@@ -545,8 +544,7 @@ class _FileViewerState extends State<FileViewer> {
final raf = await file.open(); final raf = await file.open();
try { try {
final bytes = await raf.read(_textViewMaxBytes); final bytes = await raf.read(_textViewMaxBytes);
// Truncated payloads cannot be reliably re-formatted (parser will // Truncated payloads stay raw — a parser would choke on the dangling tail.
// choke on the dangling tail), so they stay raw.
return _TextPayload( return _TextPayload(
content: utf8.decode(bytes, allowMalformed: true), content: utf8.decode(bytes, allowMalformed: true),
truncated: true, truncated: true,
@@ -556,9 +554,7 @@ class _FileViewerState extends State<FileViewer> {
} }
} }
/// Re-indents JSON so dumped/minified payloads from the server are easier /// Falls through to the original text on parse errors.
/// to read. Falls through to the original text on parse errors so we
/// never destroy the user's content.
String _maybePrettify(String content, String ext) { String _maybePrettify(String content, String ext) {
if (ext != 'json') return content; if (ext != 'json') return content;
try { try {
@@ -587,10 +583,6 @@ class _TextPayload {
const _TextPayload({required this.content, required this.truncated}); const _TextPayload({required this.content, required this.truncated});
} }
/// Plays back a local file via `video_player`. Renders the standard Chewie
/// controls for video files; audio files get a centered icon plus a custom
/// transport row (slider, time, play/pause), since Chewie's chrome is
/// designed around a video frame.
class _MediaPlayer extends StatefulWidget { class _MediaPlayer extends StatefulWidget {
final String path; final String path;
final bool isAudio; final bool isAudio;
@@ -780,10 +772,6 @@ class _AudioControls extends StatelessWidget {
} }
} }
/// One row in the text viewer: line number on the left (not selectable so
/// it never ends up in copied selections), monospace content on the right.
/// Odd-numbered lines get a slightly tinted background so long files are
/// easier to scan.
class _CodeLine extends StatelessWidget { class _CodeLine extends StatelessWidget {
final int number; final int number;
final String text; final String text;
+62 -20
View File
@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -29,15 +30,48 @@ class _AvatarPayload {
_AvatarPayload(this.bytes, this.isSvg); _AvatarPayload(this.bytes, this.isSvg);
} }
final Map<String, Future<_AvatarPayload?>> _avatarCache = {}; class _AvatarCacheEntry {
final _AvatarPayload? payload;
final DateTime fetchedAt;
_AvatarCacheEntry(this.payload, this.fetchedAt);
}
// LRU via LinkedHashMap insertion order + remove-on-hit. TTL so
// server-side avatar updates become visible within a session.
const int _kAvatarCacheMax = 256;
const Duration _kAvatarCacheTtl = Duration(minutes: 30);
// Pending map dedups concurrent mounts onto a single HTTP call.
final LinkedHashMap<String, _AvatarCacheEntry> _resolvedAvatars =
LinkedHashMap<String, _AvatarCacheEntry>();
final Map<String, Future<_AvatarPayload?>> _pendingAvatars = {};
_AvatarCacheEntry? _readAvatarCache(String url) {
final entry = _resolvedAvatars.remove(url);
if (entry == null) return null;
if (DateTime.now().difference(entry.fetchedAt) > _kAvatarCacheTtl) {
return null;
}
// Re-insert at the tail so it counts as most-recently-used.
_resolvedAvatars[url] = entry;
return entry;
}
void _writeAvatarCache(String url, _AvatarPayload? payload) {
_resolvedAvatars.remove(url);
_resolvedAvatars[url] = _AvatarCacheEntry(payload, DateTime.now());
while (_resolvedAvatars.length > _kAvatarCacheMax) {
_resolvedAvatars.remove(_resolvedAvatars.keys.first);
}
}
class _UserAvatarState extends State<UserAvatar> { class _UserAvatarState extends State<UserAvatar> {
late Future<_AvatarPayload?> _payload; _AvatarPayload? _payload;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_payload = _load(); _attach();
} }
@override @override
@@ -46,7 +80,7 @@ class _UserAvatarState extends State<UserAvatar> {
if (oldWidget.id != widget.id || if (oldWidget.id != widget.id ||
oldWidget.isGroup != widget.isGroup || oldWidget.isGroup != widget.isGroup ||
oldWidget.size != widget.size) { oldWidget.size != widget.size) {
_payload = _load(); _attach();
} }
} }
@@ -58,9 +92,21 @@ class _UserAvatarState extends State<UserAvatar> {
return 'https://$host/avatar/${widget.id}/${widget.size}'; return 'https://$host/avatar/${widget.id}/${widget.size}';
} }
Future<_AvatarPayload?> _load() { void _attach() {
final url = _url(); final url = _url();
return _avatarCache.putIfAbsent(url, () => _fetch(url)); final cached = _readAvatarCache(url);
if (cached != null) {
_payload = cached.payload;
return;
}
_payload = null;
final pending = _pendingAvatars.putIfAbsent(url, () => _fetch(url));
pending.then((p) {
_writeAvatarCache(url, p);
_pendingAvatars.remove(url);
if (!mounted || _url() != url) return;
setState(() => _payload = p);
});
} }
Future<_AvatarPayload?> _fetch(String url) async { Future<_AvatarPayload?> _fetch(String url) async {
@@ -97,20 +143,11 @@ class _UserAvatarState extends State<UserAvatar> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final radius = widget.size.toDouble(); final radius = widget.size.toDouble();
final theme = Theme.of(context); final theme = Theme.of(context);
final payload = _payload;
return FutureBuilder<_AvatarPayload?>(
future: _payload,
builder: (context, snapshot) {
final payload = snapshot.data;
Widget content; Widget content;
if (payload == null) { if (payload != null) {
content = Icon( if (payload.isSvg) {
widget.isGroup ? Icons.group : Icons.person,
size: radius,
color: Colors.white,
);
} else if (payload.isSvg) {
content = SvgPicture.memory( content = SvgPicture.memory(
payload.bytes, payload.bytes,
width: radius * 2, width: radius * 2,
@@ -126,6 +163,13 @@ class _UserAvatarState extends State<UserAvatar> {
gaplessPlayback: true, gaplessPlayback: true,
); );
} }
} else {
content = Icon(
widget.isGroup ? Icons.group : Icons.person,
size: radius,
color: Colors.white,
);
}
return CircleAvatar( return CircleAvatar(
radius: radius, radius: radius,
@@ -139,7 +183,5 @@ class _UserAvatarState extends State<UserAvatar> {
), ),
), ),
); );
},
);
} }
} }
+5
View File
@@ -40,6 +40,11 @@ dependencies:
flutter_linkify: ^6.0.0 flutter_linkify: ^6.0.0
linkify: ^5.0.0 linkify: ^5.0.0
flutter_local_notifications: ^21.0.0 flutter_local_notifications: ^21.0.0
# Cancels FCM-rendered notifications by their server-set tag
# (Android NotificationManager.cancel, iOS removeDeliveredNotifications via
# apns-collapse-id). Used to dismiss a chat's notification when the user
# opens or marks the chat read.
eraser: ^3.0.0
scrollable_positioned_list: ^0.3.8 scrollable_positioned_list: ^0.3.8
flutter_split_view: ^0.1.2 flutter_split_view: ^0.1.2
flutter_svg: ^2.0.10 flutter_svg: ^2.0.10