added base homescreen-widget setup, working on Android, iOS in progress

This commit is contained in:
2026-05-09 18:01:05 +02:00
parent 0ff5eb7bc9
commit 00664c66a8
66 changed files with 5600 additions and 4 deletions
+44 -1
View File
@@ -13,15 +13,19 @@ import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart';
import 'notification/notification_tasks.dart';
import 'notification/notify_updater.dart';
import 'routing/app_routes.dart';
import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_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/timetable/bloc/timetable_bloc.dart';
import 'state/app/modules/timetable/bloc/timetable_state.dart';
import 'storage/settings.dart' as model;
import 'utils/debouncer.dart';
import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart';
import 'widget_data/widget_navigation.dart';
import 'widget_data/widget_publisher.dart';
class App extends StatefulWidget {
const App({super.key});
@@ -33,6 +37,7 @@ class App extends StatefulWidget {
class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _refetchChats;
late Timer _updateTimings;
StreamSubscription<dynamic>? _timetableWidgetSync;
// 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.
@@ -52,9 +57,16 @@ class _AppState extends State<App> with WidgetsBindingObserver {
log('Refreshing due to LifecycleChange');
NotificationTasks.updateProviders(context);
});
_handlePendingWidgetNavigation();
}
}
Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return;
AppRoutes.goToTab(context, Modules.timetable);
}
@override
void initState() {
super.initState();
@@ -69,7 +81,37 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// App is freshly mounted on every login (BlocConsumer in main.dart
// swaps it in for Login), so this also covers the post-logout case
// where the bloc was reset to an empty state and needs a fresh fetch.
context.read<TimetableBloc>().refresh();
final timetable = context.read<TimetableBloc>();
timetable.refresh();
// Push the freshest timetable state into the home-screen widget any
// time the BLoC reports new data — without waiting for the periodic
// background refresh. This is the "user just opened the app" path:
// the widget gets the same data the user is looking at on screen.
final settingsCubit = context.read<SettingsCubit>();
_timetableWidgetSync?.cancel();
_timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data;
if (data is TimetableState && !state.isLoading) {
unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
),
);
}
});
// Also publish the current state once, in case data is already loaded
// from hydrated storage before the listener attaches.
final initialData = timetable.state.data;
if (initialData is TimetableState) {
unawaited(
WidgetPublisher.publishFromBlocState(
initialData,
settings: settingsCubit.val(),
),
);
}
unawaited(_handlePendingWidgetNavigation());
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
@@ -115,6 +157,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void dispose() {
_refetchChats.cancel();
_updateTimings.cancel();
_timetableWidgetSync?.cancel();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
+178
View File
@@ -0,0 +1,178 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'package:workmanager/workmanager.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../api/webuntis/queries/authenticate/authenticate.dart';
import '../api/webuntis/queries/get_holidays/get_holidays.dart';
import '../api/webuntis/queries/get_holidays/get_holidays_response.dart';
import '../api/webuntis/queries/get_rooms/get_rooms.dart';
import '../api/webuntis/queries/get_rooms/get_rooms_response.dart';
import '../api/webuntis/queries/get_subjects/get_subjects.dart';
import '../api/webuntis/queries/get_subjects/get_subjects_response.dart';
import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart';
import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart';
import '../api/webuntis/queries/get_timetable/get_timetable.dart';
import '../api/webuntis/queries/get_timetable/get_timetable_params.dart';
import '../model/account_data.dart';
import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart';
import '../widget_data/widget_sync.dart';
/// Periodic widget refresh in a background Dart isolate. Native HTTP would
/// mean reimplementing WebUntis JSON-RPC (auth, session-timeout retry -8520,
/// payload quirks) twice — Dart isolate keeps that logic in one place.
class WidgetBackgroundTask {
static const String periodicTaskName = 'eu.mhsl.marianum.widget.refresh';
static const String oneOffTaskName = 'eu.mhsl.marianum.widget.refresh.once';
static const Duration periodicFrequency = Duration(minutes: 30);
static Future<void> initialize() async {
await Workmanager().initialize(_callbackDispatcher);
await Workmanager().registerPeriodicTask(
periodicTaskName,
periodicTaskName,
frequency: periodicFrequency,
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
backoffPolicy: BackoffPolicy.linear,
backoffPolicyDelay: const Duration(minutes: 5),
);
}
static Future<void> requestImmediateRefresh() async {
await Workmanager().registerOneOffTask(
'$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
oneOffTaskName,
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.append,
);
}
static Future<void> cancelAll() async {
await Workmanager().cancelAll();
}
}
@pragma('vm:entry-point')
void _callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
try {
WidgetsFlutterBinding.ensureInitialized();
await AccountData().waitForPopulation();
if (!AccountData().isPopulated()) {
log('[widget-bg] not logged in, skipping refresh');
await WidgetSync.setLoggedIn(false);
await WidgetSync.triggerUpdate();
return true;
}
await _refresh();
return true;
} on Exception catch (e, s) {
log('[widget-bg] refresh failed: $e', stackTrace: s);
// false → Workmanager retries with backoff. Native side keeps the
// last good snapshot so the user still sees something.
return false;
}
});
}
Future<void> _refresh() async {
await WidgetSync.ensureInitialized();
await Authenticate.createSession();
final now = WidgetPublisher.widgetNow();
final dateFormat = DateFormat('yyyyMMdd');
// 14-day window so the week-widget rolls forward into next Monday's
// lessons on Friday evening.
final weekStart = _startOfWeek(now);
final weekEndExclusive = weekStart.add(const Duration(days: 14));
final session = await Authenticate.getSession();
final timetable = await GetTimetable(
GetTimetableParams(
options: GetTimetableParamsOptions(
element: GetTimetableParamsOptionsElement(
id: session.personId,
type: session.personType,
keyType: GetTimetableParamsOptionsElementKeyType.id,
),
startDate: int.parse(dateFormat.format(weekStart)),
endDate: int.parse(
dateFormat.format(weekEndExclusive.subtract(const Duration(days: 1))),
),
teacherFields: GetTimetableParamsOptionsFields.all,
subjectFields: GetTimetableParamsOptionsFields.all,
roomFields: GetTimetableParamsOptionsFields.all,
klasseFields: GetTimetableParamsOptionsFields.all,
),
),
).run();
// Reference data — failures fall through to null in the mapper rather
// than aborting the whole refresh.
final subjects = await _runOrNull<GetSubjectsResponse>(() => GetSubjects().run());
final rooms = await _runOrNull<GetRoomsResponse>(() => GetRooms().run());
final holidays = await _runOrNull<GetHolidaysResponse>(() => GetHolidays().run());
final timegrid = await _runOrNull<GetTimegridUnitsResponse>(
() => GetTimegridUnits().run(),
);
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>(
() => GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()),
).run(),
);
final lessons = timetable.result;
final connectDouble = await WidgetSync.getConnectDoubleLessons();
final dayData = WidgetDataMapper.buildDayData(
now: now,
lessons: lessons,
subjects: subjects,
rooms: rooms,
holidays: holidays,
timegrid: timegrid,
customEvents: customEvents,
connectDoubleLessons: connectDouble,
);
final weekData = WidgetDataMapper.buildWeekData(
now: now,
lessons: lessons,
subjects: subjects,
rooms: rooms,
holidays: holidays,
timegrid: timegrid,
customEvents: customEvents,
connectDoubleLessons: connectDouble,
);
await WidgetSync.writeDayData(dayData);
await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true);
await WidgetSync.triggerUpdate();
log(
'[widget-bg] refreshed: day=${dayData.lessons.length} '
'week=${weekData.lessons.length}',
);
}
DateTime _startOfWeek(DateTime reference) {
final monday = reference.subtract(Duration(days: reference.weekday - 1));
return DateTime(monday.year, monday.month, monday.day);
}
Future<T?> _runOrNull<T>(Future<T> Function() task) async {
try {
return await task();
} on Exception catch (e) {
log('[widget-bg] reference fetch failed: $e');
return null;
}
}
+17
View File
@@ -19,6 +19,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
import 'app.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
import 'model/account_data.dart';
import 'state/app/modules/account/bloc/account_bloc.dart';
@@ -35,6 +36,7 @@ import 'view/login/login.dart';
import 'widget/app_progress_indicator.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/cache_view.dart';
import 'widget_data/widget_sync.dart';
Future<void> main() async {
log('MarianumMobile started');
@@ -72,6 +74,15 @@ Future<void> main() async {
await Future.wait(initialisationTasks);
log('app initialisation done!');
// Wire up the home-screen widget bridge before runApp so any widget render
// triggered during startup hits initialised native storage.
await WidgetSync.ensureInitialized();
unawaited(
WidgetBackgroundTask.initialize().onError(
(e, _) => log('Workmanager init failed: $e'),
),
);
unawaited(
FirebaseMessaging.instance.getToken().then(
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
@@ -287,6 +298,12 @@ Future<void> _wipeUserState({
await prefs.clear();
await HydratedBloc.storage.clear();
await const CacheView().clear();
// Stop the periodic widget refresh job so the background isolate doesn't
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
// on the next successful login.
await WidgetBackgroundTask.cancelAll();
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
} catch (e, s) {
log('User state wipe failed: $e', stackTrace: s);
}
+8
View File
@@ -1,6 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../background/widget_background_task.dart';
import '../../state/app/modules/account/bloc/account_bloc.dart';
import '../../state/app/modules/account/bloc/account_state.dart';
import '../../theming/light_app_theme.dart';
@@ -34,6 +37,11 @@ class _LoginState extends State<Login> {
void _onLoginSuccess() {
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
// Re-register the periodic refresh (cancelAll runs on logout) and kick
// off an immediate one-off so the widget populates within seconds
// instead of waiting up to 30 minutes for the next periodic slot.
unawaited(WidgetBackgroundTask.initialize());
unawaited(WidgetBackgroundTask.requestImmediateRefresh());
}
@override
+6
View File
@@ -7,6 +7,7 @@ import '../../api/errors/error_mapper.dart';
import '../../api/marianumcloud/talk/room/get_room.dart';
import '../../api/marianumcloud/talk/room/get_room_params.dart';
import '../../model/account_data.dart';
import '../../widget_data/widget_sync.dart';
/// Owns the login flow's transient state (loading, last error) so it can be
/// driven from a thin Stateful view and unit-tested without a widget tree.
@@ -31,6 +32,11 @@ class LoginController extends ChangeNotifier {
final user = username.trim().toLowerCase();
try {
await AccountData().removeData();
// Drop any cached widget snapshot from a previous account before the
// new credentials populate it — otherwise a re-login with a different
// user briefly shows the previous owner's timetable on the home screen.
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
await AccountData().setData(user, password);
await GetRoom(GetRoomParams(includeStatus: false)).run();
_loading = false;
+76
View File
@@ -0,0 +1,76 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'widget_data.freezed.dart';
part 'widget_data.g.dart';
/// Status mirror of [LessonStatus] in
/// `lib/view/pages/timetable/data/lesson_status.dart`. Native widget code
/// switches on the string form, so the JSON name MUST stay stable.
enum WidgetLessonStatus {
regular,
ongoing,
past,
cancelled,
irregular,
teacherChanged,
event,
}
@freezed
abstract class WidgetLesson with _$WidgetLesson {
const factory WidgetLesson({
required DateTime start,
required DateTime end,
required String subjectShort,
String? subjectLong,
String? room,
String? teacher,
String? originalTeacher,
required WidgetLessonStatus status,
String? customColor,
@Default(0) int siblingCount,
}) = _WidgetLesson;
factory WidgetLesson.fromJson(Map<String, Object?> json) =>
_$WidgetLessonFromJson(json);
}
@freezed
abstract class WidgetPeriod with _$WidgetPeriod {
const factory WidgetPeriod({
/// Webuntis period name — typically the lesson number as string ("1",
/// "2", "3", …). Native renderers append a trailing "." for display.
required String name,
/// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in
/// Kotlin/Swift without re-parsing time strings.
required int startMinutes,
required int endMinutes,
/// Position on the **virtual** time axis used by the widget. Small
/// between-lesson gaps are squeezed out so periods stack flush; only
/// big breaks (> 5 min) remain as visible gaps. Computed by the
/// mapper so native renderers don't have to redo the maths.
required int virtualStartMinutes,
required int virtualEndMinutes,
}) = _WidgetPeriod;
factory WidgetPeriod.fromJson(Map<String, Object?> json) =>
_$WidgetPeriodFromJson(json);
}
@freezed
abstract class WidgetTimetableData with _$WidgetTimetableData {
const factory WidgetTimetableData({
required DateTime fetchedAt,
/// The day this widget snapshot is "about" — display anchor.
/// For the day variant: the rendered school day.
/// For the week variant: the Monday of the rendered school week.
required DateTime anchorDate,
required List<WidgetLesson> lessons,
@Default(<WidgetPeriod>[]) List<WidgetPeriod> periods,
@Default(false) bool isHoliday,
String? holidayName,
}) = _WidgetTimetableData;
factory WidgetTimetableData.fromJson(Map<String, Object?> json) =>
_$WidgetTimetableDataFromJson(json);
}
+891
View File
@@ -0,0 +1,891 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'widget_data.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$WidgetLesson {
DateTime get start; DateTime get end; String get subjectShort; String? get subjectLong; String? get room; String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor; int get siblingCount;
/// Create a copy of WidgetLesson
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$WidgetLessonCopyWith<WidgetLesson> get copyWith => _$WidgetLessonCopyWithImpl<WidgetLesson>(this as WidgetLesson, _$identity);
/// Serializes this WidgetLesson to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount);
@override
String toString() {
return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)';
}
}
/// @nodoc
abstract mixin class $WidgetLessonCopyWith<$Res> {
factory $WidgetLessonCopyWith(WidgetLesson value, $Res Function(WidgetLesson) _then) = _$WidgetLessonCopyWithImpl;
@useResult
$Res call({
DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount
});
}
/// @nodoc
class _$WidgetLessonCopyWithImpl<$Res>
implements $WidgetLessonCopyWith<$Res> {
_$WidgetLessonCopyWithImpl(this._self, this._then);
final WidgetLesson _self;
final $Res Function(WidgetLesson) _then;
/// Create a copy of WidgetLesson
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) {
return _then(_self.copyWith(
start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable
as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable
as DateTime,subjectShort: null == subjectShort ? _self.subjectShort : subjectShort // ignore: cast_nullable_to_non_nullable
as String,subjectLong: freezed == subjectLong ? _self.subjectLong : subjectLong // ignore: cast_nullable_to_non_nullable
as String?,room: freezed == room ? _self.room : room // ignore: cast_nullable_to_non_nullable
as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast_nullable_to_non_nullable
as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable
as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable
as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [WidgetLesson].
extension WidgetLessonPatterns on WidgetLesson {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _WidgetLesson value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _WidgetLesson() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _WidgetLesson value) $default,){
final _that = this;
switch (_that) {
case _WidgetLesson():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _WidgetLesson value)? $default,){
final _that = this;
switch (_that) {
case _WidgetLesson() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _WidgetLesson() when $default != null:
return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount) $default,) {final _that = this;
switch (_that) {
case _WidgetLesson():
return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,) {final _that = this;
switch (_that) {
case _WidgetLesson() when $default != null:
return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _WidgetLesson implements WidgetLesson {
const _WidgetLesson({required this.start, required this.end, required this.subjectShort, this.subjectLong, this.room, this.teacher, this.originalTeacher, required this.status, this.customColor, this.siblingCount = 0});
factory _WidgetLesson.fromJson(Map<String, dynamic> json) => _$WidgetLessonFromJson(json);
@override final DateTime start;
@override final DateTime end;
@override final String subjectShort;
@override final String? subjectLong;
@override final String? room;
@override final String? teacher;
@override final String? originalTeacher;
@override final WidgetLessonStatus status;
@override final String? customColor;
@override@JsonKey() final int siblingCount;
/// Create a copy of WidgetLesson
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$WidgetLessonCopyWith<_WidgetLesson> get copyWith => __$WidgetLessonCopyWithImpl<_WidgetLesson>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$WidgetLessonToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount);
@override
String toString() {
return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)';
}
}
/// @nodoc
abstract mixin class _$WidgetLessonCopyWith<$Res> implements $WidgetLessonCopyWith<$Res> {
factory _$WidgetLessonCopyWith(_WidgetLesson value, $Res Function(_WidgetLesson) _then) = __$WidgetLessonCopyWithImpl;
@override @useResult
$Res call({
DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount
});
}
/// @nodoc
class __$WidgetLessonCopyWithImpl<$Res>
implements _$WidgetLessonCopyWith<$Res> {
__$WidgetLessonCopyWithImpl(this._self, this._then);
final _WidgetLesson _self;
final $Res Function(_WidgetLesson) _then;
/// Create a copy of WidgetLesson
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) {
return _then(_WidgetLesson(
start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable
as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable
as DateTime,subjectShort: null == subjectShort ? _self.subjectShort : subjectShort // ignore: cast_nullable_to_non_nullable
as String,subjectLong: freezed == subjectLong ? _self.subjectLong : subjectLong // ignore: cast_nullable_to_non_nullable
as String?,room: freezed == room ? _self.room : room // ignore: cast_nullable_to_non_nullable
as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast_nullable_to_non_nullable
as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable
as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable
as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
mixin _$WidgetPeriod {
/// Webuntis period name — typically the lesson number as string ("1",
/// "2", "3", …). Native renderers append a trailing "." for display.
String get name;/// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in
/// Kotlin/Swift without re-parsing time strings.
int get startMinutes; int get endMinutes;/// Position on the **virtual** time axis used by the widget. Small
/// between-lesson gaps are squeezed out so periods stack flush; only
/// big breaks (> 5 min) remain as visible gaps. Computed by the
/// mapper so native renderers don't have to redo the maths.
int get virtualStartMinutes; int get virtualEndMinutes;
/// Create a copy of WidgetPeriod
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$WidgetPeriodCopyWith<WidgetPeriod> get copyWith => _$WidgetPeriodCopyWithImpl<WidgetPeriod>(this as WidgetPeriod, _$identity);
/// Serializes this WidgetPeriod to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes);
@override
String toString() {
return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)';
}
}
/// @nodoc
abstract mixin class $WidgetPeriodCopyWith<$Res> {
factory $WidgetPeriodCopyWith(WidgetPeriod value, $Res Function(WidgetPeriod) _then) = _$WidgetPeriodCopyWithImpl;
@useResult
$Res call({
String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes
});
}
/// @nodoc
class _$WidgetPeriodCopyWithImpl<$Res>
implements $WidgetPeriodCopyWith<$Res> {
_$WidgetPeriodCopyWithImpl(this._self, this._then);
final WidgetPeriod _self;
final $Res Function(WidgetPeriod) _then;
/// Create a copy of WidgetPeriod
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? startMinutes = null,Object? endMinutes = null,Object? virtualStartMinutes = null,Object? virtualEndMinutes = null,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,startMinutes: null == startMinutes ? _self.startMinutes : startMinutes // ignore: cast_nullable_to_non_nullable
as int,endMinutes: null == endMinutes ? _self.endMinutes : endMinutes // ignore: cast_nullable_to_non_nullable
as int,virtualStartMinutes: null == virtualStartMinutes ? _self.virtualStartMinutes : virtualStartMinutes // ignore: cast_nullable_to_non_nullable
as int,virtualEndMinutes: null == virtualEndMinutes ? _self.virtualEndMinutes : virtualEndMinutes // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [WidgetPeriod].
extension WidgetPeriodPatterns on WidgetPeriod {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _WidgetPeriod value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _WidgetPeriod() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _WidgetPeriod value) $default,){
final _that = this;
switch (_that) {
case _WidgetPeriod():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _WidgetPeriod value)? $default,){
final _that = this;
switch (_that) {
case _WidgetPeriod() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _WidgetPeriod() when $default != null:
return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes) $default,) {final _that = this;
switch (_that) {
case _WidgetPeriod():
return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes)? $default,) {final _that = this;
switch (_that) {
case _WidgetPeriod() when $default != null:
return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _WidgetPeriod implements WidgetPeriod {
const _WidgetPeriod({required this.name, required this.startMinutes, required this.endMinutes, required this.virtualStartMinutes, required this.virtualEndMinutes});
factory _WidgetPeriod.fromJson(Map<String, dynamic> json) => _$WidgetPeriodFromJson(json);
/// Webuntis period name — typically the lesson number as string ("1",
/// "2", "3", …). Native renderers append a trailing "." for display.
@override final String name;
/// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in
/// Kotlin/Swift without re-parsing time strings.
@override final int startMinutes;
@override final int endMinutes;
/// Position on the **virtual** time axis used by the widget. Small
/// between-lesson gaps are squeezed out so periods stack flush; only
/// big breaks (> 5 min) remain as visible gaps. Computed by the
/// mapper so native renderers don't have to redo the maths.
@override final int virtualStartMinutes;
@override final int virtualEndMinutes;
/// Create a copy of WidgetPeriod
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$WidgetPeriodCopyWith<_WidgetPeriod> get copyWith => __$WidgetPeriodCopyWithImpl<_WidgetPeriod>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$WidgetPeriodToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes);
@override
String toString() {
return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)';
}
}
/// @nodoc
abstract mixin class _$WidgetPeriodCopyWith<$Res> implements $WidgetPeriodCopyWith<$Res> {
factory _$WidgetPeriodCopyWith(_WidgetPeriod value, $Res Function(_WidgetPeriod) _then) = __$WidgetPeriodCopyWithImpl;
@override @useResult
$Res call({
String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes
});
}
/// @nodoc
class __$WidgetPeriodCopyWithImpl<$Res>
implements _$WidgetPeriodCopyWith<$Res> {
__$WidgetPeriodCopyWithImpl(this._self, this._then);
final _WidgetPeriod _self;
final $Res Function(_WidgetPeriod) _then;
/// Create a copy of WidgetPeriod
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? startMinutes = null,Object? endMinutes = null,Object? virtualStartMinutes = null,Object? virtualEndMinutes = null,}) {
return _then(_WidgetPeriod(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,startMinutes: null == startMinutes ? _self.startMinutes : startMinutes // ignore: cast_nullable_to_non_nullable
as int,endMinutes: null == endMinutes ? _self.endMinutes : endMinutes // ignore: cast_nullable_to_non_nullable
as int,virtualStartMinutes: null == virtualStartMinutes ? _self.virtualStartMinutes : virtualStartMinutes // ignore: cast_nullable_to_non_nullable
as int,virtualEndMinutes: null == virtualEndMinutes ? _self.virtualEndMinutes : virtualEndMinutes // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
mixin _$WidgetTimetableData {
DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor.
/// For the day variant: the rendered school day.
/// For the week variant: the Monday of the rendered school week.
DateTime get anchorDate; List<WidgetLesson> get lessons; List<WidgetPeriod> get periods; bool get isHoliday; String? get holidayName;
/// Create a copy of WidgetTimetableData
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$WidgetTimetableDataCopyWith<WidgetTimetableData> get copyWith => _$WidgetTimetableDataCopyWithImpl<WidgetTimetableData>(this as WidgetTimetableData, _$identity);
/// Serializes this WidgetTimetableData to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName);
@override
String toString() {
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)';
}
}
/// @nodoc
abstract mixin class $WidgetTimetableDataCopyWith<$Res> {
factory $WidgetTimetableDataCopyWith(WidgetTimetableData value, $Res Function(WidgetTimetableData) _then) = _$WidgetTimetableDataCopyWithImpl;
@useResult
$Res call({
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName
});
}
/// @nodoc
class _$WidgetTimetableDataCopyWithImpl<$Res>
implements $WidgetTimetableDataCopyWith<$Res> {
_$WidgetTimetableDataCopyWithImpl(this._self, this._then);
final WidgetTimetableData _self;
final $Res Function(WidgetTimetableData) _then;
/// Create a copy of WidgetTimetableData
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
return _then(_self.copyWith(
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable
as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable
as DateTime,lessons: null == lessons ? _self.lessons : lessons // ignore: cast_nullable_to_non_nullable
as List<WidgetLesson>,periods: null == periods ? _self.periods : periods // ignore: cast_nullable_to_non_nullable
as List<WidgetPeriod>,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable
as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// Adds pattern-matching-related methods to [WidgetTimetableData].
extension WidgetTimetableDataPatterns on WidgetTimetableData {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _WidgetTimetableData value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _WidgetTimetableData() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _WidgetTimetableData value) $default,){
final _that = this;
switch (_that) {
case _WidgetTimetableData():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _WidgetTimetableData value)? $default,){
final _that = this;
switch (_that) {
case _WidgetTimetableData() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _WidgetTimetableData() when $default != null:
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName) $default,) {final _that = this;
switch (_that) {
case _WidgetTimetableData():
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName)? $default,) {final _that = this;
switch (_that) {
case _WidgetTimetableData() when $default != null:
return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _WidgetTimetableData implements WidgetTimetableData {
const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List<WidgetLesson> lessons, final List<WidgetPeriod> periods = const <WidgetPeriod>[], this.isHoliday = false, this.holidayName}): _lessons = lessons,_periods = periods;
factory _WidgetTimetableData.fromJson(Map<String, dynamic> json) => _$WidgetTimetableDataFromJson(json);
@override final DateTime fetchedAt;
/// The day this widget snapshot is "about" — display anchor.
/// For the day variant: the rendered school day.
/// For the week variant: the Monday of the rendered school week.
@override final DateTime anchorDate;
final List<WidgetLesson> _lessons;
@override List<WidgetLesson> get lessons {
if (_lessons is EqualUnmodifiableListView) return _lessons;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_lessons);
}
final List<WidgetPeriod> _periods;
@override@JsonKey() List<WidgetPeriod> get periods {
if (_periods is EqualUnmodifiableListView) return _periods;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_periods);
}
@override@JsonKey() final bool isHoliday;
@override final String? holidayName;
/// Create a copy of WidgetTimetableData
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$WidgetTimetableDataCopyWith<_WidgetTimetableData> get copyWith => __$WidgetTimetableDataCopyWithImpl<_WidgetTimetableData>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$WidgetTimetableDataToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName);
@override
String toString() {
return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)';
}
}
/// @nodoc
abstract mixin class _$WidgetTimetableDataCopyWith<$Res> implements $WidgetTimetableDataCopyWith<$Res> {
factory _$WidgetTimetableDataCopyWith(_WidgetTimetableData value, $Res Function(_WidgetTimetableData) _then) = __$WidgetTimetableDataCopyWithImpl;
@override @useResult
$Res call({
DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> periods, bool isHoliday, String? holidayName
});
}
/// @nodoc
class __$WidgetTimetableDataCopyWithImpl<$Res>
implements _$WidgetTimetableDataCopyWith<$Res> {
__$WidgetTimetableDataCopyWithImpl(this._self, this._then);
final _WidgetTimetableData _self;
final $Res Function(_WidgetTimetableData) _then;
/// Create a copy of WidgetTimetableData
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
return _then(_WidgetTimetableData(
fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable
as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable
as DateTime,lessons: null == lessons ? _self._lessons : lessons // ignore: cast_nullable_to_non_nullable
as List<WidgetLesson>,periods: null == periods ? _self._periods : periods // ignore: cast_nullable_to_non_nullable
as List<WidgetPeriod>,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable
as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on
+90
View File
@@ -0,0 +1,90 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'widget_data.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_WidgetLesson _$WidgetLessonFromJson(Map<String, dynamic> json) =>
_WidgetLesson(
start: DateTime.parse(json['start'] as String),
end: DateTime.parse(json['end'] as String),
subjectShort: json['subjectShort'] as String,
subjectLong: json['subjectLong'] as String?,
room: json['room'] as String?,
teacher: json['teacher'] as String?,
originalTeacher: json['originalTeacher'] as String?,
status: $enumDecode(_$WidgetLessonStatusEnumMap, json['status']),
customColor: json['customColor'] as String?,
siblingCount: (json['siblingCount'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$WidgetLessonToJson(_WidgetLesson instance) =>
<String, dynamic>{
'start': instance.start.toIso8601String(),
'end': instance.end.toIso8601String(),
'subjectShort': instance.subjectShort,
'subjectLong': instance.subjectLong,
'room': instance.room,
'teacher': instance.teacher,
'originalTeacher': instance.originalTeacher,
'status': _$WidgetLessonStatusEnumMap[instance.status]!,
'customColor': instance.customColor,
'siblingCount': instance.siblingCount,
};
const _$WidgetLessonStatusEnumMap = {
WidgetLessonStatus.regular: 'regular',
WidgetLessonStatus.ongoing: 'ongoing',
WidgetLessonStatus.past: 'past',
WidgetLessonStatus.cancelled: 'cancelled',
WidgetLessonStatus.irregular: 'irregular',
WidgetLessonStatus.teacherChanged: 'teacherChanged',
WidgetLessonStatus.event: 'event',
};
_WidgetPeriod _$WidgetPeriodFromJson(Map<String, dynamic> json) =>
_WidgetPeriod(
name: json['name'] as String,
startMinutes: (json['startMinutes'] as num).toInt(),
endMinutes: (json['endMinutes'] as num).toInt(),
virtualStartMinutes: (json['virtualStartMinutes'] as num).toInt(),
virtualEndMinutes: (json['virtualEndMinutes'] as num).toInt(),
);
Map<String, dynamic> _$WidgetPeriodToJson(_WidgetPeriod instance) =>
<String, dynamic>{
'name': instance.name,
'startMinutes': instance.startMinutes,
'endMinutes': instance.endMinutes,
'virtualStartMinutes': instance.virtualStartMinutes,
'virtualEndMinutes': instance.virtualEndMinutes,
};
_WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
_WidgetTimetableData(
fetchedAt: DateTime.parse(json['fetchedAt'] as String),
anchorDate: DateTime.parse(json['anchorDate'] as String),
lessons: (json['lessons'] as List<dynamic>)
.map((e) => WidgetLesson.fromJson(e as Map<String, dynamic>))
.toList(),
periods:
(json['periods'] as List<dynamic>?)
?.map((e) => WidgetPeriod.fromJson(e as Map<String, dynamic>))
.toList() ??
const <WidgetPeriod>[],
isHoliday: json['isHoliday'] as bool? ?? false,
holidayName: json['holidayName'] as String?,
);
Map<String, dynamic> _$WidgetTimetableDataToJson(
_WidgetTimetableData instance,
) => <String, dynamic>{
'fetchedAt': instance.fetchedAt.toIso8601String(),
'anchorDate': instance.anchorDate.toIso8601String(),
'lessons': instance.lessons,
'periods': instance.periods,
'isHoliday': instance.isHoliday,
'holidayName': instance.holidayName,
};
+472
View File
@@ -0,0 +1,472 @@
import 'dart:developer';
import 'package:rrule/rrule.dart';
import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../api/webuntis/queries/get_holidays/get_holidays_response.dart';
import '../api/webuntis/queries/get_rooms/get_rooms_response.dart';
import '../api/webuntis/queries/get_subjects/get_subjects_response.dart';
import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart';
import '../api/webuntis/queries/get_timetable/get_timetable_response.dart';
import '../view/pages/timetable/data/lesson_period_schedule.dart';
import '../view/pages/timetable/data/lesson_status.dart';
import '../view/pages/timetable/data/webuntis_time.dart';
import 'widget_data.dart';
class WidgetDataMapper {
/// After 17:00 the user's question shifts from "what's left today" to
/// "what's tomorrow", so the day-widget rolls forward.
static const int _dayWidgetCutoffHour = 17;
static const _weekend = {DateTime.saturday, DateTime.sunday};
static DateTime resolveDayAnchor(DateTime now) {
var candidate = DateTime(now.year, now.month, now.day);
final shiftToTomorrow =
now.hour >= _dayWidgetCutoffHour || _weekend.contains(now.weekday);
if (shiftToTomorrow) {
candidate = candidate.add(const Duration(days: 1));
}
while (_weekend.contains(candidate.weekday)) {
candidate = candidate.add(const Duration(days: 1));
}
return candidate;
}
static DateTime resolveWeekAnchor(DateTime now) {
final anchor = resolveDayAnchor(now);
final monday = anchor.subtract(Duration(days: anchor.weekday - 1));
return DateTime(monday.year, monday.month, monday.day);
}
static WidgetTimetableData buildDayData({
required DateTime now,
required Iterable<GetTimetableResponseObject> lessons,
required GetSubjectsResponse? subjects,
required GetRoomsResponse? rooms,
required GetHolidaysResponse? holidays,
GetTimegridUnitsResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true,
}) {
final anchor = resolveDayAnchor(now);
final holiday = _findHoliday(anchor, holidays);
final dayStart = anchor;
final dayEnd = anchor.add(const Duration(days: 1));
final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList();
final source = connectDoubleLessons
? _mergeAdjacentLessons(dayLessons)
: dayLessons;
final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
..._expandCustomEvents(customEvents, dayStart, dayEnd),
]..sort((a, b) => a.start.compareTo(b.start));
return WidgetTimetableData(
fetchedAt: now,
anchorDate: anchor,
lessons: _resolveCollisions(mapped),
periods: _resolvePeriods(timegrid),
isHoliday: holiday != null,
holidayName: holiday?.longName,
);
}
static WidgetTimetableData buildWeekData({
required DateTime now,
required Iterable<GetTimetableResponseObject> lessons,
required GetSubjectsResponse? subjects,
required GetRoomsResponse? rooms,
required GetHolidaysResponse? holidays,
GetTimegridUnitsResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
bool connectDoubleLessons = true,
}) {
final anchor = resolveWeekAnchor(now);
final endExclusive = anchor.add(const Duration(days: 5));
final weekLessons = lessons.where((l) {
final dt = WebuntisTime.parse(l.date, l.startTime);
return !dt.isBefore(anchor) && dt.isBefore(endExclusive);
}).toList();
// Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
// a 1st-period lesson on Tue if subject/teacher match.
final source = connectDoubleLessons
? _mergePerDay(weekLessons)
: weekLessons;
final mapped = <WidgetLesson>[
...source.map((l) => _mapLesson(l, now, subjects, rooms)),
..._expandCustomEvents(customEvents, anchor, endExclusive),
]..sort((a, b) => a.start.compareTo(b.start));
return WidgetTimetableData(
fetchedAt: now,
anchorDate: anchor,
lessons: _resolveCollisions(mapped),
periods: _resolvePeriods(timegrid),
);
}
/// cancelled (0) < event (1) < regular (2) — events replace cancelled
/// lessons but lose to real ones, leaving a `+1` hint on the survivor.
static int _priority(WidgetLessonStatus status) => switch (status) {
WidgetLessonStatus.cancelled => 0,
WidgetLessonStatus.event => 1,
_ => 2,
};
static List<WidgetLesson> _resolveCollisions(List<WidgetLesson> lessons) {
if (lessons.length <= 1) return lessons;
bool overlaps(WidgetLesson l, WidgetLesson other) =>
l != other && l.start.isBefore(other.end) && l.end.isAfter(other.start);
// Index-based: a long event covering several regulars must bump *every*
// covered lesson, not just the first overlap.
final dropped = List<bool>.filled(lessons.length, false);
final bumps = List<int>.filled(lessons.length, 0);
for (var i = 0; i < lessons.length; i++) {
final l = lessons[i];
final myPrio = _priority(l.status);
final overrideIdxs = <int>[];
for (var j = 0; j < lessons.length; j++) {
if (i == j) continue;
if (_priority(lessons[j].status) <= myPrio) continue;
if (!overlaps(l, lessons[j])) continue;
overrideIdxs.add(j);
}
if (overrideIdxs.isNotEmpty) {
dropped[i] = true;
if (l.status == WidgetLessonStatus.event) {
for (final idx in overrideIdxs) {
bumps[idx] += 1;
}
}
}
}
final filtered = <WidgetLesson>[];
for (var i = 0; i < lessons.length; i++) {
if (dropped[i]) continue;
final l = lessons[i];
filtered.add(
bumps[i] > 0
? l.copyWith(siblingCount: l.siblingCount + bumps[i])
: l,
);
}
if (filtered.length <= 1) return filtered;
final groups = <String, List<WidgetLesson>>{};
for (final l in filtered) {
final key =
'${l.start.year}-${l.start.month}-${l.start.day}-${l.start.hour}-${l.start.minute}';
groups.putIfAbsent(key, () => []).add(l);
}
final result = <WidgetLesson>[];
for (final group in groups.values) {
if (group.length == 1) {
result.add(group.first);
continue;
}
final active = group
.where((l) => l.status != WidgetLessonStatus.cancelled)
.toList();
if (active.isEmpty) {
result.addAll(group);
continue;
}
active.sort((a, b) => a.subjectShort.compareTo(b.subjectShort));
// Additive — preserves the event-bump from the priority pass, otherwise
// a slot with another regular lesson AND a hidden event would show +1
// instead of +2.
final keeper = active.first;
result.add(
keeper.copyWith(
siblingCount: keeper.siblingCount + active.length - 1,
),
);
}
return result..sort((a, b) => a.start.compareTo(b.start));
}
/// Gaps below this collapse to zero on the virtual axis so 45-min slots
/// stack flush; bigger gaps survive as visible Pause-blocks.
static const int _smallBreakThresholdMinutes = 5;
static List<WidgetPeriod> _resolvePeriods(
GetTimegridUnitsResponse? timegrid,
) {
final schedule =
(timegrid != null ? LessonPeriodSchedule.fromApi(timegrid) : null) ??
LessonPeriodSchedule.fallback();
final raw = schedule.periods
.map(
(p) => (
name: p.name,
start: p.start.hour * 60 + p.start.minute,
end: p.end.hour * 60 + p.end.minute,
),
)
.toList()
..sort((a, b) => a.start.compareTo(b.start));
final result = <WidgetPeriod>[];
var virtualOffset = 0;
int? prevEnd;
for (final p in raw) {
if (prevEnd != null) {
final gap = p.start - prevEnd;
if (gap > _smallBreakThresholdMinutes) virtualOffset += gap;
}
final duration = p.end - p.start;
result.add(
WidgetPeriod(
name: p.name,
startMinutes: p.start,
endMinutes: p.end,
virtualStartMinutes: virtualOffset,
virtualEndMinutes: virtualOffset + duration,
),
);
virtualOffset += duration;
prevEnd = p.end;
}
return result;
}
static List<GetTimetableResponseObject> _mergePerDay(
List<GetTimetableResponseObject> lessons,
) {
final byDay = <int, List<GetTimetableResponseObject>>{};
for (final l in lessons) {
byDay.putIfAbsent(l.date, () => []).add(l);
}
return [for (final group in byDay.values) ..._mergeAdjacentLessons(group)];
}
/// Mirrors `TimetableAppointmentFactory._mergeAdjacentLessons` so the
/// widget shows the same merged blocks the in-app calendar does.
static List<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> input, {
Duration maxGap = const Duration(minutes: 5),
}) {
if (input.isEmpty) return const [];
final sorted = [...input]..sort(
(a, b) => WebuntisTime.parse(
a.date,
a.startTime,
).compareTo(WebuntisTime.parse(b.date, b.startTime)),
);
final merged = <GetTimetableResponseObject>[];
for (final current in sorted) {
if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
merged.last.endTime = current.endTime;
} else {
merged.add(GetTimetableResponseObject.fromJson(current.toJson()));
}
}
return merged;
}
static bool _canMerge(
GetTimetableResponseObject a,
GetTimetableResponseObject b,
Duration maxGap,
) {
final aSubject = a.su.firstOrNull?.id;
final bSubject = b.su.firstOrNull?.id;
if (aSubject == null || bSubject == null || aSubject != bSubject) {
return false;
}
if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false;
if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false;
if (a.code != b.code) return false;
final gap = WebuntisTime.parse(
b.date,
b.startTime,
).difference(WebuntisTime.parse(a.date, a.endTime));
return !gap.isNegative && gap <= maxGap;
}
static WidgetLesson _mapLesson(
GetTimetableResponseObject lesson,
DateTime now,
GetSubjectsResponse? subjects,
GetRoomsResponse? rooms,
) {
final start = WebuntisTime.parse(lesson.date, lesson.startTime);
final end = WebuntisTime.parse(lesson.date, lesson.endTime);
final status = _mapStatus(
LessonStatusClassifier.classify(lesson, start, end, now),
);
final subject = lesson.su.firstOrNull;
// Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall
// back to "Event" so the tile isn't just a dash.
final rawSubjectName = subject?.name.trim() ?? '';
final subjectShort = rawSubjectName.isEmpty ? 'Event' : rawSubjectName;
String? subjectLong;
if (subjects != null && subject != null) {
final found = subjects.result.where((s) => s.id == subject.id).firstOrNull;
subjectLong = found?.longName;
}
subjectLong ??= subject?.longname;
final room = lesson.ro.firstOrNull;
var roomName = room?.name;
if (rooms != null && room != null) {
final resolved =
rooms.result.where((r) => r.id == room.id).firstOrNull?.name;
roomName = resolved ?? roomName;
}
final teacher = lesson.te.firstOrNull;
final teacherName = teacher?.id == 0 ? null : teacher?.name;
final originalTeacher = teacher?.orgname;
return WidgetLesson(
start: start,
end: end,
subjectShort: subjectShort,
subjectLong: subjectLong,
room: roomName,
teacher: teacherName,
originalTeacher: originalTeacher,
status: status,
);
}
static WidgetLessonStatus _mapStatus(LessonStatus status) {
switch (status) {
case LessonStatus.cancelled:
return WidgetLessonStatus.cancelled;
case LessonStatus.event:
return WidgetLessonStatus.event;
case LessonStatus.irregular:
return WidgetLessonStatus.irregular;
case LessonStatus.teacherChanged:
return WidgetLessonStatus.teacherChanged;
case LessonStatus.past:
return WidgetLessonStatus.past;
case LessonStatus.ongoing:
return WidgetLessonStatus.ongoing;
case LessonStatus.regular:
return WidgetLessonStatus.regular;
}
}
static bool _onSameDay(GetTimetableResponseObject lesson, DateTime day) {
final dt = WebuntisTime.parse(lesson.date, lesson.startTime);
return dt.year == day.year && dt.month == day.month && dt.day == day.day;
}
static GetHolidaysResponseObject? _findHoliday(
DateTime day,
GetHolidaysResponse? holidays,
) {
if (holidays == null) return null;
final asInt = WebuntisTime.formatDate(day);
for (final h in holidays.result) {
if (asInt >= h.startDate && asInt <= h.endDate) return h;
}
return null;
}
static Iterable<WidgetLesson> _expandCustomEvents(
GetCustomTimetableEventResponse? customEvents,
DateTime rangeStart,
DateTime rangeEndExclusive,
) sync* {
if (customEvents == null) return;
final rangeStartUtc = rangeStart.toUtc();
final rangeEndUtc = rangeEndExclusive.toUtc();
for (final event in customEvents.events) {
yield* _expandSingleEvent(event, rangeStartUtc, rangeEndUtc);
}
}
static Iterable<WidgetLesson> _expandSingleEvent(
CustomTimetableEvent event,
DateTime rangeStartUtc,
DateTime rangeEndUtc,
) sync* {
final rule = event.rrule;
final duration = event.endDate.difference(event.startDate);
if (rule.isEmpty) {
final startUtc = event.startDate.toUtc();
if (startUtc.isBefore(rangeStartUtc) ||
!startUtc.isBefore(rangeEndUtc)) {
return;
}
yield* _customEventToWidgetLessons(event, event.startDate, duration);
return;
}
try {
final parsed = RecurrenceRule.fromString(rule);
final anchorUtc = event.startDate.toUtc();
for (final occUtc in parsed.getInstances(start: anchorUtc)) {
if (!occUtc.isBefore(rangeEndUtc)) break;
if (occUtc.isBefore(rangeStartUtc)) continue;
final occLocal = occUtc.toLocal();
final occStart = DateTime(
occLocal.year,
occLocal.month,
occLocal.day,
event.startDate.hour,
event.startDate.minute,
);
yield* _customEventToWidgetLessons(event, occStart, duration);
}
} on Exception catch (e) {
log('Widget mapper: invalid rrule "$rule" on event ${event.id}: $e');
}
}
/// Splits multi-day events into one block per local calendar day, so each
/// affected day on the week-widget shows the event. All-day events
/// (start = end = midnight) collapse to a single 00:0023:59 block.
static Iterable<WidgetLesson> _customEventToWidgetLessons(
CustomTimetableEvent event,
DateTime occurrenceStart,
Duration duration,
) sync* {
final title = event.title.trim();
WidgetLesson buildBlock(DateTime start, DateTime end) => WidgetLesson(
start: start,
end: end,
subjectShort: title.isEmpty ? 'Termin' : title,
subjectLong: title.isEmpty ? null : title,
status: WidgetLessonStatus.event,
customColor: event.color,
);
final isAllDay = duration == Duration.zero && _isMidnight(event.startDate);
if (isAllDay) {
yield buildBlock(
occurrenceStart,
DateTime(
occurrenceStart.year,
occurrenceStart.month,
occurrenceStart.day,
23,
59,
),
);
return;
}
final actualEnd = occurrenceStart.add(duration);
var segmentStart = occurrenceStart;
while (segmentStart.isBefore(actualEnd)) {
final nextMidnight = DateTime(
segmentStart.year,
segmentStart.month,
segmentStart.day,
).add(const Duration(days: 1));
final segmentEnd = actualEnd.isBefore(nextMidnight)
? actualEnd
: nextMidnight.subtract(const Duration(minutes: 1));
yield buildBlock(segmentStart, segmentEnd);
segmentStart = nextMidnight;
}
}
static bool _isMidnight(DateTime d) =>
d.hour == 0 && d.minute == 0 && d.second == 0;
}
+24
View File
@@ -0,0 +1,24 @@
import 'dart:developer';
import 'package:flutter/services.dart';
/// Android-only bridge: MainActivity stashes `widget_open_timetable=true`
/// from the launch Intent extra when a widget is tapped, Dart polls once
/// per app-resume to consume and route. iOS widgets simply launch the app
/// without a navigation hint (no widgetURL set) so this returns `false`
/// there via MissingPluginException.
class WidgetNavigation {
static const MethodChannel _channel = MethodChannel('eu.mhsl.marianum.widget');
static Future<bool> consumePendingTimetableTap() async {
try {
final raw = await _channel.invokeMethod<bool>('consumePendingNavigation');
return raw ?? false;
} on MissingPluginException {
return false;
} on PlatformException catch (e) {
log('WidgetNavigation channel error: $e');
return false;
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../state/app/modules/timetable/bloc/timetable_state.dart';
import '../storage/settings.dart';
import 'widget_data_mapper.dart';
import 'widget_sync.dart';
/// Pushes timetable state to the native widget whenever the foreground bloc
/// has fresh data, so the widget doesn't have to wait for the next periodic
/// background fetch.
class WidgetPublisher {
/// Debug-only "now" offset. Gated by [kDebugMode] so a stray non-zero
/// value cannot ship in release.
static const Duration debugTimeShift = Duration.zero;
static DateTime widgetNow() =>
kDebugMode ? DateTime.now().add(debugTimeShift) : DateTime.now();
static Future<void> publishFromBlocState(
TimetableState state, {
Settings? settings,
}) async {
try {
final connectDouble =
settings?.timetableSettings.connectDoubleLessons ?? true;
// Mirror into widget storage so the background isolate sees the same
// value the user just toggled.
await WidgetSync.setConnectDoubleLessons(connectDouble);
await WidgetSync.setThemeMode(_themeName(settings?.appTheme));
final lessons = state.getAllKnownLessons();
final now = widgetNow();
final dayData = WidgetDataMapper.buildDayData(
now: now,
lessons: lessons,
subjects: state.subjects,
rooms: state.rooms,
holidays: state.schoolHolidays,
timegrid: state.timegrid,
customEvents: state.customEvents,
connectDoubleLessons: connectDouble,
);
final weekData = WidgetDataMapper.buildWeekData(
now: now,
lessons: lessons,
subjects: state.subjects,
rooms: state.rooms,
holidays: state.schoolHolidays,
timegrid: state.timegrid,
customEvents: state.customEvents,
connectDoubleLessons: connectDouble,
);
await WidgetSync.writeDayData(dayData);
await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true);
await WidgetSync.triggerUpdate();
} on Object catch (e, s) {
// Catch Object: non-Exception Errors (RangeError, StateError) from the
// bloc layer must not escape into the stream listener.
log('WidgetPublisher.publishFromBlocState failed: $e', stackTrace: s);
}
}
static String _themeName(ThemeMode? mode) {
switch (mode) {
case ThemeMode.light:
return 'light';
case ThemeMode.dark:
return 'dark';
case ThemeMode.system:
case null:
return 'system';
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:home_widget/home_widget.dart';
import 'widget_data.dart';
/// Bridge to the native widget host. All keys/names live here so the Kotlin
/// and Swift sides stay in sync.
class WidgetSync {
static const String iosAppGroupId =
'group.eu.mhsl.marianum.mobile.client.widget';
static const String iosWidgetKind = 'TimetableWidget';
static const String androidDayProvider = 'TimetableDayWidget';
static const String androidWeekProvider = 'TimetableWeekWidget';
// `_v1` suffix lets a future schema change invalidate stale snapshots
// by bumping the key instead of risking a parse crash.
static const String dayDataKey = 'widget_data_day_v1';
static const String weekDataKey = 'widget_data_week_v1';
static const String fetchedAtKey = 'widget_data_fetched_at_v1';
static const String loggedInKey = 'widget_data_logged_in_v1';
// Mirrored into widget storage so the background isolate can read it
// without reopening HydratedBloc storage.
static const String connectDoubleLessonsKey =
'widget_setting_connect_double_lessons_v1';
static const String themeModeKey = 'widget_setting_theme_mode_v1';
static bool _initialised = false;
static Future<void> ensureInitialized() async {
if (_initialised) return;
await HomeWidget.setAppGroupId(iosAppGroupId);
_initialised = true;
}
static Future<void> writeDayData(WidgetTimetableData data) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<String>(dayDataKey, jsonEncode(data.toJson()));
await HomeWidget.saveWidgetData<String>(
fetchedAtKey,
data.fetchedAt.toIso8601String(),
);
}
static Future<void> writeWeekData(WidgetTimetableData data) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<String>(
weekDataKey,
jsonEncode(data.toJson()),
);
await HomeWidget.saveWidgetData<String>(
fetchedAtKey,
data.fetchedAt.toIso8601String(),
);
}
static Future<void> setLoggedIn(bool loggedIn) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<bool>(loggedInKey, loggedIn);
}
static Future<void> setConnectDoubleLessons(bool value) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<bool>(connectDoubleLessonsKey, value);
}
/// Default `true` matches `default_settings.dart` — fresh install behaves
/// like the in-app calendar.
static Future<bool> getConnectDoubleLessons() async {
await ensureInitialized();
final value = await HomeWidget.getWidgetData<bool>(
connectDoubleLessonsKey,
defaultValue: true,
);
return value ?? true;
}
static Future<void> setThemeMode(String mode) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<String>(themeModeKey, mode);
}
static Future<void> clear() async {
await ensureInitialized();
await HomeWidget.saveWidgetData<String>(dayDataKey, null);
await HomeWidget.saveWidgetData<String>(weekDataKey, null);
await HomeWidget.saveWidgetData<String>(fetchedAtKey, null);
await HomeWidget.saveWidgetData<bool>(loggedInKey, false);
}
static Future<void> triggerUpdate() async {
await ensureInitialized();
try {
await HomeWidget.updateWidget(
androidName: androidDayProvider,
iOSName: iosWidgetKind,
);
await HomeWidget.updateWidget(
androidName: androidWeekProvider,
iOSName: iosWidgetKind,
);
} on Exception catch (e) {
log('WidgetSync.triggerUpdate failed: $e');
}
}
}