Files
Client/lib/state/app/modules/app_modules.dart
T

310 lines
10 KiB
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 '../../../access/access_requirement.dart';
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import '../../../routing/app_routes.dart';
import '../../../session/session.dart';
import '../../../session/session_manager.dart';
import '../../../storage/modules_settings.dart';
import '../../../view/pages/absence_report/absence_report_view.dart';
import '../../../view/pages/files/files.dart';
import '../../../view/pages/grade_averages/grade_averages_view.dart';
import '../../../view/pages/holidays/holidays_view.dart';
import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
import '../../../view/pages/more/roomplan/roomplan.dart';
import '../../../view/pages/parent_letters/parent_letters_view.dart';
import '../../../view/pages/talk/chat_list.dart';
import '../../../view/pages/ticker/ticker_view.dart';
import '../../../view/pages/timetable/timetable.dart';
import '../../../widget/breaker/breaker.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/module_badge_icon.dart';
import '../infrastructure/loadable_state/loadable_state.dart';
import 'chat_list/bloc/chat_list_bloc.dart';
import 'chat_list/bloc/chat_list_state.dart';
import 'parent_letters/bloc/parent_letters_bloc.dart';
import 'parent_letters/bloc/parent_letters_state.dart';
import 'settings/bloc/settings_cubit.dart';
class AppModule {
Modules module;
String name;
Widget Function() icon;
BreakerArea breakerArea;
Widget Function() create;
AppModule(
this.module, {
required this.name,
required this.icon,
this.breakerArea = BreakerArea.global,
required this.create,
});
/// Backend identities each module needs. Modules without an entry work for
/// every session.
static const Map<Modules, Set<AccessRequirement>> requirements = {
Modules.talk: {AccessRequirement.nextcloud},
Modules.files: {AccessRequirement.nextcloud},
Modules.parentLetters: {AccessRequirement.guardian},
};
static bool isAvailableFor(Modules module, Session? session) =>
(requirements[module] ?? const {}).areMetBy(session);
static Map<Modules, AppModule> modules(
BuildContext context, {
bool showFiltered = false,
}) {
final settings = context.read<SettingsCubit>();
var available = {
Modules.timetable: AppModule(
Modules.timetable,
name: 'Stundenplan',
icon: () => Icon(Icons.calendar_month),
breakerArea: BreakerArea.timetable,
create: Timetable.new,
),
Modules.parentLetters: AppModule(
Modules.parentLetters,
name: 'Elternbriefe',
icon: () =>
BlocBuilder<ParentLettersBloc, LoadableState<ParentLettersState>>(
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.mail_outline,
count: state.data?.unreadCount ?? 0,
),
),
create: ParentLettersView.new,
),
Modules.ticker: AppModule(
Modules.ticker,
name: 'Ticker',
// Icons.newspaper is already taken by the "Marianum Message" module;
// use campaign to keep the two visually distinct.
icon: () => Icon(Icons.campaign),
breakerArea: BreakerArea.ticker,
create: TickerView.new,
),
Modules.talk: AppModule(
Modules.talk,
name: 'Talk',
icon: () => BlocBuilder<ChatListBloc, LoadableState<ChatListState>>(
builder: (context, state) => ModuleBadgeIcon(
icon: Icons.chat,
count:
state.data?.rooms?.data.fold<int>(
0,
(sum, room) => sum + room.unreadMessages,
) ??
0,
),
),
breakerArea: BreakerArea.talk,
create: ChatList.new,
),
Modules.files: AppModule(
Modules.files,
name: 'Dateien',
icon: () => Icon(Icons.folder),
breakerArea: BreakerArea.files,
create: Files.new,
),
Modules.marianumMessage: AppModule(
Modules.marianumMessage,
name: 'Marianum Message',
icon: () => Icon(Icons.newspaper),
breakerArea: BreakerArea.news,
create: MarianumMessageListView.new,
),
Modules.roomPlan: AppModule(
Modules.roomPlan,
name: 'Raumplan',
icon: () => Icon(Icons.location_pin),
breakerArea: BreakerArea.roomPlan,
create: Roomplan.new,
),
Modules.gradeAveragesCalculator: AppModule(
Modules.gradeAveragesCalculator,
name: 'Notendurchschnittsrechner',
icon: () => Icon(Icons.calculate),
breakerArea: BreakerArea.grades,
create: GradeAveragesView.new,
),
Modules.holidays: AppModule(
Modules.holidays,
name: 'Schulferien',
icon: () => Icon(Icons.beach_access_outlined),
breakerArea: BreakerArea.holidays,
create: HolidaysView.new,
),
Modules.marianumDates: AppModule(
Modules.marianumDates,
name: 'Marianum Termine',
icon: () => Icon(Icons.event_note),
breakerArea: BreakerArea.dates,
create: MarianumDatesView.new,
),
Modules.absenceReport: AppModule(
Modules.absenceReport,
name: 'Abwesenheitsmeldung',
icon: () => Icon(Icons.sick_outlined),
create: AbsenceReportView.new,
),
};
final session = SessionManager().current;
available.removeWhere((key, _) => !isAvailableFor(key, session));
if (!showFiltered) {
available.removeWhere(
(key, value) =>
settings.val().modulesSettings.hiddenModules.contains(key),
);
}
return {
for (var element in effectiveModuleOrder(
settings.val().modulesSettings,
).where((element) => available.containsKey(element)))
element: available[element]!,
};
}
// A persisted moduleOrder predates modules added in newer app versions.
// Deriving the effective order at read time (instead of relying on a heal
// during hydration) makes it impossible for a module to vanish from the
// bottom bar, the "Mehr" menu and the settings list at once. Missing
// modules are inserted right after their closest declared predecessor so
// they land at their default position (e.g. the ticker between Stundenplan
// and Talk) instead of at the end of the "Mehr" menu.
static List<Modules> effectiveModuleOrder(ModulesSettings settings) {
final seen = <Modules>{};
final order = [
for (final m in settings.moduleOrder)
if (seen.add(m)) m,
];
for (final missing in Modules.values) {
if (!seen.add(missing)) continue;
var insertAt = 0;
for (final predecessor in Modules.values.takeWhile((m) => m != missing)) {
final pos = order.indexOf(predecessor);
if (pos >= insertAt) insertAt = pos + 1;
}
order.insert(insertAt, missing);
}
return order;
}
// The settings list displays a capability-filtered subset, so reorder
// indices refer to that subset; the move is applied there and merged back
// into the full order (non-displayed modules keep their relative slots).
static List<Modules> reorderModuleOrder({
required List<Modules> displayed,
required List<Modules> effective,
required int oldIndex,
required int newIndex,
}) {
final moved = List<Modules>.from(displayed);
moved.insert(newIndex, moved.removeAt(oldIndex));
final displayedSet = displayed.toSet();
var moveIndex = 0;
return [
for (final m in effective)
if (displayedSet.contains(m)) moved[moveIndex++] else m,
];
}
static const int minBottomBarSlots = 3;
static const int maxBottomBarSlots = 5;
static int resolveBottomBarSlotCount(BuildContext context) {
final settings = context.read<SettingsCubit>().val().modulesSettings;
final available = modules(context).length;
int desired;
if (settings.autoFillBottomBar) {
// 4 content tabs so the bar shows 5 entries including "Mehr" — enough
// for the Ticker module without pushing "Dateien" into the overflow.
desired = 4;
} else {
desired = settings.fixedBottomBarSlots;
}
desired = desired.clamp(minBottomBarSlots, maxBottomBarSlots);
return desired.clamp(0, available);
}
static List<AppModule> getBottomBarModules(BuildContext context) {
final all = modules(context).values.toList();
final slots = resolveBottomBarSlotCount(context);
return all.take(slots).toList();
}
static List<AppModule> getOverhangModules(BuildContext context) {
final all = modules(context).values.toList();
final slots = resolveBottomBarSlotCount(context);
return all.skip(slots).toList();
}
Widget toListTile(
BuildContext context, {
Key? key,
bool isReorder = false,
Function()? onVisibleChange,
bool isVisible = true,
}) => ListTile(
key: key,
leading: CenteredLeading(icon()),
title: Text(name),
onTap: isReorder ? null : () => AppRoutes.openModule(context, this),
trailing: isReorder
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden',
onPressed: onVisibleChange,
icon: Icon(
isVisible
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
Icon(Icons.drag_handle_outlined),
],
)
: const Icon(Icons.arrow_right),
);
PersistentTabConfig toBottomTab(
BuildContext context, {
Widget Function(IconData icon)? iconBuilder,
}) => PersistentTabConfig(
screen: Breaker(breaker: breakerArea, child: create()),
item: ItemConfig(
activeForegroundColor: Theme.of(context).primaryColor,
inactiveForegroundColor: Theme.of(context).colorScheme.secondary,
icon: icon(),
title: name,
),
);
}
enum Modules {
timetable,
parentLetters,
ticker,
talk,
files,
marianumMessage,
roomPlan,
gradeAveragesCalculator,
holidays,
marianumDates,
absenceReport,
}