Files
Client/lib/routing/app_routes.dart
T

465 lines
16 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 '../view/pages/timetable/subject_colors/subject_colors_view.dart';
import '../widget/avatar_crop_page.dart';
import '../widget/debug/cache_view.dart';
import '../widget/file_viewer.dart';
import '../widget/large_profile_picture_view.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());
}
static void openSubjectColors(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const SubjectColorsView());
}
/// Opens the full-screen cropper on [imageBytes] and resolves to the cropped
/// bytes (or null if cancelled). [aspectRatio] defaults to 1:1 for avatars;
/// pass null for a free-form crop (e.g. chat backgrounds).
static Future<Uint8List?> openAvatarCrop(
BuildContext context, {
required Uint8List imageBytes,
double? aspectRatio = 1,
}) {
return Navigator.of(context).push<Uint8List>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) =>
AvatarCropPage(imageBytes: imageBytes, aspectRatio: aspectRatio),
),
);
}
/// Opens the tappable, zoomable profile-picture viewer for [id].
static void openLargeProfilePicture(BuildContext context, String id) {
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => LargeProfilePictureView(id: id)),
);
}
/// 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,
MarianumMessage message,
) {
pushScreen(
context,
withNavBar: false,
screen: MessageView(id: message.id, title: message.name),
);
}
/// Opens a Marianum Message by id — used for push deep links where only the
/// id (and the notification title) are known.
static void openNewsletterById(
BuildContext context, {
required String id,
String? title,
}) {
pushScreen(
context,
withNavBar: false,
screen: MessageView(
id: id,
title: title == null || title.isEmpty ? 'Marianum Message' : title,
),
);
}
/// 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,
).indexWhere((e) => e.module == 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,
});
}