diff --git a/android/app/build.gradle b/android/app/build.gradle
index b62e5c5..0649ca9 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -57,6 +57,9 @@ android {
signingConfig signingConfigs.debug
}
}
+ buildFeatures {
+ viewBinding true
+ }
}
flutter {
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3c24865..701cabd 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,45 +1,73 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:icon="@mipmap/ic_launcher"
+ android:label="Marianum Fulda">
+
+
+
+
+
+
+
+
-
+ to determine the Window background behind the Flutter UI.
+ -->
+ android:name="io.flutter.embedding.android.NormalTheme"
+ android:resource="@style/NormalTheme" />
+
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
+
\ No newline at end of file
diff --git a/android/app/src/main/java/eu/mhsl/marianum/mobile/client/TimetableWidget.kt b/android/app/src/main/java/eu/mhsl/marianum/mobile/client/TimetableWidget.kt
new file mode 100644
index 0000000..1c9e3ac
--- /dev/null
+++ b/android/app/src/main/java/eu/mhsl/marianum/mobile/client/TimetableWidget.kt
@@ -0,0 +1,39 @@
+package eu.mhsl.marianum.mobile.client
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.widget.RemoteViews
+
+import es.antonborri.home_widget.HomeWidgetPlugin
+import android.util.Base64
+
+/**
+ * Implementation of App Widget functionality.
+ */
+class TimetableWidget : AppWidgetProvider() {
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ for (appWidgetId in appWidgetIds) {
+ val widgetData = HomeWidgetPlugin.getData(context)
+ val views = RemoteViews(context.packageName, R.layout.timetable_widget).apply {
+ val imageBase64 = widgetData.getString("screen", null) ?: return@apply
+ val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT);
+ val imageBitmap: Bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
+ setImageViewBitmap(R.id.widget_image, imageBitmap)
+ }
+
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
+ val pendingIntent = PendingIntent.getActivity(context, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ views.setOnClickPendingIntent(R.id.background, pendingIntent)
+
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable-nodpi/timetable_widget_default.png b/android/app/src/main/res/drawable-nodpi/timetable_widget_default.png
new file mode 100644
index 0000000..4079ef9
Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/timetable_widget_default.png differ
diff --git a/android/app/src/main/res/drawable-nodpi/timetable_widget_preview.png b/android/app/src/main/res/drawable-nodpi/timetable_widget_preview.png
new file mode 100644
index 0000000..3f3ca0d
Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/timetable_widget_preview.png differ
diff --git a/android/app/src/main/res/drawable-v21/app_widget_background.xml b/android/app/src/main/res/drawable-v21/app_widget_background.xml
new file mode 100644
index 0000000..785445c
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/app_widget_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml b/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml
new file mode 100644
index 0000000..007e287
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/layout/timetable_widget.xml b/android/app/src/main/res/layout/timetable_widget.xml
new file mode 100644
index 0000000..56cb7fd
--- /dev/null
+++ b/android/app/src/main/res/layout/timetable_widget.xml
@@ -0,0 +1,26 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-night-v31/themes.xml b/android/app/src/main/res/values-night-v31/themes.xml
new file mode 100644
index 0000000..f253c9d
--- /dev/null
+++ b/android/app/src/main/res/values-night-v31/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-v21/styles.xml b/android/app/src/main/res/values-v21/styles.xml
new file mode 100644
index 0000000..0b35f7d
--- /dev/null
+++ b/android/app/src/main/res/values-v21/styles.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
index ae64507..c5f4f25 100644
--- a/android/app/src/main/res/values-v31/styles.xml
+++ b/android/app/src/main/res/values-v31/styles.xml
@@ -18,4 +18,18 @@
+
+
+
+
diff --git a/android/app/src/main/res/values-v31/themes.xml b/android/app/src/main/res/values-v31/themes.xml
new file mode 100644
index 0000000..badd306
--- /dev/null
+++ b/android/app/src/main/res/values-v31/themes.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..7781ac8
--- /dev/null
+++ b/android/app/src/main/res/values/attrs.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..b2bffa8
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,6 @@
+
+ #FFE1F5FE
+ #FF81D4FA
+ #FF039BE5
+ #FF01579B
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000..4db8c59
--- /dev/null
+++ b/android/app/src/main/res/values/dimens.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ 0dp
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..3bf528f
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Marianum Vertretungsplan
+ Hinzufügen
+ Übersicht zum Vertretungsplan
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index 0d1fa8f..4a56836 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -19,4 +19,14 @@
+
+
+
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..dcd8899
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/timetable_widget_info.xml b/android/app/src/main/res/xml/timetable_widget_info.xml
new file mode 100644
index 0000000..a05e013
--- /dev/null
+++ b/android/app/src/main/res/xml/timetable_widget_info.xml
@@ -0,0 +1,16 @@
+
+
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
index bc157bd..2081203 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -2,6 +2,21 @@ allprojects {
repositories {
google()
mavenCentral()
+
+ // [required] background_fetch
+ maven {
+ url "${project(':background_fetch').projectDir}/libs"
+ }
+ }
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10'
}
}
diff --git a/lib/app.dart b/lib/app.dart
index 0b4a03b..141531f 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -24,6 +24,7 @@ import 'storage/base/settingsProvider.dart';
import 'view/pages/overhang.dart';
class App extends StatefulWidget {
+ static GlobalKey appContext = GlobalKey();
const App({super.key});
@override
@@ -31,7 +32,6 @@ class App extends StatefulWidget {
}
class _AppState extends State with WidgetsBindingObserver {
-
late Timer refetchChats;
late Timer updateTimings;
diff --git a/lib/background_tasks/scheduledTask.dart b/lib/background_tasks/scheduledTask.dart
new file mode 100644
index 0000000..65b1dca
--- /dev/null
+++ b/lib/background_tasks/scheduledTask.dart
@@ -0,0 +1,64 @@
+import 'dart:developer';
+
+import 'package:background_fetch/background_fetch.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../app.dart';
+import '../homescreen_widgets/timetable/timetableHomeWidget.dart';
+
+class ScheduledTask {
+ static final String fetchApiLastRunTimestampKey = 'fetchApiLastRunTimestamp';
+
+ static Future configure() async {
+ var status = await BackgroundFetch.configure(BackgroundFetchConfig(
+ minimumFetchInterval: 15,
+ stopOnTerminate: false,
+ enableHeadless: true,
+ requiresBatteryNotLow: false,
+ requiresCharging: false,
+ requiresStorageNotLow: false,
+ requiresDeviceIdle: false,
+ requiredNetworkType: NetworkType.ANY,
+ startOnBoot: true,
+ ), (String taskId) async {
+ log('Background fetch started with id $taskId');
+ await ScheduledTask.backgroundFetch();
+ BackgroundFetch.finish(taskId);
+ }, (String taskId) async {
+ log('Background fetch stopped because of an timeout with id $taskId');
+ BackgroundFetch.finish(taskId);
+ });
+
+ log('Background Fetch-API status: $status');
+ }
+
+ // called periodically, iOS and Android
+ static Future backgroundFetch() async {
+ var sp = await SharedPreferences.getInstance();
+ var history = sp.getStringList(fetchApiLastRunTimestampKey) ?? List.empty(growable: true);
+ history.add(DateTime.now().toIso8601String());
+ try {
+ TimetableHomeWidget.update(App.appContext.currentContext!);
+ } on Exception catch(e) {
+ history.add('Got Error:');
+ history.add(e.toString());
+ history.add('--- EXCEPTION END ---');
+ }
+ sp.setStringList(fetchApiLastRunTimestampKey, history.take(100).toList());
+ }
+
+ // only Android, starts when app is terminated
+ @pragma('vm:entry-point')
+ static Future headless(HeadlessTask task) async {
+ var taskId = task.taskId;
+ var isTimeout = task.timeout;
+ if (isTimeout) {
+ log('Background fetch headless task timed-out: $taskId');
+ BackgroundFetch.finish(taskId);
+ return;
+ }
+ log('Background fetch headless event received.');
+ await backgroundFetch();
+ BackgroundFetch.finish(taskId);
+ }
+}
diff --git a/lib/homescreen_widgets/timetable/timetableHomeWidget.dart b/lib/homescreen_widgets/timetable/timetableHomeWidget.dart
new file mode 100644
index 0000000..26c7f4d
--- /dev/null
+++ b/lib/homescreen_widgets/timetable/timetableHomeWidget.dart
@@ -0,0 +1,88 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:developer';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_localizations/flutter_localizations.dart';
+import 'package:home_widget/home_widget.dart';
+import 'package:screenshot/screenshot.dart';
+import 'package:syncfusion_flutter_calendar/calendar.dart';
+
+import '../../model/accountData.dart';
+import '../../model/timetable/timetableProps.dart';
+import '../../storage/base/settingsProvider.dart';
+import '../../theming/darkAppTheme.dart';
+import '../../theming/lightAppTheme.dart';
+import '../../view/pages/timetable/calendar.dart';
+
+class TimetableHomeWidget {
+ static Future update(BuildContext context) async {
+ await AccountData().waitForPopulation();
+ var data = TimetableProps();
+ var settings = SettingsProvider();
+ settings.waitForPopulation();
+ var completer = Completer();
+
+ data.addListener(() async {
+ if(completer.isCompleted) return;
+ if(data.primaryLoading()) return;
+ await _generate(data, settings);
+ if(completer.isCompleted) return;
+ completer.complete();
+ });
+
+ data.run();
+ await completer.future;
+ }
+
+ static Future _generate(TimetableProps data, SettingsProvider settings) async {
+ log('Generating widget screen...');
+ log('data: ${data.getTimetableResponse.toJson().toString().substring(0, 400)}...');
+ var screenshotController = ScreenshotController();
+ var calendarController = CalendarController();
+ calendarController.displayDate = DateTime.now().copyWith(hour: 07, minute: 00);
+
+ var imageData = await screenshotController.captureFromWidget(
+ SizedBox(
+ height: 700,
+ width: 300,
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: MediaQuery(
+ data: MediaQueryData(),
+ child: MaterialApp(
+ localizationsDelegates: const [
+ ...GlobalMaterialLocalizations.delegates,
+ GlobalWidgetsLocalizations.delegate,
+ ],
+ supportedLocales: const [
+ Locale('de'),
+ Locale('en'),
+ ],
+ locale: const Locale('de'),
+ darkTheme: DarkAppTheme.theme,
+ theme: LightAppTheme.theme,
+ themeMode: settings.val().appTheme,
+ home: ClipRRect(
+ borderRadius: BorderRadius.circular(20),
+ child: Scaffold(
+ body: Calendar(
+ controller: calendarController,
+ timetableProps: data,
+ settings: settings,
+ isHomeWidget: true,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ delay: Duration(seconds: 5),
+ );
+
+ HomeWidget.saveWidgetData('screen', base64.encode(imageData));
+ HomeWidget.updateWidget(name: 'TimetableWidget');
+ log('Widget screen successfully updated! (${imageData.length})');
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index 6f20ebb..f057fef 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer';
import 'dart:io';
+import 'package:background_fetch/background_fetch.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
@@ -17,6 +18,7 @@ import 'package:provider/provider.dart';
import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart';
import 'app.dart';
+import 'background_tasks/scheduledTask.dart';
import 'firebase_options.dart';
import 'model/accountData.dart';
import 'model/accountModel.dart';
@@ -84,6 +86,8 @@ Future main() async {
child: const Main(),
)
);
+
+ BackgroundFetch.registerHeadlessTask(ScheduledTask.headless);
}
class Main extends StatefulWidget {
@@ -111,6 +115,7 @@ class _MainState extends State {
Provider.of(context, listen: false).run();
});
+ ScheduledTask.configure();
super.initState();
}
@@ -125,7 +130,6 @@ class _MainState extends State {
checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers,
checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages,
- debugShowCheckedModeBanner: false,
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
@@ -147,7 +151,7 @@ class _MainState extends State {
child: Consumer(
builder: (context, accountModel, child) {
switch(accountModel.state) {
- case AccountModelState.loggedIn: return const App();
+ case AccountModelState.loggedIn: return App(key: App.appContext);
case AccountModelState.loggedOut: return const Login();
case AccountModelState.undefined: return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen');
}
diff --git a/lib/storage/base/settingsProvider.dart b/lib/storage/base/settingsProvider.dart
index b1fb43f..b0ea639 100644
--- a/lib/storage/base/settingsProvider.dart
+++ b/lib/storage/base/settingsProvider.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:easy_debounce/easy_debounce.dart';
@@ -13,6 +14,8 @@ class SettingsProvider extends ChangeNotifier {
late SharedPreferences _storage;
late Settings _settings = DefaultSettings.get();
+ final Completer _populated = Completer();
+
Settings val({bool write = false}) {
if(write) {
notifyListeners();
@@ -56,6 +59,7 @@ class SettingsProvider extends ChangeNotifier {
}
notifyListeners();
+ _populated.complete();
}
Future update() async {
@@ -77,4 +81,8 @@ class SettingsProvider extends ChangeNotifier {
return mergedMap;
}
+
+ Future waitForPopulation() async {
+ await _populated.future;
+ }
}
diff --git a/lib/view/pages/timetable/calendar.dart b/lib/view/pages/timetable/calendar.dart
new file mode 100644
index 0000000..0f99fdd
--- /dev/null
+++ b/lib/view/pages/timetable/calendar.dart
@@ -0,0 +1,287 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import 'package:syncfusion_flutter_calendar/calendar.dart';
+
+import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
+import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
+import '../../../extensions/dateTime.dart';
+import '../../../model/timetable/timetableProps.dart';
+import '../../../storage/base/settingsProvider.dart';
+import 'appointmenetComponent.dart';
+import 'appointmentDetails.dart';
+import 'arbitraryAppointment.dart';
+import 'customTimetableColors.dart';
+import 'timeRegionComponent.dart';
+import 'timetableEvents.dart';
+import 'timetableNameMode.dart';
+
+class Calendar extends StatefulWidget {
+ final CalendarController controller;
+ final TimetableProps timetableProps;
+ final SettingsProvider settings;
+ final bool isHomeWidget;
+ const Calendar({super.key, required this.controller, required this.timetableProps, required this.settings, this.isHomeWidget = false});
+
+ @override
+ State createState() => _CalendarState();
+}
+
+class _CalendarState extends State {
+ @override
+ Widget build(BuildContext context) {
+ var holidays = widget.timetableProps.getHolidaysResponse;
+ return SfCalendar(
+ timeZone: 'W. Europe Standard Time',
+ view: widget.isHomeWidget ? CalendarView.day : CalendarView.workWeek,
+ dataSource: _buildTableEvents(widget.timetableProps),
+
+ maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
+ minDate: DateTime.now().subtract(const Duration (days: 14)).nextWeekday(DateTime.sunday),
+
+ controller: widget.controller,
+
+ onViewChanged: (ViewChangedDetails details) {
+ if(widget.isHomeWidget) return;
+ WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+ Provider.of(context, listen: false).updateWeek(details.visibleDates.first, details.visibleDates.last);
+ });
+ },
+
+ onTap: (calendarTapDetails) {
+ if(calendarTapDetails.appointments == null) return;
+ Appointment tapped = calendarTapDetails.appointments!.first;
+ AppointmentDetails.show(context, widget.timetableProps, tapped);
+ },
+
+ firstDayOfWeek: DateTime.monday,
+ specialRegions: _buildSpecialTimeRegions(holidays),
+ timeSlotViewSettings: TimeSlotViewSettings(
+ startHour: widget.isHomeWidget ? 08 : 07.5,
+ endHour: widget.isHomeWidget ? 16 : 16.5,
+ timeInterval: Duration(minutes: 30),
+ timeFormat: 'HH:mm',
+ dayFormat: 'EE',
+ timeIntervalHeight: 40,
+ ),
+
+ timeRegionBuilder: (BuildContext context, TimeRegionDetails timeRegionDetails) => TimeRegionComponent(details: timeRegionDetails),
+ appointmentBuilder: (BuildContext context, CalendarAppointmentDetails details) => AppointmentComponent(
+ details: details,
+ crossedOut: _isCrossedOut(details)
+ ),
+
+ headerHeight: 0,
+ selectionDecoration: const BoxDecoration(),
+
+ allowAppointmentResize: false,
+ allowDragAndDrop: false,
+ allowViewNavigation: false,
+ );
+ }
+
+ List _buildSpecialTimeRegions(GetHolidaysResponse holidays) {
+ var lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday);
+ var firstBreak = lastMonday.copyWith(hour: 10, minute: 15);
+ var secondBreak = lastMonday.copyWith(hour: 13, minute: 50);
+
+ var holidayList = holidays.result.map((holiday) {
+ var startDay = _parseWebuntisTimestamp(holiday.startDate, 0);
+ var dayCount = _parseWebuntisTimestamp(holiday.endDate, 0)
+ .difference(startDay)
+ .inDays;
+ var days = List.generate(dayCount, (index) => startDay.add(Duration(days: index)));
+
+ return days.map((holidayDay) => TimeRegion(
+ startTime: holidayDay.copyWith(hour: 07, minute: 55),
+ endTime: holidayDay.copyWith(hour: 16, minute: 30),
+ text: 'holiday:${holiday.name}',
+ color: Theme
+ .of(context)
+ .disabledColor
+ .withAlpha(50),
+ iconData: Icons.holiday_village_outlined
+ ));
+ }).expand((e) => e);
+
+ bool isInHoliday(DateTime time) => holidayList.any((element) => element.startTime.isSameDay(time));
+
+ return [
+ ...holidayList,
+
+ if(!isInHoliday(firstBreak))
+ TimeRegion(
+ startTime: firstBreak,
+ endTime: firstBreak.add(const Duration(minutes: 20)),
+ recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
+ text: 'centerIcon',
+ color: Theme.of(context).primaryColor.withAlpha(50),
+ iconData: Icons.restaurant
+ ),
+
+ if(!isInHoliday(secondBreak))
+ TimeRegion(
+ startTime: secondBreak,
+ endTime: secondBreak.add(const Duration(minutes: 15)),
+ recurrenceRule: 'FREQ=DAILY;INTERVAL=1',
+ text: 'centerIcon',
+ color: Theme.of(context).primaryColor.withAlpha(50),
+ iconData: Icons.restaurant
+ ),
+ ];
+ }
+
+ List _removeDuplicates(TimetableProps data, Duration maxTimeBetweenDouble) {
+
+ var timetableList = data.getTimetableResponse.result.toList();
+
+ if(timetableList.isEmpty) return timetableList;
+
+ timetableList.sort((a, b) => _parseWebuntisTimestamp(a.date, a.startTime).compareTo(_parseWebuntisTimestamp(b.date, b.startTime)));
+
+ var previousElement = timetableList.first;
+ for(var i = 1; i < timetableList.length; i++) {
+ var currentElement = timetableList.elementAt(i);
+
+ bool isSameLesson() {
+ var currentSubjectId = currentElement.su.firstOrNull?.id;
+ var previousSubjectId = previousElement.su.firstOrNull?.id;
+
+ if(currentSubjectId == null || previousSubjectId == null || currentSubjectId != previousSubjectId) return false;
+
+ var currentRoomId = currentElement.ro.firstOrNull?.id;
+ var previousRoomId = previousElement.ro.firstOrNull?.id;
+
+ if(currentRoomId != previousRoomId) return false;
+
+ var currentTeacherId = currentElement.te.firstOrNull?.id;
+ var previousTeacherId = previousElement.te.firstOrNull?.id;
+
+ if(currentTeacherId != previousTeacherId) return false;
+
+ var currentStatusCode = currentElement.code;
+ var previousStatusCode = previousElement.code;
+
+ if(currentStatusCode != previousStatusCode) return false;
+
+ return true;
+ }
+
+ bool isNotSeparated() => _parseWebuntisTimestamp(previousElement.date, previousElement.endTime).add(maxTimeBetweenDouble)
+ .isSameOrAfter(_parseWebuntisTimestamp(currentElement.date, currentElement.startTime));
+
+ if(isSameLesson() && isNotSeparated()) {
+ previousElement.endTime = currentElement.endTime;
+ timetableList.remove(currentElement);
+ i--;
+ } else {
+ previousElement = currentElement;
+ }
+ }
+
+ return timetableList;
+ }
+
+ TimetableEvents _buildTableEvents(TimetableProps data) {
+
+ var timetableList = data.getTimetableResponse.result.toList();
+
+ if(widget.settings.val().timetableSettings.connectDoubleLessons) {
+ timetableList = _removeDuplicates(data, const Duration(minutes: 5));
+ }
+
+ var appointments = timetableList.map((element) {
+
+ var rooms = data.getRoomsResponse;
+ var subjects = data.getSubjectsResponse;
+
+ try {
+ var startTime = _parseWebuntisTimestamp(element.date, element.startTime);
+ var endTime = _parseWebuntisTimestamp(element.date, element.endTime);
+
+ var subject = subjects.result.firstWhere((subject) => subject.id == element.su[0].id);
+ var subjectName = {
+ TimetableNameMode.name: subject.name,
+ TimetableNameMode.longName: subject.longName,
+ TimetableNameMode.alternateName: subject.alternateName,
+ }[widget.settings.val().timetableSettings.timetableNameMode];
+
+
+ return Appointment(
+ id: ArbitraryAppointment(webuntis: element),
+ startTime: startTime,
+ endTime: endTime,
+ subject: subjectName!,
+ location: ''
+ '${rooms.result.firstWhere((room) => room.id == element.ro[0].id).name}'
+ '\n'
+ '${element.te.first.longname}',
+ notes: element.activityType,
+ color: _getEventColor(element, startTime, endTime),
+ );
+ } catch(e) {
+ var endTime = _parseWebuntisTimestamp(element.date, element.endTime);
+ return Appointment(
+ id: ArbitraryAppointment(webuntis: element),
+ startTime: _parseWebuntisTimestamp(element.date, element.startTime),
+ endTime: endTime,
+ subject: 'Änderung',
+ notes: element.info,
+ location: 'Unbekannt',
+ color: endTime.isBefore(DateTime.now()) ? Theme.of(context).primaryColor.withAlpha(100) : Theme.of(context).primaryColor,
+ startTimeZone: '',
+ endTimeZone: '',
+ );
+ }
+ }).toList();
+
+ appointments.addAll(data.getCustomTimetableEventResponse.events.map((customEvent) => Appointment(
+ id: ArbitraryAppointment(custom: customEvent),
+ startTime: customEvent.startDate,
+ endTime: customEvent.endDate,
+ location: customEvent.description,
+ subject: customEvent.title,
+ recurrenceRule: customEvent.rrule,
+ color: TimetableColors.getColorFromString(customEvent.color ?? TimetableColors.defaultColor.name),
+ startTimeZone: '',
+ endTimeZone: '',
+ )));
+
+ return TimetableEvents(appointments);
+ }
+
+ DateTime _parseWebuntisTimestamp(int date, int time) {
+ var timeString = time.toString().padLeft(4, '0');
+ return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}');
+ }
+
+ Color _getEventColor(GetTimetableResponseObject webuntisElement, DateTime startTime, DateTime endTime) {
+ // Make element darker, when it already took place
+ var alpha = endTime.isBefore(DateTime.now()) ? 100 : 255;
+
+ // Cancelled
+ if(webuntisElement.code == 'cancelled') return const Color(0xff000000).withAlpha(alpha);
+
+ // Any changes or no teacher at this element
+ if(webuntisElement.code == 'irregular' || webuntisElement.te.first.id == 0) return const Color(0xff8F19B3).withAlpha(alpha);
+
+ // Teacher has changed
+ if(webuntisElement.te.any((element) => element.orgname != null)) return const Color(0xFF29639B).withAlpha(alpha);
+
+ // Event was in the past
+ if(endTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withAlpha(alpha);
+
+ // Event takes currently place
+ if(endTime.isAfter(DateTime.now()) && startTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withRed(200);
+
+ // Fallback
+ return Theme.of(context).primaryColor.withAlpha(alpha);
+ }
+
+ bool _isCrossedOut(CalendarAppointmentDetails calendarEntry) {
+ var appointment = calendarEntry.appointments.first.id as ArbitraryAppointment;
+ if(appointment.hasWebuntis()) {
+ return appointment.webuntis!.code == 'cancelled';
+ }
+ return false;
+ }
+}
diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart
index 715ede1..c55300e 100644
--- a/lib/view/pages/timetable/timetable.dart
+++ b/lib/view/pages/timetable/timetable.dart
@@ -2,22 +2,21 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
-import '../../../extensions/dateTime.dart';
import 'package:provider/provider.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
+import '../../../extensions/dateTime.dart';
+import '../../../homescreen_widgets/timetable/timetableHomeWidget.dart';
import '../../../model/timetable/timetableProps.dart';
import '../../../storage/base/settingsProvider.dart';
import '../../../widget/loadingSpinner.dart';
import '../../../widget/placeholderView.dart';
-import 'appointmenetComponent.dart';
-import 'appointmentDetails.dart';
import 'arbitraryAppointment.dart';
+import 'calendar.dart';
import 'customTimetableColors.dart';
import 'customTimetableEventEditDialog.dart';
-import 'timeRegionComponent.dart';
import 'timetableEvents.dart';
import 'timetableNameMode.dart';
import 'viewCustomTimetableEvents.dart';
@@ -34,7 +33,7 @@ enum CalendarActions { addEvent, viewEvents }
class _TimetableState extends State {
CalendarController controller = CalendarController();
late Timer updateTimings;
- late final SettingsProvider settings;
+ late SettingsProvider settings;
@override
void initState() {
@@ -56,6 +55,7 @@ class _TimetableState extends State {
appBar: AppBar(
title: const Text('Stunden & Vertretungsplan'),
actions: [
+ IconButton(onPressed: () => TimetableHomeWidget.update(context), icon: Icon(Icons.screen_share_outlined)),
IconButton(
icon: const Icon(Icons.home_outlined),
onPressed: () {
@@ -119,62 +119,19 @@ class _TimetableState extends State {
if(value.primaryLoading()) return const LoadingSpinner();
- var holidays = value.getHolidaysResponse;
-
return RefreshIndicator(
- child: SfCalendar(
- timeZone: 'W. Europe Standard Time',
- view: CalendarView.workWeek,
- dataSource: _buildTableEvents(value),
-
- maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday),
- minDate: DateTime.now().subtract(const Duration (days: 14)).nextWeekday(DateTime.sunday),
-
- controller: controller,
-
- onViewChanged: (ViewChangedDetails details) {
- WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
- Provider.of(context, listen: false).updateWeek(details.visibleDates.first, details.visibleDates.last);
- });
- },
-
- onTap: (calendarTapDetails) {
- if(calendarTapDetails.appointments == null) return;
- Appointment tapped = calendarTapDetails.appointments!.first;
- AppointmentDetails.show(context, value, tapped);
- },
-
- firstDayOfWeek: DateTime.monday,
- specialRegions: _buildSpecialTimeRegions(holidays),
- timeSlotViewSettings: const TimeSlotViewSettings(
- startHour: 07.5,
- endHour: 16.5,
- timeInterval: Duration(minutes: 30),
- timeFormat: 'HH:mm',
- dayFormat: 'EE',
- timeIntervalHeight: 40,
- ),
-
- timeRegionBuilder: (BuildContext context, TimeRegionDetails timeRegionDetails) => TimeRegionComponent(details: timeRegionDetails),
- appointmentBuilder: (BuildContext context, CalendarAppointmentDetails details) => AppointmentComponent(
- details: details,
- crossedOut: _isCrossedOut(details)
- ),
-
- headerHeight: 0,
- selectionDecoration: const BoxDecoration(),
-
- allowAppointmentResize: false,
- allowDragAndDrop: false,
- allowViewNavigation: false,
- ),
- onRefresh: () async {
- Provider.of(context, listen: false).run(renew: true);
- return Future.delayed(const Duration(seconds: 3));
- }
- );
+ child: Calendar(
+ controller: controller,
+ timetableProps: value,
+ settings: settings,
+ ),
+ onRefresh: () async {
+ Provider.of(context, listen: false).run(renew: true);
+ return Future.delayed(const Duration(seconds: 3));
+ }
+ );
},
- ),
+ )
);
@override
diff --git a/lib/view/settings/devToolsSettings.dart b/lib/view/settings/devToolsSettings.dart
index 4dc8877..2dfc2c8 100644
--- a/lib/view/settings/devToolsSettings.dart
+++ b/lib/view/settings/devToolsSettings.dart
@@ -1,14 +1,18 @@
+import 'package:background_fetch/background_fetch.dart';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import '../../background_tasks/scheduledTask.dart';
import '../../storage/base/settingsProvider.dart';
import '../../widget/centeredLeading.dart';
import '../../widget/confirmDialog.dart';
import '../../widget/debug/cacheView.dart';
import '../../widget/debug/jsonViewer.dart';
+import '../../widget/infoDialog.dart';
class DevToolsSettings extends StatefulWidget {
final SettingsProvider settings;
@@ -22,6 +26,84 @@ class _DevToolsSettingsState extends State {
@override
Widget build(BuildContext context) => Column(
children: [
+ ListTile(
+ leading: const CenteredLeading(Icon(Icons.task_outlined)),
+ title: const Text('Background app fetch task'),
+ trailing: const Icon(Icons.arrow_right),
+ onTap: () {
+ showDialog(context: context, builder: (context) => AlertDialog(
+ title: Text('Background fetch task'),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.start,
+ children: [
+ FutureBuilder(future: BackgroundFetch.status, builder: (context, snapshot) {
+ if(snapshot.hasData) {
+ var fetchStatus = switch(snapshot.data) {
+ BackgroundFetch.STATUS_AVAILABLE => 'STATUS_AVAILABLE, Background updates are available for the app.',
+ BackgroundFetch.STATUS_DENIED => 'STATUS_DENIED, The user explicitly disabled background behavior for this app or for the whole system.',
+ BackgroundFetch.STATUS_RESTRICTED => 'STATUS_RESTRICTED, Background updates are unavailable and the user cannot enable them again. For example, this status can occur when parental controls are in effect for the current user.',
+ _ => 'UNKNOWN',
+ };
+ return Text('(${snapshot.data}): $fetchStatus');
+ }
+ return LinearProgressIndicator();
+ }),
+ const Divider(),
+ const Text('There is no indicator if the Fetch-API is currently running or not!'),
+ const Divider(),
+ FutureBuilder(
+ future: SharedPreferences.getInstance(),
+ builder: (context, snapshot) {
+ if(!snapshot.hasData) return LinearProgressIndicator();
+ return Text('Last fetch timestamp: ${snapshot.data?.getStringList(ScheduledTask.fetchApiLastRunTimestampKey)?.last ?? 'No entry'}');
+ },
+ )
+ ],
+ ),
+ actions: [
+ FutureBuilder(future: SharedPreferences.getInstance(), builder: (context, snapshot) {
+ if(!snapshot.hasData) return LinearProgressIndicator();
+ return TextButton(
+ onPressed: () {
+ InfoDialog.show(
+ context,
+ (snapshot.data!.getStringList(ScheduledTask.fetchApiLastRunTimestampKey) ?? []).reversed.join('\n')
+ );
+ },
+ child: Text('Fetch history')
+ );
+ }),
+ TextButton(
+ onPressed: () => ConfirmDialog(
+ title: 'Warning',
+ content: 'Background Fetch worker will be started! This basically happens on every app startup.',
+ onConfirm: BackgroundFetch.start
+ ).asDialog(context),
+ child: Text('Fetch-API Start')
+ ),
+ TextButton(
+ onPressed: () => ConfirmDialog(
+ title: 'Warning',
+ content: 'Background Fetch worker will be terminated. This will result in outdated Information when App is not in foreground!',
+ onConfirm: BackgroundFetch.stop
+ ).asDialog(context),
+ child: Text('Fetch-API Stop')
+ ),
+ TextButton(
+ onPressed: () => ConfirmDialog(
+ title: 'Warning',
+ content: 'Background fetch will run now! This happens in the application layer and does not interact with the Fetch-API!',
+ confirmButton: 'Run',
+ onConfirm: ScheduledTask.backgroundFetch
+ ).asDialog(context),
+ child: Text('Run task manually')
+ ),
+ TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Zurück'))
+ ],
+ ));
+ },
+ ),
ListTile(
leading: const CenteredLeading(Icon(Icons.speed_outlined)),
title: const Text('Performance overlays'),
diff --git a/pubspec.yaml b/pubspec.yaml
index 1529aa9..5cd1578 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
-version: 0.1.4+42
+version: 0.1.5+43
environment:
sdk: '>3.0.0'
@@ -102,6 +102,9 @@ dependencies:
uuid: ^4.5.1
open_filex: ^4.7.0
collection: ^1.19.0
+ home_widget: ^0.7.0+1
+ screenshot: ^3.0.0
+ background_fetch: ^1.3.7
dev_dependencies:
flutter_test: