implemented dynamic module settings and configurable bottom bar, added all-day event support to timetable, and overhauled marianum dates UI with month grouping and search
This commit is contained in:
+89
-27
@@ -18,6 +18,7 @@ import 'state/app/modules/breaker/bloc/breaker_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 'state/app/modules/settings/bloc/settings_cubit.dart';
|
import 'state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
|
import 'storage/settings.dart' as model;
|
||||||
import 'utils/debouncer.dart';
|
import 'utils/debouncer.dart';
|
||||||
import 'view/pages/overhang.dart';
|
import 'view/pages/overhang.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
@@ -32,6 +33,15 @@ class App extends StatefulWidget {
|
|||||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
late Timer _refetchChats;
|
late Timer _refetchChats;
|
||||||
late Timer _updateTimings;
|
late Timer _updateTimings;
|
||||||
|
// Tracked via the bottom-nav controller's listener so it always reflects the
|
||||||
|
// user's actual position, even between rapid setting emits where the
|
||||||
|
// controller hasn't caught up to a scheduled jump yet.
|
||||||
|
int _knownTotalTabs = 1;
|
||||||
|
bool _userOnLastTab = false;
|
||||||
|
|
||||||
|
void _onTabControllerChanged() {
|
||||||
|
_userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
@@ -49,6 +59,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
Main.bottomNavigator = PersistentTabController(initialIndex: 0);
|
Main.bottomNavigator = PersistentTabController(initialIndex: 0);
|
||||||
|
Main.bottomNavigator.addListener(_onTabControllerChanged);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -102,38 +113,89 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_refetchChats.cancel();
|
_refetchChats.cancel();
|
||||||
_updateTimings.cancel();
|
_updateTimings.cancel();
|
||||||
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => PersistentTabView(
|
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(
|
||||||
controller: Main.bottomNavigator,
|
builder: (context, _) {
|
||||||
navBarOverlap: const NavBarOverlap.none(),
|
final bottomBarModules = AppModule.getBottomBarModules(context);
|
||||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
final totalTabs = bottomBarModules.length + 1;
|
||||||
handleAndroidBackButtonPress: true,
|
final currentIndex = Main.bottomNavigator.index;
|
||||||
screenTransitionAnimation: const ScreenTransitionAnimation(
|
|
||||||
curve: Curves.easeOutQuad,
|
// The bottom-bar layout is identified by the ordered list of module
|
||||||
duration: Duration(milliseconds: 200),
|
// names plus the trailing 'more' slot. Whenever this layout changes
|
||||||
),
|
// — slot count, reordering, or hiding a module — we recreate the
|
||||||
tabs: [
|
// entire PersistentTabView via the [layoutKey] below. The package
|
||||||
...AppModule.getBottomBarModules(context).map((e) => e.toBottomTab(context)),
|
// caches per-tab navigator state by index in `_navigatorKeys`, and
|
||||||
PersistentTabConfig(
|
// its internal `alignLength` only ever appends or trims at the end.
|
||||||
screen: const Breaker(breaker: BreakerArea.more, child: Overhang()),
|
// So when the module sitting at e.g. index 3 changes, the navigator
|
||||||
item: ItemConfig(
|
// at that index still serves the old screen's route stack and the
|
||||||
activeForegroundColor: Theme.of(context).primaryColor,
|
// user sees stale content. Re-mounting clears those stacks; the
|
||||||
inactiveForegroundColor: Theme.of(context).colorScheme.secondary,
|
// trade-off (losing in-tab pushed routes on a settings change) is
|
||||||
icon: const Icon(Icons.apps),
|
// acceptable since the user explicitly re-shaped the bar.
|
||||||
title: 'Mehr',
|
final layoutKey = ValueKey('${bottomBarModules.map((m) => m.module.name).join('|')}|more');
|
||||||
|
|
||||||
|
if (totalTabs != _knownTotalTabs) {
|
||||||
|
var targetIndex = currentIndex;
|
||||||
|
if (_userOnLastTab) {
|
||||||
|
targetIndex = totalTabs - 1;
|
||||||
|
} else if (currentIndex >= totalTabs) {
|
||||||
|
targetIndex = totalTabs - 1;
|
||||||
|
}
|
||||||
|
// Re-mounting PTV with a new key constructs fresh internals from
|
||||||
|
// its controller's current index. If the controller still points
|
||||||
|
// 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) {
|
||||||
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
|
Main.bottomNavigator = PersistentTabController(initialIndex: targetIndex);
|
||||||
|
Main.bottomNavigator.addListener(_onTabControllerChanged);
|
||||||
|
_userOnLastTab = targetIndex == totalTabs - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_knownTotalTabs = totalTabs;
|
||||||
|
|
||||||
|
return PersistentTabView(
|
||||||
|
key: layoutKey,
|
||||||
|
controller: Main.bottomNavigator,
|
||||||
|
navBarOverlap: const NavBarOverlap.none(),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
handleAndroidBackButtonPress: true,
|
||||||
|
screenTransitionAnimation: const ScreenTransitionAnimation(
|
||||||
|
curve: Curves.easeOutQuad,
|
||||||
|
duration: Duration(milliseconds: 200),
|
||||||
),
|
),
|
||||||
),
|
tabs: [
|
||||||
],
|
...bottomBarModules.map((e) => e.toBottomTab(context)),
|
||||||
navBarBuilder: (config) => Style6BottomNavBar(
|
PersistentTabConfig(
|
||||||
navBarConfig: config,
|
screen: const Breaker(breaker: BreakerArea.more, child: Overhang()),
|
||||||
navBarDecoration: NavBarDecoration(
|
item: ItemConfig(
|
||||||
border: const Border(top: BorderSide(width: 1, color: Colors.grey)),
|
activeForegroundColor: Theme.of(context).primaryColor,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
inactiveForegroundColor: Theme.of(context).colorScheme.secondary,
|
||||||
),
|
icon: const Icon(Icons.apps),
|
||||||
),
|
title: 'Mehr',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
navBarBuilder: (config) => Style6BottomNavBar(
|
||||||
|
// Style6BottomNavBar builds its internal animation controller list
|
||||||
|
// in initState and never grows it on didUpdateWidget. Keying by the
|
||||||
|
// 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),
|
||||||
|
navBarConfig: config,
|
||||||
|
navBarDecoration: NavBarDecoration(
|
||||||
|
border: const Border(top: BorderSide(width: 1, color: Colors.grey)),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../view/pages/marianum_message/marianum_message_view.dart';
|
|||||||
import '../view/pages/more/feedback/feedback_dialog.dart';
|
import '../view/pages/more/feedback/feedback_dialog.dart';
|
||||||
import '../view/pages/more/roomplan/roomplan.dart';
|
import '../view/pages/more/roomplan/roomplan.dart';
|
||||||
import '../view/pages/more/share/qr_share_view.dart';
|
import '../view/pages/more/share/qr_share_view.dart';
|
||||||
|
import '../view/pages/settings/modules_settings_page.dart';
|
||||||
import '../view/pages/settings/settings.dart';
|
import '../view/pages/settings/settings.dart';
|
||||||
import '../view/pages/talk/chat_view.dart';
|
import '../view/pages/talk/chat_view.dart';
|
||||||
import '../view/pages/talk/details/message_reactions.dart';
|
import '../view/pages/talk/details/message_reactions.dart';
|
||||||
@@ -78,6 +79,10 @@ class AppRoutes {
|
|||||||
pushScreen(context, withNavBar: false, screen: const Settings());
|
pushScreen(context, withNavBar: false, screen: const Settings());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void openModulesSettings(BuildContext context) {
|
||||||
|
pushScreen(context, withNavBar: false, screen: const ModulesSettingsPage());
|
||||||
|
}
|
||||||
|
|
||||||
static void openFeedback(BuildContext context) {
|
static void openFeedback(BuildContext context) {
|
||||||
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
|
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -95,6 +95,11 @@ abstract class LoadableHydratedBloc<
|
|||||||
gatherData().catchError(
|
gatherData().catchError(
|
||||||
(e) {
|
(e) {
|
||||||
log('Error while fetching ${TState.toString()}: ${e.toString()}');
|
log('Error while fetching ${TState.toString()}: ${e.toString()}');
|
||||||
|
// The bloc may have been closed before this async error landed (e.g.
|
||||||
|
// when its scoping widget tree was disposed mid-fetch). Adding to a
|
||||||
|
// closed bloc throws "Cannot add new events after calling close",
|
||||||
|
// so swallow that case quietly.
|
||||||
|
if (isClosed) return;
|
||||||
add(Error(LoadingError(
|
add(Error(LoadingError(
|
||||||
message: errorToUserMessage(e),
|
message: errorToUserMessage(e),
|
||||||
technicalDetails: errorToTechnicalDetails(e),
|
technicalDetails: errorToTechnicalDetails(e),
|
||||||
|
|||||||
@@ -113,8 +113,42 @@ class AppModule {
|
|||||||
return { for (var element in settings.val().modulesSettings.moduleOrder.where((element) => available.containsKey(element))) element : available[element]! };
|
return { for (var element in settings.val().modulesSettings.moduleOrder.where((element) => available.containsKey(element))) element : available[element]! };
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<AppModule> getBottomBarModules(BuildContext context) => modules(context).values.toList().getRange(0, 3).toList();
|
static const int minBottomBarSlots = 3;
|
||||||
static List<AppModule> getOverhangModules(BuildContext context) => modules(context).values.skip(3).toList();
|
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) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
if (width >= 840) {
|
||||||
|
desired = 5;
|
||||||
|
} else if (width >= 600) {
|
||||||
|
desired = 4;
|
||||||
|
} else {
|
||||||
|
desired = 3;
|
||||||
|
}
|
||||||
|
} 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(
|
Widget toListTile(BuildContext context, {Key? key, bool isReorder = false, Function()? onVisibleChange, bool isVisible = true}) => ListTile(
|
||||||
key: key,
|
key: key,
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ part 'modules_settings.g.dart';
|
|||||||
class ModulesSettings {
|
class ModulesSettings {
|
||||||
List<Modules> moduleOrder;
|
List<Modules> moduleOrder;
|
||||||
List<Modules> hiddenModules;
|
List<Modules> hiddenModules;
|
||||||
|
bool autoFillBottomBar;
|
||||||
|
int fixedBottomBarSlots;
|
||||||
|
|
||||||
ModulesSettings({
|
ModulesSettings({
|
||||||
required this.moduleOrder,
|
required this.moduleOrder,
|
||||||
required this.hiddenModules
|
required this.hiddenModules,
|
||||||
|
this.autoFillBottomBar = true,
|
||||||
|
this.fixedBottomBarSlots = 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ModulesSettings.fromJson(Map<String, dynamic> json) => _$ModulesSettingsFromJson(json);
|
factory ModulesSettings.fromJson(Map<String, dynamic> json) => _$ModulesSettingsFromJson(json);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ ModulesSettings _$ModulesSettingsFromJson(Map<String, dynamic> json) =>
|
|||||||
hiddenModules: (json['hiddenModules'] as List<dynamic>)
|
hiddenModules: (json['hiddenModules'] as List<dynamic>)
|
||||||
.map((e) => $enumDecode(_$ModulesEnumMap, e))
|
.map((e) => $enumDecode(_$ModulesEnumMap, e))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true,
|
||||||
|
fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ModulesSettingsToJson(
|
Map<String, dynamic> _$ModulesSettingsToJson(
|
||||||
@@ -23,6 +25,8 @@ Map<String, dynamic> _$ModulesSettingsToJson(
|
|||||||
'hiddenModules': instance.hiddenModules
|
'hiddenModules': instance.hiddenModules
|
||||||
.map((e) => _$ModulesEnumMap[e]!)
|
.map((e) => _$ModulesEnumMap[e]!)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
'autoFillBottomBar': instance.autoFillBottomBar,
|
||||||
|
'fixedBottomBarSlots': instance.fixedBottomBarSlots,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$ModulesEnumMap = {
|
const _$ModulesEnumMap = {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class TimetableSettings {
|
|||||||
|
|
||||||
TimetableSettings({
|
TimetableSettings({
|
||||||
required this.connectDoubleLessons,
|
required this.connectDoubleLessons,
|
||||||
required this.timetableNameMode
|
required this.timetableNameMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory TimetableSettings.fromJson(Map<String, dynamic> json) => _$TimetableSettingsFromJson(json);
|
factory TimetableSettings.fromJson(Map<String, dynamic> json) => _$TimetableSettingsFromJson(json);
|
||||||
|
|||||||
@@ -10,95 +10,287 @@ import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart
|
|||||||
import '../../../widget/animated_time.dart';
|
import '../../../widget/animated_time.dart';
|
||||||
import '../../../widget/centered_leading.dart';
|
import '../../../widget/centered_leading.dart';
|
||||||
import '../../../widget/debug/debug_tile.dart';
|
import '../../../widget/debug/debug_tile.dart';
|
||||||
import '../../../widget/list_view_util.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import '../timetable/custom_events/custom_event_edit_dialog.dart';
|
import '../timetable/custom_events/custom_event_edit_dialog.dart';
|
||||||
|
import 'search_marianum_dates.dart';
|
||||||
|
|
||||||
class MarianumDatesView extends StatelessWidget {
|
class MarianumDatesView extends StatelessWidget {
|
||||||
const MarianumDatesView({super.key});
|
const MarianumDatesView({super.key});
|
||||||
|
|
||||||
@override
|
/// Groups events by `yyyy-MM` (chronological). Uses the event's start date.
|
||||||
Widget build(BuildContext context) => BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
|
static List<_MonthGroup> _groupByMonth(List<MarianumDate> events) {
|
||||||
create: (context) => MarianumDatesBloc(),
|
final byMonth = <String, List<MarianumDate>>{};
|
||||||
autoRebuild: true,
|
for (final e in events) {
|
||||||
child: (context, bloc, state) => Scaffold(
|
final key = '${e.start.year.toString().padLeft(4, '0')}-${e.start.month.toString().padLeft(2, '0')}';
|
||||||
appBar: AppBar(
|
byMonth.putIfAbsent(key, () => []).add(e);
|
||||||
title: const Text('Marianum Termine'),
|
|
||||||
actions: [
|
|
||||||
PopupMenuButton<bool>(
|
|
||||||
initialValue: bloc.showPastEvents(),
|
|
||||||
icon: const Icon(Icons.history),
|
|
||||||
itemBuilder: (context) => [true, false].map((e) => PopupMenuItem<bool>(
|
|
||||||
value: e,
|
|
||||||
enabled: e != bloc.showPastEvents(),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, color: Theme.of(context).colorScheme.onSurface),
|
|
||||||
const SizedBox(width: 15),
|
|
||||||
Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)).toList(),
|
|
||||||
onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: LoadableStateConsumer<MarianumDatesBloc, MarianumDatesState>(
|
|
||||||
child: (state, loading) => ListViewUtil.fromList<MarianumDate>(bloc.getEvents(), (event) => _MarianumDateTile(event: event)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MarianumDateTile extends StatelessWidget {
|
|
||||||
final MarianumDate event;
|
|
||||||
const _MarianumDateTile({required this.event});
|
|
||||||
|
|
||||||
String _formatSubtitle() {
|
|
||||||
final start = Jiffy.parseFromDateTime(event.start);
|
|
||||||
final end = Jiffy.parseFromDateTime(event.end);
|
|
||||||
|
|
||||||
if (event.isAllDay) {
|
|
||||||
// iCal end is exclusive for multi-day all-day events. The feed sets
|
|
||||||
// DTSTART == DTEND for single-day all-day events, so only subtract a
|
|
||||||
// day when end actually advances past start.
|
|
||||||
final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end;
|
|
||||||
final sameAllDay = start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd');
|
|
||||||
return sameAllDay
|
|
||||||
? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig'
|
|
||||||
: '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig';
|
|
||||||
}
|
}
|
||||||
|
final keys = byMonth.keys.toList()..sort();
|
||||||
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
|
return keys.map((key) {
|
||||||
if (sameDay) {
|
final first = byMonth[key]!.first.start;
|
||||||
if (event.start == event.end) {
|
final label = Jiffy.parseFromDateTime(first).format(pattern: 'MMMM yyyy').toUpperCase();
|
||||||
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}';
|
return _MonthGroup(key: key, label: label, events: byMonth[key]!);
|
||||||
}
|
}).toList();
|
||||||
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}';
|
|
||||||
}
|
|
||||||
return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => ListTile(
|
Widget build(BuildContext context) => BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
|
||||||
leading: const CenteredLeading(Icon(Icons.event)),
|
create: (context) => MarianumDatesBloc(),
|
||||||
title: Text(event.title.isEmpty ? '(ohne Titel)' : event.title),
|
autoRebuild: true,
|
||||||
subtitle: Text(_formatSubtitle()),
|
child: (context, bloc, state) => Scaffold(
|
||||||
onTap: () => _showDetails(context),
|
appBar: AppBar(
|
||||||
trailing: IconButton(
|
title: const Text('Marianum Termine'),
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
actions: [
|
||||||
tooltip: 'In Stundenplan übernehmen',
|
PopupMenuButton<bool>(
|
||||||
onPressed: () => showDialog(
|
initialValue: bloc.showPastEvents(),
|
||||||
context: context,
|
icon: const Icon(Icons.history),
|
||||||
builder: (_) => CustomEventEditDialog(
|
itemBuilder: (context) => [true, false]
|
||||||
initialTitle: event.title,
|
.map((e) => PopupMenuItem<bool>(
|
||||||
initialDescription: event.description,
|
value: e,
|
||||||
initialStart: event.start,
|
enabled: e != bloc.showPastEvents(),
|
||||||
initialEnd: event.end,
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface),
|
||||||
|
const SizedBox(width: 15),
|
||||||
|
Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.search),
|
||||||
|
onPressed: () {
|
||||||
|
final events = bloc.getEvents() ?? const <MarianumDate>[];
|
||||||
|
showSearch(context: context, delegate: SearchMarianumDates(events));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: LoadableStateConsumer<MarianumDatesBloc, MarianumDatesState>(
|
||||||
|
child: (state, loading) {
|
||||||
|
final events = bloc.getEvents() ?? const <MarianumDate>[];
|
||||||
|
final groups = _groupByMonth(events);
|
||||||
|
|
||||||
|
if (groups.isEmpty) {
|
||||||
|
return const PlaceholderView(
|
||||||
|
icon: Icons.event_busy_outlined,
|
||||||
|
text: 'Keine Termine',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
for (final group in groups)
|
||||||
|
SliverMainAxisGroup(
|
||||||
|
slivers: [
|
||||||
|
SliverPersistentHeader(
|
||||||
|
pinned: true,
|
||||||
|
delegate: MonthHeaderDelegate(label: group.label),
|
||||||
|
),
|
||||||
|
SliverList.builder(
|
||||||
|
itemCount: group.events.length,
|
||||||
|
itemBuilder: (_, i) => MarianumDateRow(event: group.events[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MonthGroup {
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
final List<MarianumDate> events;
|
||||||
|
_MonthGroup({required this.key, required this.label, required this.events});
|
||||||
|
}
|
||||||
|
|
||||||
|
class MonthHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||||
|
final String label;
|
||||||
|
MonthHeaderDelegate({required this.label});
|
||||||
|
|
||||||
|
static const double _height = 38;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
height: _height,
|
||||||
|
color: theme.colorScheme.surfaceContainer,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
barrierDismissible: false,
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get maxExtent => _height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get minExtent => _height;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => oldDelegate.label != label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Composite icon: calendar with a small plus badge in the bottom-right.
|
||||||
|
/// Material's bundled icon set has no `calendar_add_on`, so we layer
|
||||||
|
/// `Icons.event_outlined` and `Icons.add` to get the same affordance.
|
||||||
|
class _CalendarPlusIcon extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
const _CalendarPlusIcon({required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.event_outlined, size: 22, color: color),
|
||||||
|
Positioned(
|
||||||
|
right: -2,
|
||||||
|
bottom: -2,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(1),
|
||||||
|
child: Icon(Icons.add_circle, size: 12, color: color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarianumDateRow extends StatelessWidget {
|
||||||
|
final MarianumDate event;
|
||||||
|
const MarianumDateRow({required this.event, super.key});
|
||||||
|
|
||||||
|
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
String _monthYearLabel() =>
|
||||||
|
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
|
||||||
|
|
||||||
|
String _trailingLabel() {
|
||||||
|
final start = Jiffy.parseFromDateTime(event.start);
|
||||||
|
final end = Jiffy.parseFromDateTime(event.end);
|
||||||
|
if (event.isAllDay) return 'Ganztägig';
|
||||||
|
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
|
||||||
|
if (sameDay) {
|
||||||
|
if (event.start == event.end) return start.format(pattern: 'HH:mm');
|
||||||
|
return '${start.format(pattern: 'HH:mm')}–${end.format(pattern: 'HH:mm')}';
|
||||||
|
}
|
||||||
|
return '${start.format(pattern: 'dd.MM. HH:mm')}–${end.format(pattern: 'dd.MM. HH:mm')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => _showDetails(context),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 4, 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_dayLabel(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_monthYearLabel(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
overflow: TextOverflow.visible,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
event.title.isEmpty ? '(ohne Titel)' : event.title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
if (event.description != null && event.description!.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
event.description!.trim(),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_trailingLabel(),
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
IconButton(
|
||||||
|
icon: _CalendarPlusIcon(color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
tooltip: 'In Stundenplan übernehmen',
|
||||||
|
onPressed: () => showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => CustomEventEditDialog(
|
||||||
|
initialTitle: event.title,
|
||||||
|
initialDescription: event.description,
|
||||||
|
initialStart: event.start,
|
||||||
|
initialEnd: event.end,
|
||||||
|
),
|
||||||
|
barrierDismissible: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showDetails(BuildContext context) {
|
void _showDetails(BuildContext context) {
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -108,7 +300,7 @@ class _MarianumDateTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
|
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
|
||||||
title: Text(_formatSubtitle()),
|
title: Text(_formatLongRange()),
|
||||||
),
|
),
|
||||||
if (event.description != null && event.description!.trim().isNotEmpty)
|
if (event.description != null && event.description!.trim().isNotEmpty)
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -132,4 +324,25 @@ class _MarianumDateTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _formatLongRange() {
|
||||||
|
final start = Jiffy.parseFromDateTime(event.start);
|
||||||
|
final end = Jiffy.parseFromDateTime(event.end);
|
||||||
|
if (event.isAllDay) {
|
||||||
|
final inclusiveEnd = event.end.isAfter(event.start) ? end.subtract(days: 1) : end;
|
||||||
|
final sameAllDay =
|
||||||
|
start.format(pattern: 'yyyy-MM-dd') == inclusiveEnd.format(pattern: 'yyyy-MM-dd');
|
||||||
|
return sameAllDay
|
||||||
|
? '${start.format(pattern: 'dd.MM.yyyy')} · Ganztägig'
|
||||||
|
: '${start.format(pattern: 'dd.MM.yyyy')} – ${inclusiveEnd.format(pattern: 'dd.MM.yyyy')} · Ganztägig';
|
||||||
|
}
|
||||||
|
final sameDay = start.format(pattern: 'yyyy-MM-dd') == end.format(pattern: 'yyyy-MM-dd');
|
||||||
|
if (sameDay) {
|
||||||
|
if (event.start == event.end) {
|
||||||
|
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')}';
|
||||||
|
}
|
||||||
|
return '${start.format(pattern: 'dd.MM.yyyy')} · ${start.format(pattern: 'HH:mm')} – ${end.format(pattern: 'HH:mm')}';
|
||||||
|
}
|
||||||
|
return '${start.format(pattern: 'dd.MM.yyyy HH:mm')} – ${end.format(pattern: 'dd.MM.yyyy HH:mm')}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
|
||||||
|
import '../../../widget/placeholder_view.dart';
|
||||||
|
import 'marianum_dates_view.dart';
|
||||||
|
|
||||||
|
class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
|
||||||
|
final List<MarianumDate> events;
|
||||||
|
|
||||||
|
SearchMarianumDates(this.events);
|
||||||
|
|
||||||
|
List<MarianumDate> _matches() {
|
||||||
|
if (query.trim().isEmpty) return events;
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
|
return events.where((e) {
|
||||||
|
final title = e.title.toLowerCase();
|
||||||
|
final desc = e.description?.toLowerCase() ?? '';
|
||||||
|
return title.contains(q) || desc.contains(q);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
|
if (query.isNotEmpty)
|
||||||
|
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget? buildLeading(BuildContext context) => IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => close(context, null),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildResults(BuildContext context) {
|
||||||
|
final matches = _matches();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return const PlaceholderView(
|
||||||
|
icon: Icons.search_off_outlined,
|
||||||
|
text: 'Keine Treffer',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: matches.length,
|
||||||
|
itemBuilder: (_, i) => MarianumDateRow(event: matches[i]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildSuggestions(BuildContext context) => buildResults(context);
|
||||||
|
}
|
||||||
@@ -2,18 +2,14 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
||||||
import 'package:in_app_review/in_app_review.dart';
|
import 'package:in_app_review/in_app_review.dart';
|
||||||
|
|
||||||
import '../../extensions/render_not_null.dart';
|
import '../../extensions/render_not_null.dart';
|
||||||
import '../../routing/app_routes.dart';
|
import '../../routing/app_routes.dart';
|
||||||
import '../../state/app/modules/app_modules.dart';
|
import '../../state/app/modules/app_modules.dart';
|
||||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
|
||||||
import '../../storage/settings.dart' as model;
|
|
||||||
import '../../widget/centered_leading.dart';
|
import '../../widget/centered_leading.dart';
|
||||||
import '../../widget/info_dialog.dart';
|
import '../../widget/info_dialog.dart';
|
||||||
import 'more/share/select_share_type_dialog.dart';
|
import 'more/share/select_share_type_dialog.dart';
|
||||||
import 'settings/data/default_settings.dart';
|
|
||||||
|
|
||||||
class Overhang extends StatefulWidget {
|
class Overhang extends StatefulWidget {
|
||||||
const Overhang({super.key});
|
const Overhang({super.key});
|
||||||
@@ -23,65 +19,16 @@ class Overhang extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _OverhangState extends State<Overhang> {
|
class _OverhangState extends State<Overhang> {
|
||||||
bool editMode = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(builder: (context, _) {
|
Widget build(BuildContext context) => Scaffold(
|
||||||
final settings = context.read<SettingsCubit>();
|
appBar: AppBar(
|
||||||
return Scaffold(
|
title: const Text('Mehr'),
|
||||||
appBar: AppBar(
|
actions: [
|
||||||
title: const Text('Mehr'),
|
IconButton(onPressed: () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)),
|
||||||
actions: [
|
],
|
||||||
if(editMode) IconButton(
|
),
|
||||||
onPressed: settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString()
|
body: _overhang(),
|
||||||
? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings
|
);
|
||||||
: null,
|
|
||||||
icon: Icon(Icons.undo_outlined)
|
|
||||||
),
|
|
||||||
IconButton(onPressed: () => setState(() => editMode = !editMode), icon: Icon(Icons.edit_note_outlined), color: editMode ? Theme.of(context).primaryColor : null),
|
|
||||||
IconButton(onPressed: editMode ? null : () => AppRoutes.openSettings(context), icon: const Icon(Icons.settings)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: editMode ? _sorting() : _overhang(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Widget _sorting() => BlocBuilder<SettingsCubit, model.Settings>(builder: (context, _) {
|
|
||||||
final settings = context.read<SettingsCubit>();
|
|
||||||
void changeVisibility(Modules module) {
|
|
||||||
var hidden = settings.val(write: true).modulesSettings.hiddenModules;
|
|
||||||
if (hidden.contains(module)) {
|
|
||||||
hidden.remove(module);
|
|
||||||
} else if (hidden.length < 3) {
|
|
||||||
hidden.add(module);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReorderableListView(
|
|
||||||
header: const Center(
|
|
||||||
heightFactor: 2,
|
|
||||||
child: Text('Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', textAlign: TextAlign.center)
|
|
||||||
),
|
|
||||||
children: AppModule.modules(context, showFiltered: true)
|
|
||||||
.map((key, value) => MapEntry(key, value.toListTile(
|
|
||||||
context,
|
|
||||||
key: Key(key.name),
|
|
||||||
isReorder: true,
|
|
||||||
onVisibleChange: () => changeVisibility(key),
|
|
||||||
isVisible: !settings.val().modulesSettings.hiddenModules.contains(key)
|
|
||||||
)))
|
|
||||||
.values
|
|
||||||
.toList(),
|
|
||||||
onReorder: (oldIndex, newIndex) {
|
|
||||||
if (newIndex > oldIndex) newIndex -= 1;
|
|
||||||
|
|
||||||
var order = settings.val().modulesSettings.moduleOrder.toList();
|
|
||||||
final movedModule = order.removeAt(oldIndex);
|
|
||||||
order.insert(newIndex, movedModule);
|
|
||||||
settings.val(write: true).modulesSettings.moduleOrder = order;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Widget _overhang() => ListView(
|
Widget _overhang() => ListView(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ class DefaultSettings {
|
|||||||
Modules.marianumDates,
|
Modules.marianumDates,
|
||||||
],
|
],
|
||||||
hiddenModules: [],
|
hiddenModules: [],
|
||||||
|
autoFillBottomBar: true,
|
||||||
|
fixedBottomBarSlots: 3,
|
||||||
),
|
),
|
||||||
timetableSettings: TimetableSettings(
|
timetableSettings: TimetableSettings(
|
||||||
connectDoubleLessons: true,
|
connectDoubleLessons: true,
|
||||||
timetableNameMode: TimetableNameMode.name
|
timetableNameMode: TimetableNameMode.name,
|
||||||
),
|
),
|
||||||
talkSettings: TalkSettings(
|
talkSettings: TalkSettings(
|
||||||
sortFavoritesToTop: true,
|
sortFavoritesToTop: true,
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../../../state/app/modules/app_modules.dart';
|
||||||
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import '../../../storage/settings.dart' as model;
|
||||||
|
import 'data/default_settings.dart';
|
||||||
|
|
||||||
|
/// Reorderable list with bottom-bar slot configuration on top.
|
||||||
|
///
|
||||||
|
/// Used both inline in the "Mehr" edit mode and as the body of
|
||||||
|
/// [ModulesSettingsPage] from the main settings.
|
||||||
|
class ModuleSortBody extends StatelessWidget {
|
||||||
|
const ModuleSortBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(builder: (context, _) {
|
||||||
|
final settings = context.read<SettingsCubit>();
|
||||||
|
final modulesSettings = settings.val().modulesSettings;
|
||||||
|
|
||||||
|
void changeVisibility(Modules module) {
|
||||||
|
var hidden = settings.val(write: true).modulesSettings.hiddenModules;
|
||||||
|
if (hidden.contains(module)) {
|
||||||
|
hidden.remove(module);
|
||||||
|
} else if (hidden.length < 3) {
|
||||||
|
hidden.add(module);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReorderableListView(
|
||||||
|
header: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: Text(
|
||||||
|
'Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Modulleiste automatisch füllen'),
|
||||||
|
subtitle: const Text('Auf größeren Bildschirmen werden mehr Module direkt angezeigt'),
|
||||||
|
value: modulesSettings.autoFillBottomBar,
|
||||||
|
onChanged: (value) => settings.val(write: true).modulesSettings.autoFillBottomBar = value,
|
||||||
|
),
|
||||||
|
if (!modulesSettings.autoFillBottomBar)
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Anzahl Slots in der Modulleiste'),
|
||||||
|
subtitle: Text('${modulesSettings.fixedBottomBarSlots} Module (zzgl. „Mehr")'),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
onPressed: modulesSettings.fixedBottomBarSlots > AppModule.minBottomBarSlots
|
||||||
|
? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots -= 1
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Text('${modulesSettings.fixedBottomBarSlots}'),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
onPressed: modulesSettings.fixedBottomBarSlots < AppModule.maxBottomBarSlots
|
||||||
|
? () => settings.val(write: true).modulesSettings.fixedBottomBarSlots += 1
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
children: AppModule.modules(context, showFiltered: true)
|
||||||
|
.map((key, value) => MapEntry(key, value.toListTile(
|
||||||
|
context,
|
||||||
|
key: Key(key.name),
|
||||||
|
isReorder: true,
|
||||||
|
onVisibleChange: () => changeVisibility(key),
|
||||||
|
isVisible: !settings.val().modulesSettings.hiddenModules.contains(key),
|
||||||
|
)))
|
||||||
|
.values
|
||||||
|
.toList(),
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
if (newIndex > oldIndex) newIndex -= 1;
|
||||||
|
|
||||||
|
var order = settings.val().modulesSettings.moduleOrder.toList();
|
||||||
|
final movedModule = order.removeAt(oldIndex);
|
||||||
|
order.insert(newIndex, movedModule);
|
||||||
|
settings.val(write: true).modulesSettings.moduleOrder = order;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModulesSettingsPage extends StatelessWidget {
|
||||||
|
const ModulesSettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => BlocBuilder<SettingsCubit, model.Settings>(builder: (context, _) {
|
||||||
|
final settings = context.read<SettingsCubit>();
|
||||||
|
final isModified = settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString();
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Module'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Auf Standard zurücksetzen',
|
||||||
|
onPressed: isModified ? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings : null,
|
||||||
|
icon: const Icon(Icons.undo_outlined),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: const ModuleSortBody(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../../routing/app_routes.dart';
|
||||||
|
|
||||||
|
class ModulesSection extends StatelessWidget {
|
||||||
|
const ModulesSection({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ListTile(
|
||||||
|
leading: const Icon(Icons.apps_outlined),
|
||||||
|
title: const Text('Module'),
|
||||||
|
subtitle: const Text('Reihenfolge, Sichtbarkeit und Modulleiste anpassen'),
|
||||||
|
trailing: const Icon(Icons.arrow_right),
|
||||||
|
onTap: () => AppRoutes.openModulesSettings(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'sections/about_section.dart';
|
|||||||
import 'sections/account_section.dart';
|
import 'sections/account_section.dart';
|
||||||
import 'sections/appearance_section.dart';
|
import 'sections/appearance_section.dart';
|
||||||
import 'sections/files_section.dart';
|
import 'sections/files_section.dart';
|
||||||
|
import 'sections/modules_section.dart';
|
||||||
import 'sections/talk_section.dart';
|
import 'sections/talk_section.dart';
|
||||||
import 'sections/timetable_section.dart';
|
import 'sections/timetable_section.dart';
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ class Settings extends StatelessWidget {
|
|||||||
Divider(),
|
Divider(),
|
||||||
AppearanceSection(),
|
AppearanceSection(),
|
||||||
Divider(),
|
Divider(),
|
||||||
|
ModulesSection(),
|
||||||
|
Divider(),
|
||||||
TimetableSection(),
|
TimetableSection(),
|
||||||
Divider(),
|
Divider(),
|
||||||
TalkSection(),
|
TalkSection(),
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import 'package:time_range_picker/time_range_picker.dart';
|
|||||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||||
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/focus_behaviour.dart';
|
import '../../../../widget/focus_behaviour.dart';
|
||||||
import '../../../../widget/info_dialog.dart';
|
|
||||||
import 'custom_event_colors.dart';
|
import 'custom_event_colors.dart';
|
||||||
|
|
||||||
class CustomEventEditDialog extends StatefulWidget {
|
class CustomEventEditDialog extends StatefulWidget {
|
||||||
@@ -34,15 +34,18 @@ class CustomEventEditDialog extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
||||||
// Visible window of the timetable / time picker (matches `_pickTimeRange`'s
|
// Selectable window for non-all-day events. Times outside this range are
|
||||||
// `disabledTime`). Pre-filled times from outside this window are clamped in.
|
// clamped in. For events outside school hours, use the all-day toggle.
|
||||||
static const TimeOfDay _windowStart = TimeOfDay(hour: 8, minute: 0);
|
static const TimeOfDay _windowStart = TimeOfDay(hour: 8, minute: 0);
|
||||||
static const TimeOfDay _windowEnd = TimeOfDay(hour: 16, minute: 30);
|
static const TimeOfDay _windowEnd = TimeOfDay(hour: 16, minute: 30);
|
||||||
|
static const TimeOfDay _defaultStart = _windowStart;
|
||||||
|
static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30);
|
||||||
static const int _minDurationMinutes = 15;
|
static const int _minDurationMinutes = 15;
|
||||||
|
|
||||||
late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
|
late DateTime _date = widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now();
|
||||||
late TimeOfDay _startTime;
|
late TimeOfDay _startTime;
|
||||||
late TimeOfDay _endTime;
|
late TimeOfDay _endTime;
|
||||||
|
late bool _isAllDay;
|
||||||
late final TextEditingController _name = TextEditingController(
|
late final TextEditingController _name = TextEditingController(
|
||||||
text: widget.existingEvent?.title ?? widget.initialTitle,
|
text: widget.existingEvent?.title ?? widget.initialTitle,
|
||||||
);
|
);
|
||||||
@@ -61,12 +64,22 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (_isEditing) {
|
if (_isEditing) {
|
||||||
_startTime = widget.existingEvent!.startDate.toTimeOfDay();
|
final s = widget.existingEvent!.startDate;
|
||||||
_endTime = widget.existingEvent!.endDate.toTimeOfDay();
|
final e = widget.existingEvent!.endDate;
|
||||||
|
_isAllDay = isAllDayConvention(s, e);
|
||||||
|
if (_isAllDay) {
|
||||||
|
_startTime = _defaultStart;
|
||||||
|
_endTime = _defaultEnd;
|
||||||
|
} else {
|
||||||
|
final clamped = _clampToVisibleWindow(s.toTimeOfDay(), e.toTimeOfDay());
|
||||||
|
_startTime = clamped.$1;
|
||||||
|
_endTime = clamped.$2;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final rawStart = widget.initialStart?.toTimeOfDay() ?? _windowStart;
|
_isAllDay = false;
|
||||||
final rawEnd = widget.initialEnd?.toTimeOfDay() ?? const TimeOfDay(hour: 9, minute: 30);
|
final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart;
|
||||||
|
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;
|
||||||
@@ -88,17 +101,38 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
return (fromMin(start), fromMin(end));
|
return (fromMin(start), fromMin(end));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _validate() => _name.text.isNotEmpty;
|
/// All-day convention shared with [TimetableAppointmentFactory]: a custom
|
||||||
|
/// event is treated as all-day when its start and end both land on midnight
|
||||||
|
/// of the same day. We piggyback on this so we don't need a backend schema
|
||||||
|
/// change.
|
||||||
|
static bool isAllDayConvention(DateTime start, DateTime end) =>
|
||||||
|
start.year == end.year &&
|
||||||
|
start.month == end.month &&
|
||||||
|
start.day == end.day &&
|
||||||
|
start.hour == 0 &&
|
||||||
|
start.minute == 0 &&
|
||||||
|
start.second == 0 &&
|
||||||
|
end.hour == 0 &&
|
||||||
|
end.minute == 0 &&
|
||||||
|
end.second == 0;
|
||||||
|
|
||||||
void _save() {
|
Future<void> _save() async {
|
||||||
if (!_validate()) return;
|
if (_name.text.trim().isEmpty) {
|
||||||
|
throw Exception('Bitte einen Terminnamen eingeben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-day convention: store start and end as midnight of the chosen day.
|
||||||
|
// The factory recognises this on read.
|
||||||
|
final midnight = DateTime(_date.year, _date.month, _date.day);
|
||||||
|
final startDate = _isAllDay ? midnight : _date.withTime(_startTime);
|
||||||
|
final endDate = _isAllDay ? midnight : _date.withTime(_endTime);
|
||||||
|
|
||||||
final edited = CustomTimetableEvent(
|
final edited = CustomTimetableEvent(
|
||||||
id: widget.existingEvent?.id ?? '',
|
id: widget.existingEvent?.id ?? '',
|
||||||
title: _name.text,
|
title: _name.text,
|
||||||
description: _description.text,
|
description: _description.text,
|
||||||
startDate: _date.withTime(_startTime),
|
startDate: startDate,
|
||||||
endDate: _date.withTime(_endTime),
|
endDate: endDate,
|
||||||
color: _color.name,
|
color: _color.name,
|
||||||
rrule: _rrule,
|
rrule: _rrule,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
@@ -106,17 +140,11 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final bloc = context.read<TimetableBloc>();
|
final bloc = context.read<TimetableBloc>();
|
||||||
final future = _isEditing
|
if (_isEditing) {
|
||||||
? bloc.updateCustomEvent(widget.existingEvent!.id, edited)
|
await bloc.updateCustomEvent(widget.existingEvent!.id, edited);
|
||||||
: bloc.addCustomEvent(edited);
|
} else {
|
||||||
|
await bloc.addCustomEvent(edited);
|
||||||
future.then((_) {
|
}
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}).catchError((Object error) {
|
|
||||||
if (!mounted) return;
|
|
||||||
InfoDialog.show(context, error.toString(), copyable: true, title: 'Fehler');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDate() async {
|
Future<void> _pickDate() async {
|
||||||
@@ -138,8 +166,8 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
start: _startTime,
|
start: _startTime,
|
||||||
end: _endTime,
|
end: _endTime,
|
||||||
disabledTime: TimeRange(
|
disabledTime: TimeRange(
|
||||||
startTime: const TimeOfDay(hour: 16, minute: 30),
|
startTime: _windowEnd,
|
||||||
endTime: const TimeOfDay(hour: 8, minute: 0),
|
endTime: _windowStart,
|
||||||
),
|
),
|
||||||
disabledColor: Colors.grey,
|
disabledColor: Colors.grey,
|
||||||
paintingStyle: PaintingStyle.fill,
|
paintingStyle: PaintingStyle.fill,
|
||||||
@@ -147,7 +175,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
fromText: 'Beginnend',
|
fromText: 'Beginnend',
|
||||||
toText: 'Endend',
|
toText: 'Endend',
|
||||||
strokeColor: Theme.of(context).colorScheme.secondary,
|
strokeColor: Theme.of(context).colorScheme.secondary,
|
||||||
minDuration: const Duration(minutes: 15),
|
minDuration: Duration(minutes: _minDurationMinutes),
|
||||||
selectedColor: Theme.of(context).primaryColor,
|
selectedColor: Theme.of(context).primaryColor,
|
||||||
ticks: 24,
|
ticks: 24,
|
||||||
);
|
);
|
||||||
@@ -191,12 +219,19 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
subtitle: const Text('Datum'),
|
subtitle: const Text('Datum'),
|
||||||
onTap: _pickDate,
|
onTap: _pickDate,
|
||||||
),
|
),
|
||||||
ListTile(
|
SwitchListTile(
|
||||||
leading: const Icon(Icons.access_time_outlined),
|
secondary: const Icon(Icons.today_outlined),
|
||||||
title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'),
|
title: const Text('Ganztägig'),
|
||||||
subtitle: const Text('Zeitraum'),
|
value: _isAllDay,
|
||||||
onTap: _pickTimeRange,
|
onChanged: (v) => setState(() => _isAllDay = v),
|
||||||
),
|
),
|
||||||
|
if (!_isAllDay)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.access_time_outlined),
|
||||||
|
title: Text('${_startTime.format(context)} - ${_endTime.format(context)}'),
|
||||||
|
subtitle: const Text('Zeitraum'),
|
||||||
|
onTap: _pickTimeRange,
|
||||||
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.color_lens_outlined),
|
leading: const Icon(Icons.color_lens_outlined),
|
||||||
@@ -246,8 +281,10 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')),
|
AsyncDialogAction(
|
||||||
TextButton(onPressed: _save, child: Text(_isEditing ? 'Speichern' : 'Erstellen')),
|
confirmLabel: _isEditing ? 'Speichern' : 'Erstellen',
|
||||||
|
onConfirm: _save,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,27 +68,51 @@ class TimetableAppointmentFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Appointment _customEventToAppointment(CustomTimetableEvent event) => Appointment(
|
Appointment _customEventToAppointment(CustomTimetableEvent event) {
|
||||||
id: CustomAppointment(event),
|
final allDay = isCustomEventAllDay(event);
|
||||||
startTime: event.startDate,
|
return Appointment(
|
||||||
endTime: event.endDate,
|
id: CustomAppointment(event),
|
||||||
location: _collapseWhitespace(event.description),
|
startTime: event.startDate,
|
||||||
subject: _collapseWhitespace(event.title) ?? event.title,
|
endTime: allDay
|
||||||
recurrenceRule: event.rrule,
|
? DateTime(event.startDate.year, event.startDate.month, event.startDate.day, 23, 59)
|
||||||
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
|
: event.endDate,
|
||||||
startTimeZone: '',
|
isAllDay: allDay,
|
||||||
endTimeZone: '',
|
location: _collapseWhitespace(event.description),
|
||||||
);
|
subject: _collapseWhitespace(event.title) ?? event.title,
|
||||||
|
recurrenceRule: event.rrule,
|
||||||
|
color: TimetableColors.getColorFromString(event.color ?? TimetableColors.defaultColor.name),
|
||||||
|
startTimeZone: '',
|
||||||
|
endTimeZone: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All-day convention: a `CustomTimetableEvent` is treated as all-day when
|
||||||
|
/// its `startDate` and `endDate` both land on midnight of the same day.
|
||||||
|
/// Keeps the backend schema unchanged — the editor stores all-day events as
|
||||||
|
/// `start == end == midnight(date)`.
|
||||||
|
static bool isCustomEventAllDay(CustomTimetableEvent event) {
|
||||||
|
final s = event.startDate;
|
||||||
|
final e = event.endDate;
|
||||||
|
return s.year == e.year &&
|
||||||
|
s.month == e.month &&
|
||||||
|
s.day == e.day &&
|
||||||
|
s.hour == 0 &&
|
||||||
|
s.minute == 0 &&
|
||||||
|
s.second == 0 &&
|
||||||
|
e.hour == 0 &&
|
||||||
|
e.minute == 0 &&
|
||||||
|
e.second == 0;
|
||||||
|
}
|
||||||
|
|
||||||
String _subjectName(GetTimetableResponseObject lesson) {
|
String _subjectName(GetTimetableResponseObject lesson) {
|
||||||
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
|
final subject = subjects.result.firstWhereOrNull((s) => s.id == lesson.su.firstOrNull?.id);
|
||||||
if (subject == null) return 'Unbekannt';
|
if (subject == null) return 'Event';
|
||||||
final name = switch (settings.timetableNameMode) {
|
final name = switch (settings.timetableNameMode) {
|
||||||
TimetableNameMode.name => subject.name,
|
TimetableNameMode.name => subject.name,
|
||||||
TimetableNameMode.longName => subject.longName,
|
TimetableNameMode.longName => subject.longName,
|
||||||
TimetableNameMode.alternateName => subject.alternateName,
|
TimetableNameMode.alternateName => subject.alternateName,
|
||||||
};
|
};
|
||||||
return _collapseWhitespace(name) ?? 'Unbekannt';
|
return _collapseWhitespace(name) ?? 'Event';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _locationLabel(GetTimetableResponseObject lesson) {
|
String _locationLabel(GetTimetableResponseObject lesson) {
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ Completer<void> showDeleteCustomEventDialog(BuildContext context, CustomTimetabl
|
|||||||
title: 'Termin löschen',
|
title: 'Termin löschen',
|
||||||
content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
|
content: 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.',
|
||||||
confirmButton: 'Löschen',
|
confirmButton: 'Löschen',
|
||||||
onConfirm: () {
|
onConfirmAsync: () async {
|
||||||
bloc.removeCustomEvent(event.id).then(completer.complete).onError(completer.completeError);
|
await bloc.removeCustomEvent(event.id);
|
||||||
|
completer.complete();
|
||||||
},
|
},
|
||||||
).asDialog(context);
|
).asDialog(context);
|
||||||
return completer;
|
return completer;
|
||||||
|
|||||||
@@ -78,14 +78,27 @@ class _TimetableState extends State<Timetable> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isOnInitialWeek(TimetableState state) {
|
||||||
|
final target = _initialDisplayDate();
|
||||||
|
final targetMonday = target.subtract(Duration(days: target.weekday - 1));
|
||||||
|
final mondayOnly = DateTime(targetMonday.year, targetMonday.month, targetMonday.day);
|
||||||
|
return state.startDate == mondayOnly;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bloc = context.read<TimetableBloc>();
|
final bloc = context.read<TimetableBloc>();
|
||||||
|
final loadableState = context.watch<TimetableBloc>().state;
|
||||||
|
final innerState = loadableState.data;
|
||||||
|
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Stunden & Vertretungsplan'),
|
title: const Text('Stunden & Vertretungsplan'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(icon: const Icon(Icons.home_outlined), onPressed: _jumpToToday),
|
IconButton(
|
||||||
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
onPressed: atToday ? null : _jumpToToday,
|
||||||
|
),
|
||||||
PopupMenuButton<_CalendarAction>(
|
PopupMenuButton<_CalendarAction>(
|
||||||
icon: const Icon(Icons.edit_calendar_outlined),
|
icon: const Icon(Icons.edit_calendar_outlined),
|
||||||
onSelected: _onAction,
|
onSelected: _onAction,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class CustomWorkWeekCalendar extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
||||||
static const double _rulerWidth = 50;
|
static const double _rulerWidth = 36;
|
||||||
|
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
late int _currentWeekIndex;
|
late int _currentWeekIndex;
|
||||||
@@ -128,6 +128,28 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ClipRect(
|
||||||
|
child: AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 320),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 280),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
transitionBuilder: (child, animation) =>
|
||||||
|
FadeTransition(opacity: animation, child: child),
|
||||||
|
child: _OutsideHoursStrip(
|
||||||
|
key: ValueKey(visibleWeekStart),
|
||||||
|
weekStart: visibleWeekStart,
|
||||||
|
appointments: widget.appointments,
|
||||||
|
rulerWidth: _rulerWidth,
|
||||||
|
onAppointmentTap: widget.onAppointmentTap,
|
||||||
|
isCrossedOut: widget.isCrossedOut,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Container(height: 0.5, color: theme.dividerColor.withAlpha(110)),
|
Container(height: 0.5, color: theme.dividerColor.withAlpha(110)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
@@ -189,6 +211,271 @@ class CustomWorkWeekCalendarState extends State<CustomWorkWeekCalendar> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OutsideHoursStrip extends StatelessWidget {
|
||||||
|
static const int _maxVisibleChips = 2;
|
||||||
|
static const double _chipHeight = 22;
|
||||||
|
static const double _chipSpacing = 3;
|
||||||
|
static const double _verticalPadding = 3;
|
||||||
|
|
||||||
|
final DateTime weekStart;
|
||||||
|
final List<Appointment> appointments;
|
||||||
|
final double rulerWidth;
|
||||||
|
final void Function(Appointment) onAppointmentTap;
|
||||||
|
final bool Function(Appointment) isCrossedOut;
|
||||||
|
|
||||||
|
const _OutsideHoursStrip({
|
||||||
|
super.key,
|
||||||
|
required this.weekStart,
|
||||||
|
required this.appointments,
|
||||||
|
required this.rulerWidth,
|
||||||
|
required this.onAppointmentTap,
|
||||||
|
required this.isCrossedOut,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final outside = _partitionAppointmentsForWeek(appointments, weekStart).outside;
|
||||||
|
if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final maxChipsPerDay = outside
|
||||||
|
.map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length)
|
||||||
|
.fold<int>(0, (m, c) => c > m ? c : m);
|
||||||
|
final stripHeight = _verticalPadding * 2 +
|
||||||
|
maxChipsPerDay * _chipHeight +
|
||||||
|
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: theme.colorScheme.surfaceContainerLowest,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: _verticalPadding),
|
||||||
|
child: SizedBox(
|
||||||
|
height: stripHeight - _verticalPadding * 2,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(width: rulerWidth),
|
||||||
|
for (var d = 0; d < 5; d++)
|
||||||
|
Expanded(
|
||||||
|
child: _OutsideDayColumn(
|
||||||
|
appointments: outside[d],
|
||||||
|
maxVisible: _maxVisibleChips,
|
||||||
|
chipHeight: _chipHeight,
|
||||||
|
chipSpacing: _chipSpacing,
|
||||||
|
onAppointmentTap: onAppointmentTap,
|
||||||
|
isCrossedOut: isCrossedOut,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OutsideDayColumn extends StatelessWidget {
|
||||||
|
final List<Appointment> appointments;
|
||||||
|
final int maxVisible;
|
||||||
|
final double chipHeight;
|
||||||
|
final double chipSpacing;
|
||||||
|
final void Function(Appointment) onAppointmentTap;
|
||||||
|
final bool Function(Appointment) isCrossedOut;
|
||||||
|
|
||||||
|
const _OutsideDayColumn({
|
||||||
|
required this.appointments,
|
||||||
|
required this.maxVisible,
|
||||||
|
required this.chipHeight,
|
||||||
|
required this.chipSpacing,
|
||||||
|
required this.onAppointmentTap,
|
||||||
|
required this.isCrossedOut,
|
||||||
|
});
|
||||||
|
|
||||||
|
void _showOverflow(BuildContext context, List<Appointment> hidden) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (sheetCtx) => SafeArea(
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
itemCount: hidden.length,
|
||||||
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final apt = hidden[i];
|
||||||
|
return ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: apt.color,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
apt.subject,
|
||||||
|
style: isCrossedOut(apt)
|
||||||
|
? const TextStyle(decoration: TextDecoration.lineThrough)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
subtitle: Text(_subtitleFor(apt)),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetCtx).pop();
|
||||||
|
onAppointmentTap(apt);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _subtitleFor(Appointment a) {
|
||||||
|
if (_isAllDayLike(a)) return 'Ganztägig';
|
||||||
|
return '${_hm(a.startTime)}–${_hm(a.endTime)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _hm(DateTime t) =>
|
||||||
|
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (appointments.isEmpty) return const SizedBox.shrink();
|
||||||
|
final sorted = [...appointments]
|
||||||
|
..sort((a, b) {
|
||||||
|
final aLike = _isAllDayLike(a);
|
||||||
|
final bLike = _isAllDayLike(b);
|
||||||
|
if (aLike && !bLike) return -1;
|
||||||
|
if (!aLike && bLike) return 1;
|
||||||
|
return a.startTime.compareTo(b.startTime);
|
||||||
|
});
|
||||||
|
final visible = sorted.length <= maxVisible
|
||||||
|
? sorted
|
||||||
|
: sorted.take(maxVisible - 1).toList();
|
||||||
|
final overflow =
|
||||||
|
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < visible.length; i++) ...[
|
||||||
|
if (i > 0) SizedBox(height: chipSpacing),
|
||||||
|
SizedBox(
|
||||||
|
height: chipHeight,
|
||||||
|
child: _OutsideChip(
|
||||||
|
appointment: visible[i],
|
||||||
|
onTap: () => onAppointmentTap(visible[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (overflow.isNotEmpty) ...[
|
||||||
|
SizedBox(height: chipSpacing),
|
||||||
|
SizedBox(
|
||||||
|
height: chipHeight,
|
||||||
|
child: _OutsideOverflowChip(
|
||||||
|
count: overflow.length,
|
||||||
|
onTap: () => _showOverflow(context, overflow),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OutsideChip extends StatelessWidget {
|
||||||
|
final Appointment appointment;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _OutsideChip({required this.appointment, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final allDay = _isAllDayLike(appointment);
|
||||||
|
final timeLabel = allDay
|
||||||
|
? null
|
||||||
|
: '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}';
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: appointment.color.withAlpha(60),
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(7)),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
appointment.subject,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
softWrap: false,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (timeLabel != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
timeLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
softWrap: false,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OutsideOverflowChip extends StatelessWidget {
|
||||||
|
final int count;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _OutsideOverflowChip({required this.count, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.secondaryContainer,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'+$count weitere',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _DayHeaderStrip extends StatelessWidget {
|
class _DayHeaderStrip extends StatelessWidget {
|
||||||
final DateTime weekStart;
|
final DateTime weekStart;
|
||||||
final DateTime today;
|
final DateTime today;
|
||||||
@@ -301,7 +588,7 @@ class _WeekGrid extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final perDay = _expandAppointmentsForWeek(appointments, weekStart);
|
final partitioned = _partitionAppointmentsForWeek(appointments, weekStart);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -316,7 +603,7 @@ class _WeekGrid extends StatelessWidget {
|
|||||||
child: _DayColumn(
|
child: _DayColumn(
|
||||||
date: weekStart.add(Duration(days: d)),
|
date: weekStart.add(Duration(days: d)),
|
||||||
schedule: schedule,
|
schedule: schedule,
|
||||||
appointments: perDay[d],
|
appointments: partitioned.inside[d],
|
||||||
timeRegions: timeRegions,
|
timeRegions: timeRegions,
|
||||||
layout: layout,
|
layout: layout,
|
||||||
today: today,
|
today: today,
|
||||||
@@ -389,9 +676,9 @@ class _PeriodLabel extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final timeStyle = theme.textTheme.labelSmall?.copyWith(
|
final timeStyle = theme.textTheme.labelSmall?.copyWith(
|
||||||
color: secondaryTextColor,
|
color: secondaryTextColor.withAlpha(140),
|
||||||
height: 1.0,
|
height: 1.0,
|
||||||
fontSize: 10,
|
fontSize: 9,
|
||||||
);
|
);
|
||||||
const tightTextHeight = TextHeightBehavior(
|
const tightTextHeight = TextHeightBehavior(
|
||||||
applyHeightToFirstAscent: false,
|
applyHeightToFirstAscent: false,
|
||||||
@@ -422,7 +709,7 @@ class _PeriodLabel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${period.name}.',
|
period.name,
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -728,25 +1015,34 @@ List<_BoundRegion> _expandRegionsForDay(List<TimeRegion> regions, DateTime day)
|
|||||||
bool _isSameDay(DateTime a, DateTime b) =>
|
bool _isSameDay(DateTime a, DateTime b) =>
|
||||||
a.year == b.year && a.month == b.month && a.day == b.day;
|
a.year == b.year && a.month == b.month && a.day == b.day;
|
||||||
|
|
||||||
/// Expands the given list of appointments across the visible 5-day work week,
|
/// Expands the given list of appointments across the visible 5-day work week
|
||||||
/// resolving any RRULE-based recurrences into per-day synthetic instances.
|
/// (resolving RRULE recurrences) and splits each day's events into two
|
||||||
/// Returns a list of length 5 (Monday..Friday); each entry holds the
|
/// buckets: those that fit within the school-hours grid (`inside`) and those
|
||||||
/// appointments occurring on that day, with `startTime` and `endTime` shifted
|
/// that don't (`outside` — all-day events and events that start before
|
||||||
/// to the actual occurrence date (preserving time-of-day and duration). The
|
/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket
|
||||||
/// original `id`, `subject`, `color`, `location` and `notes` are kept so taps
|
/// is rendered as chips above the grid.
|
||||||
/// still resolve to the correct underlying event.
|
({List<List<Appointment>> inside, List<List<Appointment>> outside})
|
||||||
List<List<Appointment>> _expandAppointmentsForWeek(
|
_partitionAppointmentsForWeek(
|
||||||
List<Appointment> appointments, DateTime weekStart) {
|
List<Appointment> appointments, DateTime weekStart) {
|
||||||
final perDay = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
final inside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||||
|
final outside = List<List<Appointment>>.generate(5, (_) => <Appointment>[]);
|
||||||
final weekEnd = weekStart.add(const Duration(days: 5));
|
final weekEnd = weekStart.add(const Duration(days: 5));
|
||||||
final weekStartUtc = weekStart.toUtc();
|
final weekStartUtc = weekStart.toUtc();
|
||||||
final weekEndUtc = weekEnd.toUtc();
|
final weekEndUtc = weekEnd.toUtc();
|
||||||
|
|
||||||
|
void place(int idx, Appointment a) {
|
||||||
|
if (_isOutsideSchoolHours(a)) {
|
||||||
|
outside[idx].add(a);
|
||||||
|
} else {
|
||||||
|
inside[idx].add(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (final a in appointments) {
|
for (final a in appointments) {
|
||||||
final rule = a.recurrenceRule;
|
final rule = a.recurrenceRule;
|
||||||
if (rule == null || rule.isEmpty) {
|
if (rule == null || rule.isEmpty) {
|
||||||
final idx = a.startTime.difference(weekStart).inDays;
|
final idx = _dayIndex(a.startTime, weekStart);
|
||||||
if (idx >= 0 && idx < 5) perDay[idx].add(a);
|
if (idx >= 0 && idx < 5) place(idx, a);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -763,25 +1059,53 @@ List<List<Appointment>> _expandAppointmentsForWeek(
|
|||||||
if (idx < 0 || idx >= 5) continue;
|
if (idx < 0 || idx >= 5) continue;
|
||||||
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
|
final newStart = DateTime(occLocal.year, occLocal.month, occLocal.day,
|
||||||
a.startTime.hour, a.startTime.minute);
|
a.startTime.hour, a.startTime.minute);
|
||||||
perDay[idx].add(Appointment(
|
place(
|
||||||
id: a.id,
|
idx,
|
||||||
startTime: newStart,
|
Appointment(
|
||||||
endTime: newStart.add(duration),
|
id: a.id,
|
||||||
subject: a.subject,
|
startTime: newStart,
|
||||||
color: a.color,
|
endTime: newStart.add(duration),
|
||||||
location: a.location,
|
subject: a.subject,
|
||||||
notes: a.notes,
|
color: a.color,
|
||||||
));
|
location: a.location,
|
||||||
|
notes: a.notes,
|
||||||
|
isAllDay: a.isAllDay,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Malformed RRULE → behave as non-recurring (anchor day only).
|
final idx = _dayIndex(a.startTime, weekStart);
|
||||||
final idx = a.startTime.difference(weekStart).inDays;
|
if (idx >= 0 && idx < 5) place(idx, a);
|
||||||
if (idx >= 0 && idx < 5) perDay[idx].add(a);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return perDay;
|
return (inside: inside, outside: outside);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _dayIndex(DateTime t, DateTime weekStart) =>
|
||||||
|
DateTime(t.year, t.month, t.day).difference(weekStart).inDays;
|
||||||
|
|
||||||
|
/// True when the appointment doesn't fit into the school-hours grid:
|
||||||
|
/// all-day, fully before the grid start, fully after the grid end, engulfing
|
||||||
|
/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day
|
||||||
|
/// event the source system happens to represent with explicit times).
|
||||||
|
bool _isOutsideSchoolHours(Appointment a) {
|
||||||
|
if (_isAllDayLike(a)) return true;
|
||||||
|
final schoolStart = (kCalendarStartHour * 60).round();
|
||||||
|
final schoolEnd = (kCalendarEndHour * 60).round();
|
||||||
|
final startMin = a.startTime.hour * 60 + a.startTime.minute;
|
||||||
|
final endMin = a.endTime.hour * 60 + a.endTime.minute;
|
||||||
|
if (endMin <= schoolStart) return true;
|
||||||
|
if (startMin >= schoolEnd) return true;
|
||||||
|
if (startMin <= schoolStart && endMin >= schoolEnd) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Either explicitly marked as all-day, or so long it's effectively a full
|
||||||
|
/// day from the user's perspective. We compare in minutes (not hours) because
|
||||||
|
/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9.
|
||||||
|
bool _isAllDayLike(Appointment a) =>
|
||||||
|
a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60;
|
||||||
|
|
||||||
/// Maps lesson periods to vertical screen positions. Every non-break period
|
/// Maps lesson periods to vertical screen positions. Every non-break period
|
||||||
/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`.
|
/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`.
|
||||||
/// Short transition gaps (Wechselzeiten) between periods are not represented
|
/// Short transition gaps (Wechselzeiten) between periods are not represented
|
||||||
|
|||||||
Reference in New Issue
Block a user