Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ android {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} else {
signingConfig = signingConfigs.getByName("debug")
}
Expand Down
1 change: 1 addition & 0 deletions android/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-keep class com.builttoroam.devicecalendar.** { *; }
2 changes: 2 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />

<queries>
<intent>
Expand Down
6 changes: 6 additions & 0 deletions assets/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@
"unknownDirection": "Unbekannte Richtung",
"showWeekends": "Wochenenden zeigen",
"showHiddenCalendarEntries": "Versteckte Kalendareintrage zeigen",
"exportToDeviceCalendar": "In Gerätekalender exportieren",
"exportCalendarDescription": "Deine TUM-Kalendereinträge werden in einen \"TUM Campus\"-Kalender auf deinem Gerät kopiert. Die Einträge werden bei jedem App-Start aktualisiert. Änderungen im Gerätekalender werden nicht zu TUM zurückübertragen.",
"removeExportedCalendar": "Exportierten Kalender entfernen",
"removeExportedCalendarDescription": "Der \"TUM Campus\"-Kalender und alle seine Einträge werden von deinem Gerät entfernt.",
"enable": "Aktivieren",
"remove": "Entfernen",
"color": "Farbe",
"resetLogin": "Zurücksetzen & Anmelden",
"resetPreferences": "Einstellungen zurücksetzen",
Expand Down
6 changes: 6 additions & 0 deletions assets/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@
"unknownDirection": "Unknown Direction",
"showWeekends": "Show Weekends",
"showHiddenCalendarEntries": "Show Hidden Calendar Entries",
"exportToDeviceCalendar": "Export to Device Calendar",
"exportCalendarDescription": "Your TUM calendar events will be copied to a \"TUM Campus\" calendar on your device. Events are updated each time the app is opened. This does not sync changes made on your device back to TUM.",
"removeExportedCalendar": "Remove Exported Calendar",
"removeExportedCalendarDescription": "This will remove the \"TUM Campus\" calendar and all its events from your device.",
"enable": "Enable",
"remove": "Remove",
"color": "Color",
"resetLogin": "Reset & Login",
"resetPreferences": "Reset Preferences",
Expand Down
6 changes: 6 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
PODS:
- device_calendar (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Firebase/CoreOnly (12.8.0):
Expand Down Expand Up @@ -148,6 +150,7 @@ PODS:
- FlutterMacOS

DEPENDENCIES:
- device_calendar (from `.symlinks/plugins/device_calendar/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
Expand Down Expand Up @@ -191,6 +194,8 @@ SPEC REPOS:
- sqlite3

EXTERNAL SOURCES:
device_calendar:
:path: ".symlinks/plugins/device_calendar/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
firebase_core:
Expand Down Expand Up @@ -233,6 +238,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"

SPEC CHECKSUMS:
device_calendar: b55b2c5406cfba45c95a59f9059156daee1f74ed
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d
firebase_core: ee30637e6744af8e0c12a6a1e8a9718506ec2398
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
<string>To provide you with the closest study rooms, cafeteria and departures we need to access your location while in use</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>To provide you with the closest study rooms, cafeteria and departures we need to access your location</string>
<key>NSCalendarsUsageDescription</key>
<string>To sync your TUM calendar events with your device calendar</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>To sync your TUM calendar events with your device calendar</string>
<key>NSContactsUsageDescription</key>
<string>To save TUM contacts to your local contacts we need access to your contacts</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
Expand Down
1 change: 1 addition & 0 deletions lib/base/enums/user_preference.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum UserPreference {
failedGrades(bool),
weekends(bool),
hiddenCalendarEntries(bool),
calendarSync(bool),
calendarTab(int);

final Type type;
Expand Down
199 changes: 199 additions & 0 deletions lib/calendarComponent/services/calendar_sync_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import 'package:campus_flutter/calendarComponent/model/calendar_event.dart';
import 'package:device_calendar/device_calendar.dart';
import 'package:shared_preferences/shared_preferences.dart';

class CalendarSyncService {
static const _calendarName = "TUM Campus";
static const _syncMappingKey = "calendar_sync_mapping";
static const _syncCalendarIdKey = "calendar_sync_calendar_id";

final DeviceCalendarPlugin _deviceCalendar;
final SharedPreferences _sharedPreferences;

CalendarSyncService(this._sharedPreferences)
: _deviceCalendar = DeviceCalendarPlugin();

/// Requests calendar permissions from the user.
/// Returns true if permissions were granted.
Future<bool> requestPermissions() async {
var permissionsGranted = await _deviceCalendar.hasPermissions();
if (permissionsGranted.isSuccess && !(permissionsGranted.data ?? false)) {
permissionsGranted = await _deviceCalendar.requestPermissions();
}
return permissionsGranted.isSuccess && (permissionsGranted.data ?? false);
}

/// Checks if calendar permissions are currently granted.
Future<bool> hasPermissions() async {
final result = await _deviceCalendar.hasPermissions();
return result.isSuccess && (result.data ?? false);
}

/// Gets or creates the TUM calendar on the device.
/// Returns the calendar ID or null if it could not be created.
Future<String?> getOrCreateTumCalendar() async {
// Check if we have a stored calendar ID that still exists
final storedId = _sharedPreferences.getString(_syncCalendarIdKey);
if (storedId != null) {
final calendarsResult = await _deviceCalendar.retrieveCalendars();
if (calendarsResult.isSuccess && calendarsResult.data != null) {
final exists = calendarsResult.data!.any((c) => c.id == storedId);
if (exists) return storedId;
}
}

// Try to find an existing TUM calendar
final calendarsResult = await _deviceCalendar.retrieveCalendars();
if (calendarsResult.isSuccess && calendarsResult.data != null) {
for (final calendar in calendarsResult.data!) {
if (calendar.name == _calendarName) {
_sharedPreferences.setString(_syncCalendarIdKey, calendar.id!);
return calendar.id;
}
}
}

// Create a new calendar
// Note: device_calendar createCalendar may not be available on all platforms
// On iOS, it creates a local calendar. On Android, it creates under the default account.
try {
final result = await _deviceCalendar.createCalendar(
_calendarName,
localAccountName: _calendarName,
);
if (result.isSuccess && result.data != null) {
_sharedPreferences.setString(_syncCalendarIdKey, result.data!);
return result.data;
}
} catch (_) {}

return null;
}

/// Syncs the provided TUM calendar events to the device calendar.
/// Only syncs visible, non-canceled events.
Future<SyncResult> syncEvents(List<CalendarEvent> events) async {
final hasPerms = await hasPermissions();
if (!hasPerms) {
return SyncResult(synced: 0, failed: 0, deleted: 0, error: "No calendar permissions");
}

final calendarId = await getOrCreateTumCalendar();
if (calendarId == null) {
return SyncResult(synced: 0, failed: 0, deleted: 0, error: "Could not create TUM calendar");
}

final mapping = _loadSyncMapping();
final visibleEvents = events.where((e) => e.isVisible ?? true).toList();
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method doc says only non-canceled events are synced, but visibleEvents currently only filters by visibility. If a caller passes in canceled events, they will be exported to the device calendar. Filter out e.isCanceled here (or update the doc if canceled events are intentionally included).

Suggested change
final visibleEvents = events.where((e) => e.isVisible ?? true).toList();
final visibleEvents =
events.where((e) => (e.isVisible ?? true) && !e.isCanceled).toList();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add that @ManuelMuehlberger :)

final currentTumEventIds = visibleEvents.map((e) => e.id).toSet();

int synced = 0;
int failed = 0;
int deleted = 0;

// Add or update events
for (final tumEvent in visibleEvents) {
try {
final deviceEventId = mapping[tumEvent.id];
final event = _mapToDeviceEvent(calendarId, tumEvent, deviceEventId);

final result = await _deviceCalendar.createOrUpdateEvent(event);
if (result?.isSuccess == true && result?.data != null) {
mapping[tumEvent.id] = result!.data!;
synced++;
} else {
failed++;
}
} catch (_) {
failed++;
}
}

// Delete events that are no longer in the TUM calendar
final idsToRemove = <String>[];
for (final entry in mapping.entries) {
if (!currentTumEventIds.contains(entry.key)) {
try {
await _deviceCalendar.deleteEvent(calendarId, entry.value);
deleted++;
} catch (_) {}
idsToRemove.add(entry.key);
}
}
for (final id in idsToRemove) {
mapping.remove(id);
}

_saveSyncMapping(mapping);

return SyncResult(synced: synced, failed: failed, deleted: deleted);
}

/// Removes the TUM calendar and all synced events from the device.
Future<void> removeSyncedCalendar() async {
final calendarId = _sharedPreferences.getString(_syncCalendarIdKey);
if (calendarId != null) {
try {
await _deviceCalendar.deleteCalendar(calendarId);
} catch (_) {}
}
_sharedPreferences.remove(_syncCalendarIdKey);
_sharedPreferences.remove(_syncMappingKey);
}

Event _mapToDeviceEvent(
String calendarId,
CalendarEvent tumEvent,
String? existingDeviceEventId,
) {
final local = getLocation('Europe/Berlin');
final event = Event(
calendarId,
eventId: existingDeviceEventId,
title: tumEvent.title ?? "",
start: TZDateTime.from(tumEvent.startDate, local),
end: TZDateTime.from(tumEvent.endDate, local),
description: tumEvent.description,
location: tumEvent.locations.isNotEmpty
? tumEvent.locations.join(", ")
: null,
);
return event;
}

/// Loads the TUM event ID → device calendar event ID mapping.
Map<String, String> _loadSyncMapping() {
final raw = _sharedPreferences.getStringList(_syncMappingKey);
if (raw == null) return {};
final map = <String, String>{};
for (final entry in raw) {
final parts = entry.split("=");
if (parts.length == 2) {
map[parts[0]] = parts[1];
}
}
return map;
}

/// Saves the mapping as a list of "tumId=deviceId" strings.
void _saveSyncMapping(Map<String, String> mapping) {
final list = mapping.entries.map((e) => "${e.key}=${e.value}").toList();
_sharedPreferences.setStringList(_syncMappingKey, list);
}
}

class SyncResult {
final int synced;
final int failed;
final int deleted;
final String? error;

SyncResult({
required this.synced,
required this.failed,
required this.deleted,
this.error,
});

bool get hasError => error != null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class CalendarAdditionViewModel {

String? id;
final Ref ref;
bool _isSaving = false;

CalendarAdditionViewModel(this.ref) {
final date = ref.read(selectedDate);
Expand Down Expand Up @@ -114,6 +115,9 @@ class CalendarAdditionViewModel {
}

Future<void> saveEvent() async {
if (_isSaving) return;
_isSaving = true;
try {
if (id != null) {
await CalendarService.deleteCalendarEvent(id!);
}
Expand Down Expand Up @@ -149,6 +153,9 @@ class CalendarAdditionViewModel {
),
);
}
} finally {
_isSaving = false;
}
}

void checkValidity() {
Expand Down
34 changes: 34 additions & 0 deletions lib/calendarComponent/viewModels/calendar_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:campus_flutter/base/enums/user_preference.dart';
import 'package:campus_flutter/calendarComponent/model/calendar_event.dart';
import 'package:campus_flutter/calendarComponent/services/calendar_preference_service.dart';
import 'package:campus_flutter/calendarComponent/services/calendar_service.dart';
import 'package:campus_flutter/calendarComponent/services/calendar_sync_service.dart';
import 'package:campus_flutter/main.dart';
import 'package:campus_flutter/base/services/user_preferences_service.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -41,6 +42,7 @@ class CalendarViewModel {
}
events.add(response.$2);
updateHomeWidget(response.$2);
_syncToDeviceCalendar(response.$2);
}, onError: (error) => events.addError(error));
}

Expand Down Expand Up @@ -181,4 +183,36 @@ class CalendarViewModel {
events.add(elements);
updateHomeWidget(events.value ?? []);
}

Future<void> _syncToDeviceCalendar(List<CalendarEvent> calendarEvents) async {
final isSyncEnabled =
getIt<UserPreferencesService>().load(UserPreference.calendarSync)
as bool? ??
false;
if (!isSyncEnabled) return;

final syncService = getIt<CalendarSyncService>();
await syncService.syncEvents(calendarEvents);
}

Future<bool> enableCalendarSync() async {
final syncService = getIt<CalendarSyncService>();
final granted = await syncService.requestPermissions();
if (!granted) return false;

getIt<UserPreferencesService>().save(UserPreference.calendarSync, true);

// Trigger an initial sync with current events
final currentEvents = events.value;
if (currentEvents != null) {
await syncService.syncEvents(currentEvents);
}
return true;
}

Future<void> disableCalendarSync() async {
getIt<UserPreferencesService>().save(UserPreference.calendarSync, false);
final syncService = getIt<CalendarSyncService>();
await syncService.removeSyncedCalendar();
}
}
4 changes: 4 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:campus_flutter/base/routing/routes.dart';
import 'package:campus_flutter/base/theme/dark_theme.dart';
import 'package:campus_flutter/base/theme/light_theme.dart';
import 'package:campus_flutter/calendarComponent/services/calendar_preference_service.dart';
import 'package:campus_flutter/calendarComponent/services/calendar_sync_service.dart';
import 'package:campus_flutter/calendarComponent/services/calendar_view_service.dart';
import 'package:campus_flutter/onboardingComponent/services/onboarding_service.dart';
import 'package:campus_flutter/navigation_service.dart';
Expand Down Expand Up @@ -109,6 +110,9 @@ Future<void> _initializeServices() async {
getIt.registerSingleton<CalendarPreferenceService>(
CalendarPreferenceService(sharedPreferences),
);
getIt.registerSingleton<CalendarSyncService>(
CalendarSyncService(sharedPreferences),
);
}

Future<bool> _initializeHomeWidgets() async {
Expand Down
Loading