418 lines
14 KiB
Dart
418 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
|
|
import 'package:url_launcher/url_launcher_string.dart';
|
|
|
|
import '../api/marianumcloud/talk/room/get_room_response.dart';
|
|
import '../api/marianumconnect/marianumconnect_endpoint.dart';
|
|
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.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';
|
|
import '../state/app/modules/chat/bloc/chat_bloc.dart';
|
|
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
|
import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
|
import '../view/pages/files/files.dart';
|
|
import '../view/pages/files/sharing/sharee_picker_page.dart';
|
|
import '../view/pages/foreign_timetable/element_picker_page.dart';
|
|
import '../view/pages/marianum_message/marianum_message_view.dart';
|
|
import '../view/pages/more/feedback/feedback_dialog.dart';
|
|
import '../view/pages/more/roomplan/roomplan.dart';
|
|
import '../view/pages/more/share/qr_share_view.dart';
|
|
import '../view/pages/settings/chat_background_settings_page.dart';
|
|
import '../view/pages/settings/modules_settings_page.dart';
|
|
import '../view/pages/settings/settings.dart';
|
|
import '../view/pages/share_intent/share_chat_picker.dart';
|
|
import '../view/pages/share_intent/share_folder_picker.dart';
|
|
import '../view/pages/share_intent/share_target_page.dart';
|
|
import '../view/pages/talk/chat_view.dart';
|
|
import '../view/pages/talk/details/message_reactions.dart';
|
|
import '../view/pages/talk/talk_navigator.dart';
|
|
import '../view/pages/ticker/ticker_page_view.dart';
|
|
import '../view/pages/timetable/custom_events/custom_events_view.dart';
|
|
import '../widget/debug/cache_view.dart';
|
|
import '../widget/file_viewer.dart';
|
|
import '../widget/user_avatar.dart';
|
|
|
|
/// Single entry point for full-page navigations. Dialogs and bottom sheets
|
|
/// stay at the call sites; only full-page pushes go through here.
|
|
class AppRoutes {
|
|
AppRoutes._();
|
|
|
|
/// Set by [openChatByToken] (e.g. from a tapped notification) and consumed
|
|
/// 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>>();
|
|
|
|
/// Root navigator key, set on [MaterialApp]. Lets globally-mounted UI (e.g.
|
|
/// the downloads tray, which lives above the navigator in `MaterialApp.builder`
|
|
/// and is therefore never covered by a pushed route) open full-page routes.
|
|
static final GlobalKey<NavigatorState> rootNavigatorKey =
|
|
GlobalKey<NavigatorState>();
|
|
|
|
/// A context that is a descendant of the root navigator (its overlay), safe to
|
|
/// pass to `pushScreen`/`showModalBottomSheet` from outside the route tree.
|
|
static BuildContext? get overlayContext =>
|
|
rootNavigatorKey.currentState?.overlay?.context;
|
|
|
|
static void openFolder(BuildContext context, List<String> path) {
|
|
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
|
}
|
|
|
|
static void openFileViewer(
|
|
BuildContext context,
|
|
String localPath, {
|
|
bool openExternal = false,
|
|
RemoteFileRef? remoteFile,
|
|
}) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: FileViewer(
|
|
path: localPath,
|
|
openExternal: openExternal,
|
|
remoteFile: remoteFile,
|
|
),
|
|
);
|
|
}
|
|
|
|
static void openCustomEvents(BuildContext context) {
|
|
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
|
}
|
|
|
|
/// Opens the picker for choosing a foreign timetable element and resolves to
|
|
/// the selected element (or null if dismissed). The timetable view renders
|
|
/// the chosen plan inline. Gated behind the `viewForeignTimetables`
|
|
/// capability at the call site.
|
|
static Future<TimetableElementRef?> openElementPicker(
|
|
BuildContext context,
|
|
) async {
|
|
// pushScreen casts its internal MaterialPageRoute to `Route<T>`, which
|
|
// blows up for a concrete (record) T — so push untyped (T = dynamic, like
|
|
// every other caller) and cast the popped result ourselves.
|
|
final result = await pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: const ElementPickerPage(),
|
|
);
|
|
return result as TimetableElementRef?;
|
|
}
|
|
|
|
/// Opens the user/group picker for sharing a file and resolves to the chosen
|
|
/// recipient (or null if dismissed). Which kinds are offered is gated by the
|
|
/// Nextcloud sharing capabilities at the call site.
|
|
static Future<ShareeRef?> openShareePicker(
|
|
BuildContext context, {
|
|
required bool allowUsers,
|
|
required bool allowGroups,
|
|
}) async {
|
|
// Push untyped (T = dynamic) and cast the popped result ourselves —
|
|
// pushScreen can't carry a concrete non-Widget T (see openElementPicker).
|
|
final result = await pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: ShareePickerPage(
|
|
allowUsers: allowUsers,
|
|
allowGroups: allowGroups,
|
|
),
|
|
);
|
|
return result as ShareeRef?;
|
|
}
|
|
|
|
static void openMarianumMessage(
|
|
BuildContext context,
|
|
String basePath,
|
|
MarianumMessage message,
|
|
) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: MessageView(basePath: basePath, message: message),
|
|
);
|
|
}
|
|
|
|
/// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail
|
|
/// screen. Used for deep links from outside the ticker module — inside the
|
|
/// module pages open in-place instead.
|
|
static void openTickerPage(
|
|
BuildContext context, {
|
|
required String slug,
|
|
String? title,
|
|
}) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: TickerPageView(slug: slug, title: title),
|
|
);
|
|
}
|
|
|
|
/// Resolves a link tapped inside ticker content. Links that point at another
|
|
/// ticker page (absolute against the active base URL, or site-relative
|
|
/// `/ticker/p/{slug}`) open in-app; everything else goes to the external
|
|
/// launcher.
|
|
static void openTickerLink(BuildContext context, String href) {
|
|
final slug = tickerSlugOf(href);
|
|
if (slug != null && slug.isNotEmpty) {
|
|
openTickerPage(context, slug: slug);
|
|
return;
|
|
}
|
|
unawaited(openExternalUrl(href));
|
|
}
|
|
|
|
/// Extracts the ticker page slug from a link that points at another ticker
|
|
/// page (absolute against the active base URL, or site-relative
|
|
/// `/ticker/p/{slug}`), or null for any other link. Exposed so the in-place
|
|
/// ticker navigation can reuse the same detection instead of duplicating it.
|
|
static String? tickerSlugOf(String href) {
|
|
const marker = '/ticker/p/';
|
|
final base = MarianumConnectEndpoint.current();
|
|
String? rest;
|
|
if (href.startsWith('$base$marker')) {
|
|
rest = href.substring('$base$marker'.length);
|
|
} else if (href.startsWith(marker)) {
|
|
rest = href.substring(marker.length);
|
|
}
|
|
if (rest == null) return null;
|
|
rest = rest.split('#').first.split('?').first;
|
|
if (rest.isEmpty) return null;
|
|
return Uri.decodeComponent(rest);
|
|
}
|
|
|
|
/// Launches an external URL, restricted to safe schemes (http/https/mailto/tel).
|
|
static Future<void> openExternalUrl(String url) async {
|
|
final uri = Uri.tryParse(url);
|
|
if (uri == null) return;
|
|
const allowed = {'http', 'https', 'mailto', 'tel'};
|
|
if (!allowed.contains(uri.scheme.toLowerCase())) return;
|
|
if (await canLaunchUrlString(url)) {
|
|
await launchUrlString(url, mode: LaunchMode.externalApplication);
|
|
}
|
|
}
|
|
|
|
/// Opens a possibly site-relative web URL (e.g. the ticker `webUrl`) by
|
|
/// prefixing the active Marianum-Connect base URL when needed.
|
|
static Future<void> openWebUrl(String relativeOrAbsolute) {
|
|
if (relativeOrAbsolute.startsWith('http')) {
|
|
return openExternalUrl(relativeOrAbsolute);
|
|
}
|
|
return openExternalUrl(
|
|
'${MarianumConnectEndpoint.current()}$relativeOrAbsolute',
|
|
);
|
|
}
|
|
|
|
static void openQrShare(BuildContext context) {
|
|
pushScreen(context, withNavBar: false, screen: const QrShareView());
|
|
}
|
|
|
|
static void openSettings(BuildContext context) {
|
|
pushScreen(context, withNavBar: false, screen: const Settings());
|
|
}
|
|
|
|
static void openModulesSettings(BuildContext context) {
|
|
pushScreen(context, withNavBar: false, screen: const ModulesSettingsPage());
|
|
}
|
|
|
|
static void openChatBackgroundSettings(BuildContext context) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: const ChatBackgroundSettingsPage(),
|
|
);
|
|
}
|
|
|
|
static void openFeedback(BuildContext context) {
|
|
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
|
|
}
|
|
|
|
static void openCacheView(BuildContext context) {
|
|
pushScreen(context, withNavBar: false, screen: const CacheView());
|
|
}
|
|
|
|
static void openRoomplan(BuildContext context) {
|
|
pushScreen(context, withNavBar: false, screen: const Roomplan());
|
|
}
|
|
|
|
static void openShareTarget(BuildContext context, PendingShare share) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: ShareTargetPage(share: share),
|
|
);
|
|
}
|
|
|
|
static void openShareChatPicker(BuildContext context, PendingShare share) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: ShareChatPicker.forExternalShare(share: share),
|
|
);
|
|
}
|
|
|
|
static void openShareFolderPicker(BuildContext context, PendingShare share) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: ShareFolderPicker.forExternalShare(share: share),
|
|
);
|
|
}
|
|
|
|
static void openInternalShareToChat(
|
|
BuildContext context,
|
|
RemoteFileRef file,
|
|
) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: ShareChatPicker.forInternalShare(file: file),
|
|
);
|
|
}
|
|
|
|
static void openForwardMessageToChat(
|
|
BuildContext context, {
|
|
String? text,
|
|
RemoteFileRef? file,
|
|
}) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: ShareChatPicker.forMessageForward(text: text, file: file),
|
|
);
|
|
}
|
|
|
|
static void openInternalSaveToFolder(
|
|
BuildContext context,
|
|
RemoteFileRef file,
|
|
) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: ShareFolderPicker.forInternalSave(file: file),
|
|
);
|
|
}
|
|
|
|
static void openMessageReactions(
|
|
BuildContext context,
|
|
String token,
|
|
int messageId,
|
|
) {
|
|
pushScreen(
|
|
context,
|
|
withNavBar: false,
|
|
screen: MessageReactions(token: token, messageId: messageId),
|
|
);
|
|
}
|
|
|
|
static void openChatView(
|
|
BuildContext context, {
|
|
required GetRoomResponseObject room,
|
|
required String selfId,
|
|
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),
|
|
overrideToSingleSubScreen: overrideToSingleSubScreen,
|
|
);
|
|
context.read<ChatBloc>().setToken(room.token);
|
|
}
|
|
|
|
/// Schedules a chat to be opened in the Talk tab once the room is loaded.
|
|
/// Use when only the token is known (e.g. from a tapped notification).
|
|
static void openChatByToken(BuildContext context, String token) {
|
|
pendingChatToken.value = token;
|
|
goToTalkTab(context);
|
|
try {
|
|
context.read<ChatListBloc>().refresh();
|
|
} catch (e) {
|
|
if (kDebugMode) {
|
|
debugPrint('openChatByToken: ChatListBloc refresh failed: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolves a pending chat token (set via [openChatByToken]). Returns null
|
|
/// if the room or account is not ready yet — callers should retry when
|
|
/// [pendingChatToken] or the bloc state change.
|
|
static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
|
|
final token = pendingChatToken.value;
|
|
if (token == null) return null;
|
|
if (!AccountData().isPopulated()) return null;
|
|
|
|
final rooms = context.read<ChatListBloc>().state.data?.rooms;
|
|
final room = _findRoomByToken(rooms, token);
|
|
if (room == null) return null;
|
|
|
|
final isGroup = room.type != GetRoomResponseObjectConversationType.oneToOne;
|
|
final avatar = UserAvatar(
|
|
id: isGroup ? room.token : room.name,
|
|
isGroup: isGroup,
|
|
);
|
|
return ResolvedPendingChat(
|
|
room: room,
|
|
selfId: AccountData().getUsername(),
|
|
avatar: avatar,
|
|
);
|
|
}
|
|
|
|
static GetRoomResponseObject? _findRoomByToken(
|
|
GetRoomResponse? rooms,
|
|
String token,
|
|
) {
|
|
if (rooms == null) return null;
|
|
for (final room in rooms.data) {
|
|
if (room.token == token) return room;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Pushes a module from the "Mehr" tab list. Modules already in the bottom
|
|
/// bar are switched to via [goToTab] instead.
|
|
static void openModule(BuildContext context, AppModule module) {
|
|
pushScreen(context, withNavBar: false, screen: module.create());
|
|
}
|
|
|
|
/// Switches the bottom navigation to [module]. Returns false when the
|
|
/// module is not currently in the bottom bar.
|
|
static bool goToTab(BuildContext context, Modules module) {
|
|
final index = AppModule.getBottomBarModules(
|
|
context,
|
|
).map((e) => e.module).toList().indexOf(module);
|
|
if (index == -1) return false;
|
|
Main.bottomNavigator.jumpToTab(index);
|
|
return true;
|
|
}
|
|
|
|
static void goToTalkTab(BuildContext context) {
|
|
goToTab(context, Modules.talk);
|
|
}
|
|
}
|
|
|
|
class ResolvedPendingChat {
|
|
final GetRoomResponseObject room;
|
|
final String selfId;
|
|
final UserAvatar avatar;
|
|
|
|
const ResolvedPendingChat({
|
|
required this.room,
|
|
required this.selfId,
|
|
required this.avatar,
|
|
});
|
|
}
|