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: