Compare commits

..

3 Commits

34 changed files with 312 additions and 837 deletions

View File

@ -57,9 +57,6 @@ android {
signingConfig signingConfigs.debug
}
}
buildFeatures {
viewBinding true
}
}
flutter {

View File

@ -1,73 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
>
<!--
Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin.
-->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
tools:replace="android:label"
android:label="Marianum Fulda"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:label="Marianum Fulda">
<receiver
android:name=".TimetableWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/timetable_widget_info" />
</receiver>
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!--
Specifies an Android theme to apply to this Activity as soon as
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI.
-->
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!--
Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java
-->
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -1,39 +0,0 @@
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)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Background for widgets to make the rounded corners based on the
appWidgetRadius attribute value
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="?attr/appWidgetRadius" />
<solid android:color="?android:attr/colorBackground" />
</shape>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
Background for views inside widgets to make the rounded corners based on the
appWidgetInnerRadius attribute value
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="?attr/appWidgetInnerRadius" />
<solid android:color="?android:attr/colorAccent" />
</shape>

View File

@ -1,26 +0,0 @@
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/background"
style="@style/Widget.Android.AppWidget.Container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:padding="0dp"
android:theme="@style/Theme.Android.AppWidgetContainer">
<ImageView
android:id="@+id/widget_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentLeft="true"
android:layout_marginLeft="0dp"
android:layout_marginTop="0dp"
android:layout_marginBottom="0dp"
android:layout_weight="1"
android:adjustViewBounds="false"
android:background="@android:color/transparent"
android:scaleType="fitCenter"
android:src="@drawable/timetable_widget_default"
android:visibility="visible"
tools:visibility="visible" />
</RelativeLayout>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Having themes.xml for night-v31 because of the priority order of the resource qualifiers.
-->
<style name="Theme.Android.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
</style>
</resources>

View File

@ -1,14 +0,0 @@
<resources>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item>
</style>
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_inner_view_background</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
</resources>

View File

@ -18,18 +18,4 @@
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_background</item>
<item name="android:clipToOutline">true</item>
</style>
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
<item name="android:padding">?attr/appWidgetPadding</item>
<item name="android:background">@drawable/app_widget_inner_view_background</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
<item name="android:clipToOutline">true</item>
</style>
</resources>

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Having themes.xml for v31 variant because @android:dimen/system_app_widget_background_radius
and @android:dimen/system_app_widget_internal_padding requires API level 31
-->
<style name="Theme.Android.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
<item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
</style>
</resources>

View File

@ -1,7 +0,0 @@
<resources>
<declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetPadding" format="dimension" />
<attr name="appWidgetInnerRadius" format="dimension" />
<attr name="appWidgetRadius" format="dimension" />
</declare-styleable>
</resources>

View File

@ -1,6 +0,0 @@
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
</resources>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--
Refer to App Widget Documentation for margin information
http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
-->
<dimen name="widget_margin">0dp</dimen>
</resources>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="appwidget_text">Marianum Vertretungsplan</string>
<string name="add_widget">Hinzufügen</string>
<string name="app_widget_description">Übersicht zum Vertretungsplan</string>
</resources>

View File

@ -19,14 +19,4 @@
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
<item name="android:id">@android:id/background</item>
<item name="android:background">?android:attr/colorBackground</item>
</style>
<style name="Widget.Android.AppWidget.InnerView" parent="android:Widget">
<item name="android:background">?android:attr/colorBackground</item>
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
</resources>

View File

@ -1,17 +0,0 @@
<resources>
<style name="Theme.Android.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault">
<!-- Radius of the outer bound of widgets to make the rounded corners -->
<item name="appWidgetRadius">16dp</item>
<!--
Radius of the inner view's bound of widgets to make the rounded corners.
It needs to be 8dp or less than the value of appWidgetRadius
-->
<item name="appWidgetInnerRadius">8dp</item>
</style>
<style name="Theme.Android.AppWidgetContainer" parent="Theme.Android.AppWidgetContainerParent">
<!-- Apply padding to avoid the content of the widget colliding with the rounded corners -->
<item name="appWidgetPadding">16dp</item>
</style>
</resources>

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_widget_description"
android:initialKeyguardLayout="@layout/timetable_widget"
android:initialLayout="@layout/timetable_widget"
android:minWidth="220dp"
android:minHeight="294dp"
android:minResizeWidth="110dp"
android:minResizeHeight="147dp"
android:previewImage="@drawable/timetable_widget_preview"
android:previewLayout="@layout/timetable_widget"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="4"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen" />

View File

@ -2,21 +2,6 @@ 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'
}
}

View File

@ -110,6 +110,7 @@ enum GetRoomResponseObjectConversationType {
@JsonValue(3) public,
@JsonValue(4) changelog,
@JsonValue(5) deleted,
@JsonValue(6) noteToSelf,
}
enum GetRoomResponseObjectParticipantNotificationLevel {

View File

@ -102,6 +102,7 @@ const _$GetRoomResponseObjectConversationTypeEnumMap = {
GetRoomResponseObjectConversationType.public: 3,
GetRoomResponseObjectConversationType.changelog: 4,
GetRoomResponseObjectConversationType.deleted: 5,
GetRoomResponseObjectConversationType.noteToSelf: 6,
};
const _$GetRoomResponseObjectParticipantNotificationLevelEnumMap = {

View File

@ -58,11 +58,9 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
assembled?.headers = data.headers;
return assembled;
} catch (e) {
// TODO report error
log('Error assembling Talk API ${T.toString()} message: ${e.toString()} response on ${endpoint.path} with request body: $body and request headers: ${headers.toString()}');
var message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()} response with request body: $body and request headers: ${headers.toString()}';
log(message);
throw Exception(message);
}
throw Exception('Error assembling Talk API response');
}
}

View File

@ -24,7 +24,6 @@ import 'storage/base/settingsProvider.dart';
import 'view/pages/overhang.dart';
class App extends StatefulWidget {
static GlobalKey appContext = GlobalKey();
const App({super.key});
@override
@ -32,6 +31,7 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> with WidgetsBindingObserver {
late Timer refetchChats;
late Timer updateTimings;

View File

@ -1,64 +0,0 @@
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<void> 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<void> 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<void> 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);
}
}

View File

@ -1,85 +0,0 @@
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<void> 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);
completer.complete();
});
data.run();
await completer.future;
}
static Future<void> _generate(TimetableProps data, SettingsProvider settings) async {
log('Generating widget screen...');
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,
),
),
),
),
),
),
),
);
HomeWidget.saveWidgetData<String>('screen', base64.encode(imageData));
HomeWidget.updateWidget(name: 'TimetableWidget');
log('Widget screen successfully updated! (${imageData.length})');
}
}

View File

@ -2,7 +2,6 @@ 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';
@ -18,7 +17,6 @@ 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';
@ -86,8 +84,6 @@ Future<void> main() async {
child: const Main(),
)
);
BackgroundFetch.registerHeadlessTask(ScheduledTask.headless);
}
class Main extends StatefulWidget {
@ -115,7 +111,6 @@ class _MainState extends State<Main> {
Provider.of<BreakerProps>(context, listen: false).run();
});
ScheduledTask.configure();
super.initState();
}
@ -130,6 +125,7 @@ class _MainState extends State<Main> {
checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers,
checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false,
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
@ -151,7 +147,7 @@ class _MainState extends State<Main> {
child: Consumer<AccountModel>(
builder: (context, accountModel, child) {
switch(accountModel.state) {
case AccountModelState.loggedIn: return App(key: App.appContext);
case AccountModelState.loggedIn: return const App();
case AccountModelState.loggedOut: return const Login();
case AccountModelState.undefined: return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen');
}

View File

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../api/apiResponse.dart';
@ -12,6 +13,8 @@ class BreakerProps extends DataHolder {
PackageInfo? packageInfo;
String? isBlocked(BreakerArea? type) {
if(kDebugMode) return null;
if(packageInfo == null) {
PackageInfo.fromPlatform().then((value) => packageInfo = value);
return null;

View File

@ -62,7 +62,7 @@ class AppModule {
),
Modules.files: AppModule(
Modules.files,
name: 'Files',
name: 'Dateien',
icon: () => Icon(Icons.folder),
breakerArea: BreakerArea.files,
create: Files.new,

View File

@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:easy_debounce/easy_debounce.dart';
@ -14,8 +13,6 @@ class SettingsProvider extends ChangeNotifier {
late SharedPreferences _storage;
late Settings _settings = DefaultSettings.get();
final Completer<void> _populated = Completer();
Settings val({bool write = false}) {
if(write) {
notifyListeners();
@ -59,7 +56,6 @@ class SettingsProvider extends ChangeNotifier {
}
notifyListeners();
_populated.complete();
}
Future<void> update() async {
@ -81,8 +77,4 @@ class SettingsProvider extends ChangeNotifier {
return mergedMap;
}
Future<void> waitForPopulation() async {
await _populated.future;
}
}

View File

@ -1,287 +0,0 @@
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<Calendar> createState() => _CalendarState();
}
class _CalendarState extends State<Calendar> {
@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<TimetableProps>(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<TimeRegion> _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<DateTime>.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<GetTimetableResponseObject> _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;
}
}

View File

@ -2,16 +2,24 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../extensions/dateTime.dart';
import 'package:provider/provider.dart';
import 'package:syncfusion_flutter_calendar/calendar.dart';
import '../../../homescreen_widgets/timetable/timetableHomeWidget.dart';
import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart';
import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart';
import '../../../model/timetable/timetableProps.dart';
import '../../../storage/base/settingsProvider.dart';
import '../../../widget/loadingSpinner.dart';
import '../../../widget/placeholderView.dart';
import 'calendar.dart';
import 'appointmenetComponent.dart';
import 'appointmentDetails.dart';
import 'arbitraryAppointment.dart';
import 'customTimetableColors.dart';
import 'customTimetableEventEditDialog.dart';
import 'timeRegionComponent.dart';
import 'timetableEvents.dart';
import 'timetableNameMode.dart';
import 'viewCustomTimetableEvents.dart';
class Timetable extends StatefulWidget {
@ -26,11 +34,12 @@ enum CalendarActions { addEvent, viewEvents }
class _TimetableState extends State<Timetable> {
CalendarController controller = CalendarController();
late Timer updateTimings;
late SettingsProvider settings;
late final SettingsProvider settings;
@override
void initState() {
settings = Provider.of<SettingsProvider>(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
Provider.of<TimetableProps>(context, listen: false).run();
});
@ -47,7 +56,6 @@ class _TimetableState extends State<Timetable> {
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: () {
@ -111,19 +119,62 @@ class _TimetableState extends State<Timetable> {
if(value.primaryLoading()) return const LoadingSpinner();
var holidays = value.getHolidaysResponse;
return RefreshIndicator(
child: Calendar(
controller: controller,
timetableProps: value,
settings: settings,
),
onRefresh: () async {
Provider.of<TimetableProps>(context, listen: false).run(renew: true);
return Future.delayed(const Duration(seconds: 3));
}
);
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<TimetableProps>(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<TimetableProps>(context, listen: false).run(renew: true);
return Future.delayed(const Duration(seconds: 3));
}
);
},
)
),
);
@override
@ -131,4 +182,210 @@ class _TimetableState extends State<Timetable> {
updateTimings.cancel();
super.dispose();
}
List<TimeRegion> _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<DateTime>.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<GetTimetableResponseObject> _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(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,
}[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;
}
}

View File

@ -1,18 +1,14 @@
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;
@ -26,84 +22,6 @@ class _DevToolsSettingsState extends State<DevToolsSettings> {
@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'),

View File

@ -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.2+40
version: 0.1.3+41
environment:
sdk: '>3.0.0'
@ -101,9 +101,6 @@ dependencies:
time_range_picker: ^2.3.0
url_launcher: ^6.3.1
uuid: ^4.5.1
home_widget: ^0.7.0+1
screenshot: ^3.0.0
background_fetch: ^1.3.7
dev_dependencies:
flutter_test: