import 'package:badges/badges.dart' as badges; 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 '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import '../../../routing/app_routes.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/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 '../infrastructure/loadable_state/loadable_state.dart'; import 'chat_list/bloc/chat_list_bloc.dart'; import 'chat_list/bloc/chat_list_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, }); static Map modules( BuildContext context, { bool showFiltered = false, }) { final settings = context.read(); var available = { Modules.timetable: AppModule( Modules.timetable, name: 'Stundenplan', icon: () => Icon(Icons.calendar_month), breakerArea: BreakerArea.timetable, create: Timetable.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>( builder: (context, state) { final rooms = state.data?.rooms; if (rooms == null || rooms.data.isEmpty) { return const Icon(Icons.chat); } final messages = rooms.data .map((e) => e.unreadMessages) .reduce((a, b) => a + b); return badges.Badge( showBadge: messages > 0, position: badges.BadgePosition.topEnd(top: -3, end: -3), stackFit: StackFit.loose, badgeStyle: badges.BadgeStyle( padding: const EdgeInsets.all(3), badgeColor: Theme.of(context).primaryColor, elevation: 1, ), badgeContent: Text( '$messages', style: const TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), child: const Icon(Icons.chat), ); }, ), 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: 'Krankmeldung', icon: () => Icon(Icons.sick_outlined), create: AbsenceReportView.new, ), }; 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 effectiveModuleOrder(ModulesSettings settings) { final seen = {}; 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 reorderModuleOrder({ required List displayed, required List effective, required int oldIndex, required int newIndex, }) { final moved = List.from(displayed); moved.insert(newIndex, moved.removeAt(oldIndex)); final displayedSet = displayed.toSet(); var moveIndex = 0; return [ for (final m in effective) displayedSet.contains(m) ? moved[moveIndex++] : m, ]; } static const int minBottomBarSlots = 3; static const int maxBottomBarSlots = 5; static int resolveBottomBarSlotCount(BuildContext context) { final settings = context.read().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 getBottomBarModules(BuildContext context) { final all = modules(context).values.toList(); final slots = resolveBottomBarSlotCount(context); return all.take(slots).toList(); } static List 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, ticker, talk, files, marianumMessage, roomPlan, gradeAveragesCalculator, holidays, marianumDates, absenceReport, }