diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3c24865..3b12681 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,9 @@ + android:icon="@mipmap/ic_launcher" + android:fullBackupContent="@xml/backup_rules" + android:dataExtractionRules="@xml/data_extraction_rules"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt index 5e1f387..cf439fc 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt @@ -1,5 +1,42 @@ package eu.mhsl.marianum.mobile.client +import android.content.Intent import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterActivity() +class MainActivity : FlutterActivity() { + private val widgetChannel = "eu.mhsl.marianum.widget" + /// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation` + /// so the same intent isn't replayed on every resume. + private var pendingTimetableTap: Boolean = false + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + widgetChannel + ).setMethodCallHandler { call, result -> + when (call.method) { + "consumePendingNavigation" -> { + val pending = pendingTimetableTap + pendingTimetableTap = false + result.success(pending) + } + else -> result.notImplemented() + } + } + consumeIntentData(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + consumeIntentData(intent) + } + + private fun consumeIntentData(intent: Intent?) { + if (intent?.getBooleanExtra("widget_open_timetable", false) == true) { + pendingTimetableTap = true + } + } +} diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt new file mode 100644 index 0000000..9b7da6e --- /dev/null +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt @@ -0,0 +1,37 @@ +package eu.mhsl.marianum.mobile.client + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.os.Bundle +import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer + +/** + * Lives at the package root (not under `widgets/`) because the home_widget + * Flutter plugin resolves the receiver class as `.`. + */ +class TimetableDayWidget : AppWidgetProvider() { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + for (id in appWidgetIds) { + val options = appWidgetManager.getAppWidgetOptions(id) + val views = WidgetRenderer.buildDay(context, context.packageName, options) + appWidgetManager.updateAppWidget(id, views) + } + } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: Bundle, + ) { + // Re-render on resize, otherwise the tiles stay at install-time size + // and either clip or leave dead space. + val views = WidgetRenderer.buildDay(context, context.packageName, newOptions) + appWidgetManager.updateAppWidget(appWidgetId, views) + } +} diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt new file mode 100644 index 0000000..9ffcf8f --- /dev/null +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt @@ -0,0 +1,31 @@ +package eu.mhsl.marianum.mobile.client + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.os.Bundle +import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer + +class TimetableWeekWidget : AppWidgetProvider() { + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + for (id in appWidgetIds) { + val options = appWidgetManager.getAppWidgetOptions(id) + val views = WidgetRenderer.buildWeek(context, context.packageName, options) + appWidgetManager.updateAppWidget(id, views) + } + } + + override fun onAppWidgetOptionsChanged( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + newOptions: Bundle, + ) { + val views = WidgetRenderer.buildWeek(context, context.packageName, newOptions) + appWidgetManager.updateAppWidget(appWidgetId, views) + } +} diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt new file mode 100644 index 0000000..402d503 --- /dev/null +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt @@ -0,0 +1,157 @@ +package eu.mhsl.marianum.mobile.client.widgets + +import org.json.JSONException +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names +// must stay in sync. +enum class WidgetLessonStatus { + REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT; + + companion object { + fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { + "regular" -> REGULAR + "ongoing" -> ONGOING + "past" -> PAST + "cancelled" -> CANCELLED + "irregular" -> IRREGULAR + "teacherChanged" -> TEACHER_CHANGED + "event" -> EVENT + else -> REGULAR + } + } +} + +data class WidgetLesson( + val start: Date, + val end: Date, + val subjectShort: String, + val subjectLong: String?, + val room: String?, + val teacher: String?, + val originalTeacher: String?, + val status: WidgetLessonStatus, + val customColor: String?, + val siblingCount: Int, +) + +data class WidgetPeriod( + val name: String, + val startMinutes: Int, + val endMinutes: Int, + val virtualStartMinutes: Int, + val virtualEndMinutes: Int, +) + +data class WidgetTimetableData( + val fetchedAt: Date, + val anchorDate: Date, + val lessons: List, + val periods: List, + val isHoliday: Boolean, + val holidayName: String?, +) + +object WidgetDataParser { + private val isoFormat: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.ROOT).apply { + timeZone = TimeZone.getDefault() + } + + /// Dart's toIso8601String() ships microseconds (6 digits) when non-zero; + /// SimpleDateFormat only parses 3 → strip the extra digits. Local time + /// without Z is the default, so removeSuffix("Z") makes the parser + /// tolerate both shapes. + private fun parseDate(raw: String?): Date? { + if (raw.isNullOrEmpty()) return null + val cleaned = raw + .replace(Regex("([.,]\\d{3})\\d+"), "$1") + .removeSuffix("Z") + return try { + isoFormat.parse(cleaned) + } catch (_: Exception) { + null + } + } + + fun parse(json: String?): WidgetTimetableData? { + if (json.isNullOrEmpty()) return null + return try { + val root = JSONObject(json) + val lessonsArray = root.optJSONArray("lessons") + val lessons = mutableListOf() + if (lessonsArray != null) { + for (i in 0 until lessonsArray.length()) { + val obj = lessonsArray.optJSONObject(i) ?: continue + val start = parseDate(obj.stringOrNull("start")) ?: continue + val end = parseDate(obj.stringOrNull("end")) ?: continue + lessons += WidgetLesson( + start = start, + end = end, + subjectShort = obj.stringOrNull("subjectShort") ?: "", + subjectLong = obj.stringOrNull("subjectLong"), + room = obj.stringOrNull("room"), + teacher = obj.stringOrNull("teacher"), + originalTeacher = obj.stringOrNull("originalTeacher"), + status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")), + customColor = obj.stringOrNull("customColor"), + siblingCount = obj.optInt("siblingCount", 0), + ) + } + } + val periodsArray = root.optJSONArray("periods") + val periods = mutableListOf() + if (periodsArray != null) { + for (i in 0 until periodsArray.length()) { + val obj = periodsArray.optJSONObject(i) ?: continue + periods += WidgetPeriod( + name = obj.stringOrNull("name") ?: "", + startMinutes = obj.optInt("startMinutes", 0), + endMinutes = obj.optInt("endMinutes", 0), + virtualStartMinutes = obj.optInt("virtualStartMinutes", 0), + virtualEndMinutes = obj.optInt("virtualEndMinutes", 0), + ) + } + } + WidgetTimetableData( + fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(), + anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(), + lessons = lessons, + periods = periods, + isHoliday = root.optBoolean("isHoliday", false), + holidayName = root.stringOrNull("holidayName"), + ) + } catch (_: JSONException) { + null + } + } + + private fun JSONObject.stringOrNull(key: String): String? { + if (!has(key) || isNull(key)) return null + val raw = optString(key, "") + return if (raw.isEmpty()) null else raw + } +} + +object WidgetDateUtils { + fun startOfDay(date: Date): Date { + val cal = Calendar.getInstance().apply { time = date } + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + return cal.time + } + + fun isSameDay(a: Date, b: Date): Boolean { + val ca = Calendar.getInstance().apply { time = a } + val cb = Calendar.getInstance().apply { time = b } + return ca.get(Calendar.YEAR) == cb.get(Calendar.YEAR) && + ca.get(Calendar.DAY_OF_YEAR) == cb.get(Calendar.DAY_OF_YEAR) + } +} diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt new file mode 100644 index 0000000..3c454dd --- /dev/null +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt @@ -0,0 +1,786 @@ +package eu.mhsl.marianum.mobile.client.widgets + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.os.Bundle +import android.util.TypedValue +import android.view.View +import android.widget.RemoteViews +import eu.mhsl.marianum.mobile.client.MainActivity +import eu.mhsl.marianum.mobile.client.R +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import kotlin.math.max + +/** + * Renders the day and week widgets as a time-grid: lesson blocks absolutely + * positioned on a vertical axis, mirroring the in-app Syncfusion calendar. + * Per-hour dp is computed from the widget bundle so the grid scales with + * resize, clamped to [MIN_HOUR_HEIGHT_DP, MAX_HOUR_HEIGHT_DP]. + */ +object WidgetRenderer { + private const val FALLBACK_VIRTUAL_MINUTES = 11 * 60 + + private const val DAY_CHROME_DP = 40 + private const val WEEK_CHROME_DP = 64 + + private const val MIN_HOUR_HEIGHT_DP = 18 + private const val MAX_HOUR_HEIGHT_DP = 72 + + private const val MIN_BLOCK_HEIGHT_DP = 16 + private const val LESSON_GAP_DP = 1.5f + + // Below SHOW_ROOM_MIN: subject only. Below SHOW_TEACHER_SEPARATE_MIN: + // subject + room. Above: subject + room + teacher stacked. + private const val BLOCK_SHOW_ROOM_MIN_DP = 18 + private const val BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP = 30 + + /// Below this column width autoSize can't fit subject + room — drop + /// room/teacher entirely on the week-widget. + private const val WEEK_COLUMN_TIGHT_DP = 45 + + private val timeFormat = SimpleDateFormat("HH:mm", Locale.GERMAN) + private val dateShort = SimpleDateFormat("dd.MM.", Locale.GERMAN) + private val weekdayShort = SimpleDateFormat("EE", Locale.GERMAN) + private val dateTimeShort = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN) + + /// Hex values mirror LightAppTheme / DarkAppTheme tokens so the widget + /// matches the app's branding rather than the generic system look. + private data class WidgetPalette( + val background: Int, + val textPrimary: Int, + val textSecondary: Int, + val divider: Int, + val breakBlock: Int, + val watermarkAlpha: Float, + ) + + private val lightPalette = WidgetPalette( + background = 0xFFFCF7F5.toInt(), + textPrimary = 0xFF1A1A1A.toInt(), + textSecondary = 0xFF555555.toInt(), + divider = 0x22000000, + breakBlock = 0x0C000000, + watermarkAlpha = 0.014f, + ) + + private val darkPalette = WidgetPalette( + background = 0xFF1F1716.toInt(), + textPrimary = 0xFFF1F1F1.toInt(), + textSecondary = 0xFFB0B0B0.toInt(), + divider = 0x33FFFFFF, + breakBlock = 0x14FFFFFF, + watermarkAlpha = 0.025f, + ) + + private fun resolvePalette(context: Context, themeMode: String?): WidgetPalette { + val isDark = when (themeMode) { + "light" -> false + "dark" -> true + else -> { + val uiMode = context.resources.configuration.uiMode and + Configuration.UI_MODE_NIGHT_MASK + uiMode == Configuration.UI_MODE_NIGHT_YES + } + } + return if (isDark) darkPalette else lightPalette + } + + fun buildDay( + context: Context, + packageName: String, + options: Bundle? = null, + ): RemoteViews { + val prefs = sharedPrefs(context) + val palette = resolvePalette(context, prefs.getString(KEY_THEME_MODE, "system")) + if (!prefs.getBoolean(KEY_LOGGED_IN, false)) { + return buildPlaceholder( + context, + packageName, + context.getString(R.string.widget_login_required), + palette, + ) + } + val data = WidgetDataParser.parse(prefs.getString(KEY_DAY_DATA, null)) + ?: return buildPlaceholder( + context, + packageName, + context.getString(R.string.widget_loading), + palette, + ) + + val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes + ?: FALLBACK_VIRTUAL_MINUTES + val hourHeightDp = resolveHourHeight(options, DAY_CHROME_DP, totalVirtualMin) + + val views = RemoteViews(packageName, R.layout.widget_day) + applyChrome(views, palette, options) + views.setTextColor(R.id.widget_day_title, palette.textPrimary) + views.setTextColor(R.id.widget_day_subtitle, palette.textSecondary) + views.setTextColor(R.id.widget_day_empty, palette.textSecondary) + views.setTextViewText( + R.id.widget_day_title, + "${dayLabel(context, data.anchorDate)} · ${dateShort.format(data.anchorDate)}", + ) + views.setTextViewText( + R.id.widget_day_subtitle, + context.getString(R.string.widget_status_label, freshnessLabel(context, data.fetchedAt)), + ) + + views.removeAllViews(R.id.widget_day_time_labels) + views.removeAllViews(R.id.widget_day_grid) + + if (data.isHoliday) { + views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) + views.setTextViewText( + R.id.widget_day_empty, + data.holidayName ?: context.getString(R.string.widget_holiday), + ) + } else if (data.lessons.isEmpty()) { + views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) + views.setTextViewText( + R.id.widget_day_empty, + context.getString(R.string.widget_no_lessons), + ) + } else { + views.setViewVisibility(R.id.widget_day_empty, View.GONE) + populateGridLines(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods) + populateTimeLabels(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods) + populateGridLines(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods) + populateBreakBlocks(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods) + for (lesson in data.lessons) { + addLessonBlock( + context = context, + packageName = packageName, + parent = views, + containerId = R.id.widget_day_grid, + lesson = lesson, + hourHeightDp = hourHeightDp, + periods = data.periods, + subjectOnly = false, + horizontalPaddingDp = 7, + ) + } + maybeAddNowIndicator( + packageName, + views, + R.id.widget_day_grid, + hourHeightDp, + anchorDate = data.anchorDate, + periods = data.periods, + ) + } + + views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) + return views + } + + fun buildWeek( + context: Context, + packageName: String, + options: Bundle? = null, + ): RemoteViews { + val prefs = sharedPrefs(context) + val palette = resolvePalette(context, prefs.getString(KEY_THEME_MODE, "system")) + if (!prefs.getBoolean(KEY_LOGGED_IN, false)) { + return buildPlaceholder( + context, + packageName, + context.getString(R.string.widget_login_required), + palette, + ) + } + val data = WidgetDataParser.parse(prefs.getString(KEY_WEEK_DATA, null)) + ?: return buildPlaceholder( + context, + packageName, + context.getString(R.string.widget_loading), + palette, + ) + + val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes + ?: FALLBACK_VIRTUAL_MINUTES + val hourHeightDp = resolveHourHeight(options, WEEK_CHROME_DP, totalVirtualMin) + + val views = RemoteViews(packageName, R.layout.widget_week) + applyChrome(views, palette, options) + views.setTextColor(R.id.widget_week_title, palette.textPrimary) + views.setTextColor(R.id.widget_week_subtitle, palette.textSecondary) + val cal = Calendar.getInstance().apply { time = data.anchorDate } + val weekNumber = cal.get(Calendar.WEEK_OF_YEAR) + val end = Calendar.getInstance().apply { + time = data.anchorDate + add(Calendar.DAY_OF_YEAR, 4) + }.time + val kwPrefix = context.getString(R.string.widget_calendar_week_prefix) + views.setTextViewText( + R.id.widget_week_title, + "$kwPrefix $weekNumber · ${dateShort.format(data.anchorDate)}–${dateShort.format(end)}", + ) + views.setTextViewText( + R.id.widget_week_subtitle, + context.getString(R.string.widget_status_label, freshnessLabel(context, data.fetchedAt)), + ) + + val headerIds = listOf( + R.id.widget_week_header_mon, + R.id.widget_week_header_tue, + R.id.widget_week_header_wed, + R.id.widget_week_header_thu, + R.id.widget_week_header_fri, + ) + val columnIds = listOf( + R.id.widget_week_col_mon, + R.id.widget_week_col_tue, + R.id.widget_week_col_wed, + R.id.widget_week_col_thu, + R.id.widget_week_col_fri, + ) + views.removeAllViews(R.id.widget_week_time_labels) + populateGridLines(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods) + populateTimeLabels(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods) + + val (weekWidthDp, _) = widgetSizeDp(options) + // Time-label column is 28dp wide; the rest is split across 5 days + // plus thin dividers (negligible). Drop room/teacher only on the + // very narrowest week widgets — autoSize handles the in-between + // sizes. + val dayColumnWidthDp = (weekWidthDp - 28f - 20f) / 5f + val subjectOnly = dayColumnWidthDp < WEEK_COLUMN_TIGHT_DP + + for ((index, columnId) in columnIds.withIndex()) { + views.removeAllViews(headerIds[index]) + views.removeAllViews(columnId) + val day = Calendar.getInstance().apply { + time = data.anchorDate + add(Calendar.DAY_OF_YEAR, index) + }.time + val header = RemoteViews(packageName, R.layout.widget_week_day_header) + header.setTextColor(R.id.widget_week_day_header_weekday, palette.textPrimary) + header.setTextColor(R.id.widget_week_day_header_date, palette.textSecondary) + header.setTextViewText(R.id.widget_week_day_header_weekday, weekdayShort.format(day)) + header.setTextViewText(R.id.widget_week_day_header_date, dateShort.format(day)) + views.addView(headerIds[index], header) + + populateGridLines(packageName, views, columnId, hourHeightDp, palette, data.periods) + populateBreakBlocks(packageName, views, columnId, hourHeightDp, palette, data.periods) + for (lesson in data.lessons.filter { WidgetDateUtils.isSameDay(it.start, day) }) { + addLessonBlock( + context = context, + packageName = packageName, + parent = views, + containerId = columnId, + lesson = lesson, + hourHeightDp = hourHeightDp, + periods = data.periods, + subjectOnly = subjectOnly, + horizontalPaddingDp = 3, + ) + } + if (WidgetDateUtils.isSameDay(day, Date())) { + maybeAddNowIndicator( + packageName, + views, + columnId, + hourHeightDp, + anchorDate = day, + periods = data.periods, + ) + } + } + + views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) + return views + } + + /// Pulls the launcher-reported widget size out of the AppWidget options + /// bundle. The grid now spans `totalVirtualMin` minutes (lessons + + /// preserved big breaks), so we divide by that instead of a fixed hour + /// count to keep tiles readable across different timetables. + private fun resolveHourHeight( + options: Bundle?, + chromeDp: Int, + totalVirtualMin: Int, + ): Float { + val virtualHours = (totalVirtualMin / 60.0f).coerceAtLeast(1f) + val rawHeightDp = options?.let { + max( + it.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), + it.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), + ) + } ?: 0 + if (rawHeightDp <= 0) return 32f + val gridHeightDp = (rawHeightDp - chromeDp) + .coerceAtLeast((MIN_HOUR_HEIGHT_DP * virtualHours).toInt()) + return (gridHeightDp.toFloat() / virtualHours) + .coerceIn(MIN_HOUR_HEIGHT_DP.toFloat(), MAX_HOUR_HEIGHT_DP.toFloat()) + } + + /// Real wall-clock minute → position on the virtual axis. Inside a + /// period: linear. In a gap: linear across the virtual gap (zero for + /// squeezed small breaks, real width for big breaks). + private fun realMinutesToVirtual( + realMin: Int, + periods: List, + ): Float { + if (periods.isEmpty()) return realMin.toFloat() + for (period in periods) { + if (realMin in period.startMinutes..period.endMinutes) { + return period.virtualStartMinutes + (realMin - period.startMinutes).toFloat() + } + } + val first = periods.first() + if (realMin < first.startMinutes) { + return (realMin - first.startMinutes + first.virtualStartMinutes).toFloat() + } + val last = periods.last() + if (realMin > last.endMinutes) { + return last.virtualEndMinutes + (realMin - last.endMinutes).toFloat() + } + var prev = first + for (i in 1 until periods.size) { + val curr = periods[i] + if (realMin in (prev.endMinutes + 1) until curr.startMinutes) { + val gap = curr.startMinutes - prev.endMinutes + val virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes + return if (gap > 0) { + prev.virtualEndMinutes + + (realMin - prev.endMinutes).toFloat() * virtualGap / gap + } else { + curr.virtualStartMinutes.toFloat() + } + } + prev = curr + } + return 0f + } + + /// Below this per-hour height the two-line label collapses to a single + /// period number — time + number overlap otherwise. + private const val TIME_LABEL_COMPACT_THRESHOLD_DP = 26f + + private fun populateTimeLabels( + packageName: String, + parent: RemoteViews, + containerId: Int, + hourHeightDp: Float, + palette: WidgetPalette, + periods: List, + ) { + val compact = hourHeightDp < TIME_LABEL_COMPACT_THRESHOLD_DP + for (period in periods) { + val label = RemoteViews(packageName, R.layout.widget_time_label) + label.setTextViewText(R.id.widget_time_label_number, "${period.name}.") + label.setTextViewText(R.id.widget_time_label_time, formatHm(period.startMinutes)) + if (compact) { + label.setViewVisibility(R.id.widget_time_label_time, View.GONE) + label.setTextViewTextSize( + R.id.widget_time_label_number, + TypedValue.COMPLEX_UNIT_SP, + 9f, + ) + label.setTextColor(R.id.widget_time_label_number, palette.textPrimary) + } else { + label.setViewVisibility(R.id.widget_time_label_time, View.VISIBLE) + label.setTextViewTextSize( + R.id.widget_time_label_number, + TypedValue.COMPLEX_UNIT_SP, + 7f, + ) + label.setTextColor(R.id.widget_time_label_number, palette.textSecondary) + } + label.setTextColor(R.id.widget_time_label_time, palette.textPrimary) + val topDp = period.virtualStartMinutes * hourHeightDp / 60.0f + label.setViewLayoutMargin( + R.id.widget_time_label_root, + RemoteViews.MARGIN_TOP, + topDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + parent.addView(containerId, label) + } + } + + private fun populateGridLines( + packageName: String, + parent: RemoteViews, + containerId: Int, + hourHeightDp: Float, + palette: WidgetPalette, + periods: List, + ) { + // Lines at every period start + end, deduped by virtual minute so + // adjacent periods share a line and big-break boundaries get both + // upper and lower edges. + val drawn = mutableSetOf() + for (period in periods) { + for (virtualMin in listOf(period.virtualStartMinutes, period.virtualEndMinutes)) { + if (!drawn.add(virtualMin)) continue + val line = RemoteViews(packageName, R.layout.widget_grid_line) + line.setInt(R.id.widget_grid_line_root, "setBackgroundColor", palette.divider) + val topDp = virtualMin * hourHeightDp / 60.0f + line.setViewLayoutMargin( + R.id.widget_grid_line_root, + RemoteViews.MARGIN_TOP, + topDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + parent.addView(containerId, line) + } + } + } + + /// Faint translucent block in any virtual gap between two periods — + /// only big breaks (Hofpause, Mittagspause) survive the mapper's + /// small-break collapse. + private fun populateBreakBlocks( + packageName: String, + parent: RemoteViews, + containerId: Int, + hourHeightDp: Float, + palette: WidgetPalette, + periods: List, + ) { + for (i in 0 until periods.size - 1) { + val curr = periods[i] + val next = periods[i + 1] + val virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes + if (virtualGap <= 0) continue + val block = RemoteViews(packageName, R.layout.widget_break_block) + val topDp = curr.virtualEndMinutes * hourHeightDp / 60.0f + val heightDp = virtualGap * hourHeightDp / 60.0f + block.setViewLayoutMargin( + R.id.widget_break_block_root, + RemoteViews.MARGIN_TOP, + topDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + block.setViewLayoutHeight( + R.id.widget_break_block_root, + heightDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + block.setInt( + R.id.widget_break_block_root, + "setBackgroundColor", + palette.breakBlock, + ) + parent.addView(containerId, block) + } + } + + private fun formatHm(minutesSinceMidnight: Int): String { + val h = minutesSinceMidnight / 60 + val m = minutesSinceMidnight % 60 + return "%02d:%02d".format(h, m) + } + + /// Overrides the chrome XML's `@color/widget_*` defaults when the user + /// pins a fixed light/dark theme, and resizes the watermark M to match + /// the current widget bounds. + private fun applyChrome(views: RemoteViews, palette: WidgetPalette, options: Bundle?) { + views.setInt(R.id.widget_root, "setBackgroundColor", palette.background) + views.setInt(R.id.widget_watermark, "setColorFilter", palette.textPrimary) + views.setFloat(R.id.widget_watermark, "setAlpha", palette.watermarkAlpha) + + val (widthDp, heightDp) = widgetSizeDp(options) + // Sized to the longer edge so the M scales with widget resize. + // Negative end/bottom margin lets a sliver tuck behind the edge. + val markSize = (max(widthDp, heightDp) * 0.8f).coerceIn(160f, 400f) + val offsetEnd = -(markSize * 0.18f) + val offsetBottom = -(markSize * 0.18f) + views.setViewLayoutWidth( + R.id.widget_watermark, + markSize, + TypedValue.COMPLEX_UNIT_DIP, + ) + views.setViewLayoutHeight( + R.id.widget_watermark, + markSize, + TypedValue.COMPLEX_UNIT_DIP, + ) + views.setViewLayoutMargin( + R.id.widget_watermark, + RemoteViews.MARGIN_END, + offsetEnd, + TypedValue.COMPLEX_UNIT_DIP, + ) + views.setViewLayoutMargin( + R.id.widget_watermark, + RemoteViews.MARGIN_BOTTOM, + offsetBottom, + TypedValue.COMPLEX_UNIT_DIP, + ) + } + + private fun widgetSizeDp(options: Bundle?): Pair { + if (options == null) return Pair(220, 220) + val width = max( + options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0), + options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0), + ).coerceAtLeast(140) + val height = max( + options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), + options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), + ).coerceAtLeast(140) + return Pair(width, height) + } + + private fun addLessonBlock( + context: Context, + packageName: String, + parent: RemoteViews, + containerId: Int, + lesson: WidgetLesson, + hourHeightDp: Float, + periods: List, + subjectOnly: Boolean, + horizontalPaddingDp: Int, + ) { + val cal = Calendar.getInstance() + cal.time = lesson.start + val startMinutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) + cal.time = lesson.end + val endMinutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) + val durationMinutes = (endMinutes - startMinutes).coerceAtLeast(15) + + val virtualStart = realMinutesToVirtual(startMinutes, periods) + val virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods) + if (virtualEnd <= virtualStart) return + + // Half the gap above + half below so the grid line under the tile + // stays visible. + val topDp = virtualStart * hourHeightDp / 60.0f + LESSON_GAP_DP / 2f + val heightDp = ((virtualEnd - virtualStart) * hourHeightDp / 60.0f - LESSON_GAP_DP) + .coerceAtLeast(MIN_BLOCK_HEIGHT_DP.toFloat()) + + val block = RemoteViews(packageName, R.layout.widget_lesson_block) + block.setViewLayoutMargin( + R.id.widget_lesson_block_root, + RemoteViews.MARGIN_TOP, + topDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + block.setViewLayoutHeight( + R.id.widget_lesson_block_root, + heightDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + block.setInt( + R.id.widget_lesson_block_root, + "setBackgroundResource", + statusDrawable(lesson), + ) + + val density = context.resources.displayMetrics.density + val padXPx = (horizontalPaddingDp * density).toInt() + val padYPx = (3 * density).toInt() + block.setViewPadding( + R.id.widget_lesson_block_root, + padXPx, padYPx, padXPx, padYPx, + ) + + block.setTextViewText(R.id.widget_lesson_subject, subjectLabel(lesson)) + + // Separate fixed-size badge so the +N hint stays readable when + // autoSize shrinks the subject on narrow tiles. + if (lesson.siblingCount > 0) { + block.setTextViewText( + R.id.widget_lesson_sibling_badge, + "+${lesson.siblingCount}", + ) + block.setViewVisibility(R.id.widget_lesson_sibling_badge, View.VISIBLE) + } else { + block.setViewVisibility(R.id.widget_lesson_sibling_badge, View.GONE) + } + + val room = roomLabel(lesson) + val teacher = teacherLabel(lesson) + val noSecondaryContent = room.isNullOrEmpty() && teacher.isNullOrEmpty() + val hideSecondary = subjectOnly || + heightDp < BLOCK_SHOW_ROOM_MIN_DP || + noSecondaryContent + block.setViewVisibility( + R.id.widget_lesson_secondary_stack, + if (hideSecondary) View.GONE else View.VISIBLE, + ) + // Custom-events have no room/teacher → let the subject wrap to 2 lines + // so long titles don't autoshrink to nothing. + block.setInt( + R.id.widget_lesson_subject, + "setMaxLines", + if (noSecondaryContent) 2 else 1, + ) + when { + hideSecondary -> { + applyOptionalText(block, R.id.widget_lesson_room, null) + applyOptionalText(block, R.id.widget_lesson_teacher, null) + } + heightDp < BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP -> { + applyOptionalText(block, R.id.widget_lesson_room, room) + applyOptionalText(block, R.id.widget_lesson_teacher, null) + } + else -> { + applyOptionalText(block, R.id.widget_lesson_room, room) + applyOptionalText(block, R.id.widget_lesson_teacher, teacher) + } + } + + parent.addView(containerId, block) + } + + private fun applyOptionalText(views: RemoteViews, viewId: Int, text: String?) { + if (text.isNullOrEmpty()) { + views.setViewVisibility(viewId, View.GONE) + } else { + views.setTextViewText(viewId, text) + views.setViewVisibility(viewId, View.VISIBLE) + } + } + + private fun maybeAddNowIndicator( + packageName: String, + parent: RemoteViews, + containerId: Int, + hourHeightDp: Float, + anchorDate: Date, + periods: List, + ) { + if (!WidgetDateUtils.isSameDay(anchorDate, Date())) return + val now = Calendar.getInstance() + val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + if (periods.isNotEmpty()) { + if (nowMinutes < periods.first().startMinutes || + nowMinutes > periods.last().endMinutes + ) return + } + val virtualNow = realMinutesToVirtual(nowMinutes, periods) + val topDp = virtualNow * hourHeightDp / 60.0f + val indicator = RemoteViews(packageName, R.layout.widget_now_indicator) + indicator.setViewLayoutMargin( + R.id.widget_now_indicator_root, + RemoteViews.MARGIN_TOP, + topDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + parent.addView(containerId, indicator) + } + + /// Custom-events use the user-picked palette (orange/red/green/blue, + /// mirroring CustomTimetableColors). + private fun statusDrawable(lesson: WidgetLesson): Int { + if (lesson.status == WidgetLessonStatus.EVENT && lesson.customColor != null) { + return when (lesson.customColor) { + "orange" -> R.drawable.widget_lesson_block_event_orange + "red" -> R.drawable.widget_lesson_block_event_red + "green" -> R.drawable.widget_lesson_block_event_green + "blue" -> R.drawable.widget_lesson_block_event_blue + else -> R.drawable.widget_lesson_block_event_orange + } + } + return when (lesson.status) { + WidgetLessonStatus.CANCELLED -> R.drawable.widget_lesson_block_cancelled + WidgetLessonStatus.IRREGULAR -> R.drawable.widget_lesson_block_irregular + WidgetLessonStatus.TEACHER_CHANGED -> R.drawable.widget_lesson_block_teacher_changed + WidgetLessonStatus.PAST -> R.drawable.widget_lesson_block_past + WidgetLessonStatus.EVENT -> R.drawable.widget_lesson_block_event_orange + WidgetLessonStatus.ONGOING -> R.drawable.widget_lesson_block_ongoing + WidgetLessonStatus.REGULAR -> R.drawable.widget_lesson_block_regular + } + } + + private fun subjectLabel(lesson: WidgetLesson): String { + return lesson.subjectShort.ifEmpty { lesson.subjectLong ?: "—" } + } + + private fun roomLabel(lesson: WidgetLesson): String? = lesson.room + + private fun teacherLabel(lesson: WidgetLesson): String? = + lesson.teacher ?: lesson.originalTeacher + + private fun dayLabel(context: Context, anchor: Date): String { + val today = WidgetDateUtils.startOfDay(Date()) + val tomorrow = Calendar.getInstance().apply { + time = today + add(Calendar.DAY_OF_YEAR, 1) + }.time + val anchorStart = WidgetDateUtils.startOfDay(anchor) + return when { + anchorStart == today -> context.getString(R.string.widget_today) + anchorStart == tomorrow -> context.getString(R.string.widget_tomorrow) + else -> SimpleDateFormat("EEEE", Locale.GERMAN).format(anchor) + } + } + + private fun freshnessLabel(context: Context, fetchedAt: Date): String { + val today = WidgetDateUtils.startOfDay(Date()) + val fetchedDay = WidgetDateUtils.startOfDay(fetchedAt) + val yesterday = Calendar.getInstance().apply { + time = today + add(Calendar.DAY_OF_YEAR, -1) + }.time + val yesterdayPrefix = context.getString(R.string.widget_yesterday_prefix) + return when (fetchedDay) { + today -> timeFormat.format(fetchedAt) + yesterday -> "$yesterdayPrefix ${timeFormat.format(fetchedAt)}" + else -> dateTimeShort.format(fetchedAt) + } + } + + private fun buildPlaceholder( + context: Context, + packageName: String, + message: String, + palette: WidgetPalette, + ): RemoteViews { + val views = RemoteViews(packageName, R.layout.widget_placeholder) + applyChrome(views, palette, null) + views.setTextColor(R.id.widget_placeholder_title, palette.textPrimary) + views.setTextColor(R.id.widget_placeholder_message, palette.textSecondary) + views.setTextViewText( + R.id.widget_placeholder_title, + context.getString(R.string.widget_placeholder_title), + ) + views.setTextViewText(R.id.widget_placeholder_message, message) + views.setOnClickPendingIntent( + R.id.widget_placeholder_message, + openAppIntent(context), + ) + return views + } + + private fun openAppIntent(context: Context): PendingIntent { + // ACTION_MAIN + LAUNCHER mirrors a launcher tap; the boolean extra + // is consumed by Dart via WidgetNavigation to route to the timetable. + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + putExtra("widget_open_timetable", true) + } + return PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun sharedPrefs(context: Context): SharedPreferences { + return context.getSharedPreferences( + "HomeWidgetPreferences", + Context.MODE_PRIVATE, + ) + } + + const val KEY_DAY_DATA = "widget_data_day_v1" + const val KEY_WEEK_DATA = "widget_data_week_v1" + const val KEY_LOGGED_IN = "widget_data_logged_in_v1" + const val KEY_THEME_MODE = "widget_setting_theme_mode_v1" +} diff --git a/android/app/src/main/res/drawable/app_widget_background.xml b/android/app/src/main/res/drawable/app_widget_background.xml new file mode 100644 index 0000000..4927caa --- /dev/null +++ b/android/app/src/main/res/drawable/app_widget_background.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/marianum_m_watermark.xml b/android/app/src/main/res/drawable/marianum_m_watermark.xml new file mode 100644 index 0000000..20756fa --- /dev/null +++ b/android/app/src/main/res/drawable/marianum_m_watermark.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_grid_line.xml b/android/app/src/main/res/drawable/widget_grid_line.xml new file mode 100644 index 0000000..44d3e41 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_grid_line.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml b/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml new file mode 100644 index 0000000..7427de0 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml new file mode 100644 index 0000000..f50c66a --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml new file mode 100644 index 0000000..bedb7ac --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml new file mode 100644 index 0000000..1cbb295 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml new file mode 100644 index 0000000..9576750 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml b/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml new file mode 100644 index 0000000..b9bfdc5 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml b/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml new file mode 100644 index 0000000..2bdab1c --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_past.xml b/android/app/src/main/res/drawable/widget_lesson_block_past.xml new file mode 100644 index 0000000..b25ab7c --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_past.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_regular.xml b/android/app/src/main/res/drawable/widget_lesson_block_regular.xml new file mode 100644 index 0000000..ca78a54 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_regular.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml b/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml new file mode 100644 index 0000000..58cb161 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml new file mode 100644 index 0000000..d396613 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml @@ -0,0 +1,11 @@ + + + + diff --git a/android/app/src/main/res/drawable/widget_now_indicator.xml b/android/app/src/main/res/drawable/widget_now_indicator.xml new file mode 100644 index 0000000..48a7f67 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_now_indicator.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android/app/src/main/res/layout/widget_break_block.xml b/android/app/src/main/res/layout/widget_break_block.xml new file mode 100644 index 0000000..8cc7900 --- /dev/null +++ b/android/app/src/main/res/layout/widget_break_block.xml @@ -0,0 +1,8 @@ + + diff --git a/android/app/src/main/res/layout/widget_day.xml b/android/app/src/main/res/layout/widget_day.xml new file mode 100644 index 0000000..841d14e --- /dev/null +++ b/android/app/src/main/res/layout/widget_day.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_day_preview.xml b/android/app/src/main/res/layout/widget_day_preview.xml new file mode 100644 index 0000000..1fc7a63 --- /dev/null +++ b/android/app/src/main/res/layout/widget_day_preview.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_grid_line.xml b/android/app/src/main/res/layout/widget_grid_line.xml new file mode 100644 index 0000000..001015c --- /dev/null +++ b/android/app/src/main/res/layout/widget_grid_line.xml @@ -0,0 +1,7 @@ + + diff --git a/android/app/src/main/res/layout/widget_lesson_block.xml b/android/app/src/main/res/layout/widget_lesson_block.xml new file mode 100644 index 0000000..8cdbb39 --- /dev/null +++ b/android/app/src/main/res/layout/widget_lesson_block.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_now_indicator.xml b/android/app/src/main/res/layout/widget_now_indicator.xml new file mode 100644 index 0000000..02f77de --- /dev/null +++ b/android/app/src/main/res/layout/widget_now_indicator.xml @@ -0,0 +1,7 @@ + + diff --git a/android/app/src/main/res/layout/widget_placeholder.xml b/android/app/src/main/res/layout/widget_placeholder.xml new file mode 100644 index 0000000..35b4763 --- /dev/null +++ b/android/app/src/main/res/layout/widget_placeholder.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_time_label.xml b/android/app/src/main/res/layout/widget_time_label.xml new file mode 100644 index 0000000..43ac8bf --- /dev/null +++ b/android/app/src/main/res/layout/widget_time_label.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/widget_week.xml b/android/app/src/main/res/layout/widget_week.xml new file mode 100644 index 0000000..da2babb --- /dev/null +++ b/android/app/src/main/res/layout/widget_week.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_week_day_header.xml b/android/app/src/main/res/layout/widget_week_day_header.xml new file mode 100644 index 0000000..2d9e89b --- /dev/null +++ b/android/app/src/main/res/layout/widget_week_day_header.xml @@ -0,0 +1,28 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/widget_week_preview.xml b/android/app/src/main/res/layout/widget_week_preview.xml new file mode 100644 index 0000000..821eaaf --- /dev/null +++ b/android/app/src/main/res/layout/widget_week_preview.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/widget_colors.xml b/android/app/src/main/res/values-night/widget_colors.xml new file mode 100644 index 0000000..d1856c7 --- /dev/null +++ b/android/app/src/main/res/values-night/widget_colors.xml @@ -0,0 +1,7 @@ + + + #FF1F1716 + #FFF1F1F1 + #FFB0B0B0 + #33FFFFFF + 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..7d0ae4c --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ + + + Marianum · Heute + Marianum · Woche + Stundenplan und Vertretungen für den anstehenden Schultag. + Stundenplan und Vertretungen für die ganze Schulwoche. + Keine Stunden + Ferien + Bitte einloggen, um den Stundenplan zu laden + Lade… + Stand: %1$s + Heute + Morgen + Marianum Stundenplan + KW + gestern + diff --git a/android/app/src/main/res/values/widget_colors.xml b/android/app/src/main/res/values/widget_colors.xml new file mode 100644 index 0000000..db5e003 --- /dev/null +++ b/android/app/src/main/res/values/widget_colors.xml @@ -0,0 +1,17 @@ + + + + #FF993333 + #FFC83333 + #FF993333 + #FF000000 + #FF8F19B3 + #FF29639B + #FF2E7D32 + + #FFFCF7F5 + #FF111111 + #FF555555 + #22000000 + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..9975e1b --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..76e91c7 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/xml/timetable_day_widget_info.xml b/android/app/src/main/res/xml/timetable_day_widget_info.xml new file mode 100644 index 0000000..81c630c --- /dev/null +++ b/android/app/src/main/res/xml/timetable_day_widget_info.xml @@ -0,0 +1,12 @@ + + diff --git a/android/app/src/main/res/xml/timetable_week_widget_info.xml b/android/app/src/main/res/xml/timetable_week_widget_info.xml new file mode 100644 index 0000000..8673570 --- /dev/null +++ b/android/app/src/main/res/xml/timetable_week_widget_info.xml @@ -0,0 +1,12 @@ + + diff --git a/android/build.gradle b/android/build.gradle index bc157bd..17b32f3 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -9,6 +9,29 @@ rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } +// Pin every Android subproject to JVM 17 so plugins that ship Kotlin sources +// compiled with a higher target (e.g. receive_sharing_intent at 21) or stale +// Java compatibility (e.g. home_widget at 1.8) don't break the build under +// newer Gradle/Kotlin tooling. Registered before evaluationDependsOn so the +// afterEvaluate fires at the right point in the lifecycle. +subprojects { sub -> + sub.afterEvaluate { + if (sub.hasProperty('android')) { + sub.android { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + } + sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = '17' + } + } + } +} + subprojects { project.evaluationDependsOn(':app') } diff --git a/assets/img/marianum_m_white.svg b/assets/img/marianum_m_white.svg new file mode 100644 index 0000000..b7c0c85 --- /dev/null +++ b/assets/img/marianum_m_white.svg @@ -0,0 +1,21 @@ + + + + diff --git a/ios/Podfile b/ios/Podfile index b34a8eb..a603498 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -34,6 +34,10 @@ target 'Runner' do pod 'PhoneNumberKit', '~> 3.7.6' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + target 'Share Extension' do + inherit! :search_paths + end # target 'RunnerTests' do # inherit! :search_paths # end diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ccbfe9c..ad33a62 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,19 @@ + AppGroupId + $(CUSTOM_GROUP_ID) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + + CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 903def2..711c2c5 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,5 +4,10 @@ aps-environment development + com.apple.security.application-groups + + group.eu.mhsl.marianum.mobile.client.widget + group.eu.mhsl.marianum.mobile.client.share + diff --git a/ios/Share Extension/Info.plist b/ios/Share Extension/Info.plist new file mode 100644 index 0000000..627a72d --- /dev/null +++ b/ios/Share Extension/Info.plist @@ -0,0 +1,54 @@ + + + + + AppGroupId + $(CUSTOM_GROUP_ID) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Marianum Fulda + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionAttributes + + PHSupportedMediaTypes + + Video + Image + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsImageWithMaxCount + 10 + NSExtensionActivationSupportsMovieWithMaxCount + 10 + NSExtensionActivationSupportsFileWithMaxCount + 10 + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/ios/Share Extension/MainInterface.storyboard b/ios/Share Extension/MainInterface.storyboard new file mode 100644 index 0000000..1746985 --- /dev/null +++ b/ios/Share Extension/MainInterface.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Share Extension/SETUP.md b/ios/Share Extension/SETUP.md new file mode 100644 index 0000000..6e9031e --- /dev/null +++ b/ios/Share Extension/SETUP.md @@ -0,0 +1,93 @@ +# iOS Share Extension — Xcode Setup + +Die Quellen unter `ios/Share Extension/` müssen einmalig in Xcode als **Share Extension Target** verdrahtet werden — analog zur `TimetableWidgetExtension`. Erst danach taucht „Marianum Fulda" im System-Share-Sheet auf. + +## Schritt 1 — Share-Extension-Target anlegen + +1. `ios/Runner.xcworkspace` in Xcode öffnen. +2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links. +3. **iOS → Share Extension** wählen. +4. Eigenschaften: + - Product Name: `Share Extension` (mit Leerzeichen, exakt so — der Ordnername und Podfile-Eintrag matchen). + - Bundle Identifier: `eu.mhsl.marianum.mobile.client.Share-Extension`. + - Language: Swift. + - Embed in: Runner. +5. Beim Activate-Scheme-Dialog auf **Cancel** klicken. +6. Deployment Target = mind. iOS 12.0 (Plugin-Mindestanforderung). + +## Schritt 2 — Vorhandene Quelldateien ins Target ziehen + +Xcode legt Dummy-Dateien an. Diese **löschen** (Move to Trash). Dann: + +1. Sidebar → Rechtsklick auf den Ordner `Share Extension` → **Add Files to "Runner"…** +2. Im File-Picker zu `ios/Share Extension/` navigieren und folgende Dateien selektieren: + - `ShareViewController.swift` + - `Info.plist` + - `MainInterface.storyboard` + - `Share Extension.entitlements` +3. **Wichtig**: bei „Add to targets" nur `Share Extension` ankreuzen, **nicht** Runner. + +## Schritt 3 — App Group aktivieren + +Beide Targets brauchen die App-Group-Berechtigung, damit die Extension geteilte Dateien für die Hauptapp im gemeinsamen Container ablegen kann. + +1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**. + - Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.share` (zusätzlich zur bereits existierenden Widget-Group). +2. Dasselbe für **Share Extension**-Target — mit derselben Group-ID `group.eu.mhsl.marianum.mobile.client.share`. + +Im Apple-Developer-Portal muss diese App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl. + +## Schritt 4 — User-Defined Build Setting `CUSTOM_GROUP_ID` + +Beide Targets brauchen das User-Defined Setting, das in `Runner/Info.plist` und `Share Extension/Info.plist` als `$(CUSTOM_GROUP_ID)` referenziert wird. + +1. **Runner** → Build Settings → `+` (oben links) → **Add User-Defined Setting**. + - Name: `CUSTOM_GROUP_ID` + - Wert: `group.eu.mhsl.marianum.mobile.client.share` +2. Dasselbe für **Share Extension**-Target. + +## Schritt 5 — Entitlements verlinken + +1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` zeigt bereits auf `Runner/Runner.entitlements` (jetzt mit beiden Groups). +2. **Share Extension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `Share Extension/Share Extension.entitlements` setzen. + +## Schritt 6 — Info.plist-Pfad + +**Share Extension** → Build Settings → `INFOPLIST_FILE` → auf `Share Extension/Info.plist` setzen. + +## Schritt 7 — Build Phases reorder + +Damit das Plugin-Modul vom Extension-Target gefunden wird: + +1. **Runner**-Target → **Build Phases**. +2. `Embed Foundation Extensions` per Drag-and-Drop **vor** `Thin Binary` ziehen. + +## Schritt 8 — Pods installieren + +```bash +cd ios && pod install +``` + +Der Podfile-Eintrag (`target 'Share Extension' do inherit! :search_paths end`) ist bereits vorhanden. + +## Schritt 9 — Build & Run + +1. Scheme `Runner` wählen → Run auf Device oder Simulator (≥ iOS 12). +2. Foto in der Fotos-App auswählen → Teilen → „Marianum Fulda" sollte erscheinen. +3. Auswahl → App öffnet sich, ShareTargetPage erscheint. + +## Troubleshooting + +- **Error: No such module 'receive_sharing_intent'** + → Schritt 7 (Build Phases reorder) wurde übersprungen. +- **Error: ‚Frameworks' not allowed in extension** + → In Build Settings der Share Extension `Other Linker Flags` und `Framework Search Paths` leeren (nur die geerbten Pod-Pfade behalten). +- **Share-Sheet zeigt App nicht an** + → `NSExtensionActivationRule`-Limits in `Share Extension/Info.plist` zu klein? Werte testweise erhöhen. Außerdem: App muss **mindestens einmal nach Install** geöffnet worden sein, sonst wird die Extension von iOS nicht registriert. +- **Files kommen mit `nil` Pfad an** + → App-Group nicht konsistent. Prüfen, dass `CUSTOM_GROUP_ID` in beiden Targets identisch ist und die Entitlement-Files dieselbe Group enthalten. + +## Was am Mac noch zu tun ist + +- Schritte 1–8 oben (~15 Min). +- Auf physischem iPhone testen — Simulator-Share-Sheet ist eingeschränkt. diff --git a/ios/Share Extension/Share Extension.entitlements b/ios/Share Extension/Share Extension.entitlements new file mode 100644 index 0000000..80e2a27 --- /dev/null +++ b/ios/Share Extension/Share Extension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.eu.mhsl.marianum.mobile.client.share + + + diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift new file mode 100644 index 0000000..74b3416 --- /dev/null +++ b/ios/Share Extension/ShareViewController.swift @@ -0,0 +1,8 @@ +import UIKit +import receive_sharing_intent + +class ShareViewController: RSIShareViewController { + override func shouldAutoRedirect() -> Bool { + return true + } +} diff --git a/ios/TimetableWidgetExtension/Assets.xcassets/Contents.json b/ios/TimetableWidgetExtension/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/TimetableWidgetExtension/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/Contents.json b/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/Contents.json new file mode 100644 index 0000000..ad043b9 --- /dev/null +++ b/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "marianum_m_white.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/marianum_m_white.svg b/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/marianum_m_white.svg new file mode 100644 index 0000000..b7c0c85 --- /dev/null +++ b/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/marianum_m_white.svg @@ -0,0 +1,21 @@ + + + + diff --git a/ios/TimetableWidgetExtension/Info.plist b/ios/TimetableWidgetExtension/Info.plist new file mode 100644 index 0000000..c59485a --- /dev/null +++ b/ios/TimetableWidgetExtension/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Marianum Stundenplan + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/TimetableWidgetExtension/MarianumWatermark.swift b/ios/TimetableWidgetExtension/MarianumWatermark.swift new file mode 100644 index 0000000..ad6e247 --- /dev/null +++ b/ios/TimetableWidgetExtension/MarianumWatermark.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// Marianum-M peeking out of the bottom-right corner. Sized to the longer +/// widget edge so it scales with resize; offset nudges a sliver behind the +/// edge. +struct MarianumWatermark: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + GeometryReader { geo in + let markSize = min(400, max(160, max(geo.size.width, geo.size.height) * 0.8)) + let offsetX = markSize * 0.18 + let offsetY = markSize * 0.18 + ZStack(alignment: .bottomTrailing) { + Color.clear + Image("marianum_m") + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .foregroundStyle(.primary) + .frame(width: markSize, height: markSize) + .opacity(colorScheme == .dark ? 0.025 : 0.014) + .offset(x: offsetX, y: offsetY) + } + } + .clipped() + } +} diff --git a/ios/TimetableWidgetExtension/SETUP.md b/ios/TimetableWidgetExtension/SETUP.md new file mode 100644 index 0000000..bcb46c4 --- /dev/null +++ b/ios/TimetableWidgetExtension/SETUP.md @@ -0,0 +1,72 @@ +# iOS Widget Extension — Xcode Setup + +Die Swift-Quellen unter `ios/TimetableWidgetExtension/` müssen einmalig in Xcode als **Widget Extension Target** verdrahtet werden — ohne diesen Schritt bleibt der Code unkompiliert. + +## Schritt 1 — Widget-Extension-Target anlegen + +1. `ios/Runner.xcworkspace` in Xcode öffnen. +2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links. +3. **iOS → Widget Extension** wählen. +4. Eigenschaften: + - Product Name: `TimetableWidgetExtension` + - Bundle Identifier: `eu.mhsl.marianum.mobile.client.TimetableWidgetExtension` + - Language: Swift + - Include Configuration Intent: **OFF** (StaticConfiguration reicht) + - Embed in: Runner +5. Beim Activate-Scheme-Dialog auf **Cancel** klicken. + +## Schritt 2 — Vorhandene Quelldateien ins Target ziehen + +Xcode hat zunächst Dummy-Dateien (`TimetableWidgetExtension.swift`, `TimetableWidgetExtensionBundle.swift`) angelegt. Diese **löschen** (Move to Trash). Dann: + +1. Sidebar → Rechtsklick auf den Ordner `TimetableWidgetExtension` → **Add Files to "Runner"…** +2. Im File-Picker zu `ios/TimetableWidgetExtension/` navigieren und alle `.swift`-Dateien, die `Info.plist`, `TimetableWidgetExtension.entitlements` **und den `Assets.xcassets`-Ordner** selektieren (mit `marianum_m`-Asset darin — gleicher Asset-Name wie auf Android-Seite). +3. **Wichtig**: bei „Add to targets" nur `TimetableWidgetExtension` ankreuzen, **nicht** Runner. + +## Schritt 3 — App Group aktivieren + +Beide Targets brauchen die App-Group-Berechtigung, damit Hauptapp und Widget über `UserDefaults(suiteName:)` schreiben/lesen können. + +1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**. + - Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.widget` +2. Dasselbe für **TimetableWidgetExtension** — mit derselben Group-ID. + +Im Apple-Developer-Portal muss die App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl. + +## Schritt 4 — Entitlements verlinken + +1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` sollte bereits auf `Runner/Runner.entitlements` zeigen. +2. **TimetableWidgetExtension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `TimetableWidgetExtension/TimetableWidgetExtension.entitlements` setzen. + +## Schritt 5 — Info.plist + Deployment Target + +1. **TimetableWidgetExtension** → Build Settings → `INFOPLIST_FILE` → auf `TimetableWidgetExtension/Info.plist` setzen. +2. Build Settings → `IPHONEOS_DEPLOYMENT_TARGET` ≥ 16.0 (Code gated `.containerBackground` mit `if #available(iOS 17, *)`, läuft also auch auf 16). + +## Schritt 6 — Build & Run + +- Scheme `Runner` (nicht das Widget-Scheme) wählen → Run. +- Auf Home-Screen langes Drücken → Widget hinzufügen → "Marianum · Heute" / "Marianum · Woche". +- Widget-Tap öffnet die App im zuletzt sichtbaren Tab. Eine Tab-Navigation auf den Stundenplan ist bewusst nicht implementiert (Android nutzt Intent-Extras, iOS würde dafür ein URL-Scheme oder AppIntent brauchen — beides bewusst ausgespart). + +## Troubleshooting + +- **Widget zeigt „Lade…"** auch nach Refresh: App-Group greift nicht. Prüfen, ob beide Targets dieselbe Group-ID haben und das Provisioning aktualisiert wurde. +- **Stale-Daten nach Logout**: `WidgetSync.clear()` schreibt `widget_data_logged_in_v1 = false`; Widget zeigt dann den Login-Placeholder. +- **Lessons um 1–2 Stunden verschoben**: Date-Parser-Bug. Sollte gefixt sein in `WidgetData.swift::parseDartDate` — verifizieren, dass die ISO-8601-Strings ohne Z-Suffix als `TimeZone.current` geparsed werden. +- **App-Store-Submit später**: `Runner.entitlements` `aps-environment` von `development` auf `production` umbiegen. + +## Was bereits im Repo erledigt ist + +- Alle Swift-Quellen, Info.plist, Entitlements liegen unter `ios/TimetableWidgetExtension/`. +- App-Group-ID konsistent zwischen Dart (`WidgetSync.iosAppGroupId`), Swift (`WidgetDataKey.appGroupId`) und der Entitlements-Datei. +- `home_widget`-Plugin auf der Dart-Seite konfiguriert; ruft `HomeWidget.setAppGroupId` beim ersten Sync. +- `containerBackground` für iOS 17+ gegated, fällt auf iOS 16 sauber zurück. +- Date-Parser fixt das fehlende Z-Suffix (Dart schreibt lokale Zeit ohne TZ-Marker). + +## Was am Mac noch zu tun ist + +- Schritte 1–5 oben in Xcode durchklicken (10–15 Min). +- `flutter pub get` + `cd ios && pod install`. +- Auf physischem Gerät oder iOS-Simulator (≥ 16.0) bauen. +- Widget aufs Home-Screen ziehen, prüfen dass Lesson-Zeiten korrekt rendern. diff --git a/ios/TimetableWidgetExtension/TimetableDayView.swift b/ios/TimetableWidgetExtension/TimetableDayView.swift new file mode 100644 index 0000000..6ece9f7 --- /dev/null +++ b/ios/TimetableWidgetExtension/TimetableDayView.swift @@ -0,0 +1,451 @@ +import SwiftUI +import WidgetKit + +// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise +// the platforms drift apart on the same widget size. +let FALLBACK_VIRTUAL_MINUTES = 11 * 60 +let MIN_HOUR_HEIGHT: CGFloat = 18 +let MAX_HOUR_HEIGHT: CGFloat = 72 +let MIN_BLOCK_HEIGHT: CGFloat = 16 +let LESSON_GAP: CGFloat = 1.5 + +func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat { + guard !periods.isEmpty else { return CGFloat(realMin) } + for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes { + return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes)) + } + let first = periods.first! + if realMin < first.startMinutes { + return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes) + } + let last = periods.last! + if realMin > last.endMinutes { + return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes)) + } + var prev = first + for i in 1.. prev.endMinutes && realMin < curr.startMinutes { + let gap = curr.startMinutes - prev.endMinutes + let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes + if gap > 0 { + return CGFloat(prev.virtualEndMinutes) + + CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap) + } + return CGFloat(curr.virtualStartMinutes) + } + prev = curr + } + return 0 +} + +let BLOCK_SHOW_ROOM_MIN: CGFloat = 18 +let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 30 + +let MIN_SUBJECT_FONT: CGFloat = 9 +let MAX_SUBJECT_FONT: CGFloat = 14 +let MIN_SECONDARY_FONT: CGFloat = 7 + +func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat { + let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT))) + return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT) +} + +struct TimetableDayView: View { + let entry: TimetableEntry + + var body: some View { + ZStack { + if !entry.isLoggedIn { + placeholder("Bitte einloggen, um den Stundenplan zu laden") + } else if let data = entry.data { + content(data: data) + } else { + placeholder("Lade…") + } + } + .background(MarianumWatermark()) + .widgetThemeOverride(entry.themeMode) + } + + @ViewBuilder + private func content(data: WidgetTimetableData) -> some View { + VStack(alignment: .leading, spacing: 6) { + header(data: data) + if data.isHoliday { + emptyState(text: data.holidayName ?? "Ferien") + } else if data.lessons.isEmpty { + emptyState(text: "Keine Stunden") + } else { + GeometryReader { geo in + let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) + TimeGridView( + lessons: data.lessons, + periods: data.periods, + anchorDate: data.anchorDate, + hourHeight: max( + MIN_HOUR_HEIGHT, + min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) + ), + showRoom: true, + showTeacher: true, + showTimeLabels: true + ) + } + } + } + } + + private func header(data: WidgetTimetableData) -> some View { + HStack { + Text(dayLabel(for: data.anchorDate)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + Text("Stand: \(freshnessLabel(for: data.fetchedAt))") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + + private func emptyState(text: String) -> some View { + VStack { + Spacer() + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func placeholder(_ message: String) -> some View { + VStack(spacing: 4) { + Text("Marianum Stundenplan") + .font(.system(size: 14, weight: .semibold)) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct TimeGridView: View { + let lessons: [WidgetLesson] + let periods: [WidgetPeriod] + let anchorDate: Date + let hourHeight: CGFloat + let showRoom: Bool + let showTeacher: Bool + let showTimeLabels: Bool + /// Week-widget passes 3 for narrow columns; day-widget keeps 7. + var horizontalPadding: CGFloat = 7 + + private var totalVirtualMinutes: Int { + periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES + } + + private var totalHeight: CGFloat { + CGFloat(totalVirtualMinutes) * hourHeight / 60.0 + } + + /// Below this per-hour height the two-line label collapses to a single + /// period number — time + number overlap otherwise. + private var compactLabels: Bool { hourHeight < 26 } + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if showTimeLabels { + timeLabelsColumn + .frame(width: 32, alignment: .topTrailing) + Rectangle() + .fill(Color.primary.opacity(0.13)) + .frame(width: 1) + } + ZStack(alignment: .top) { + gridLines + breakBlocks + ForEach(lessons.indices, id: \.self) { idx in + lessonBlock(lessons[idx]) + } + if Calendar.current.isDate(anchorDate, inSameDayAs: Date()) { + nowIndicator + } + } + .frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top) + } + } + + private var timeLabelsColumn: some View { + ZStack(alignment: .topTrailing) { + // Hour rules continue through the time-label column so it reads + // as a real table column rather than a free-floating tick list. + // Hour rules extend through the time-label column so it reads + // as a table column rather than a free-floating tick list. + ForEach(periodBoundaries(periods), id: \.self) { virtualMin in + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(height: 1) + .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) + } + ForEach(periods, id: \.startMinutes) { period in + VStack(alignment: .trailing, spacing: -2) { + if compactLabels { + Text("\(period.name).") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + } else { + Text(formatHm(period.startMinutes)) + .font(.system(size: 9)) + .foregroundStyle(.primary) + .lineLimit(1) + Text("\(period.name).") + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.trailing, 4) + .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) + } + } + .frame(height: totalHeight, alignment: .topTrailing) + } + + private var gridLines: some View { + ZStack(alignment: .top) { + // Hour rules continue through the time-label column so it reads + // as a real table column rather than a free-floating tick list. + ForEach(periodBoundaries(periods), id: \.self) { virtualMin in + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(height: 1) + .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) + } + } + .frame(height: totalHeight) + } + + private var breakBlocks: some View { + ZStack(alignment: .top) { + ForEach(0.. 0 { + Rectangle() + .fill(Color.primary.opacity(0.03)) + .frame(height: CGFloat(virtualGap) * hourHeight / 60.0) + .padding(.horizontal, 1) + .offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0) + } + } + } + .frame(height: totalHeight) + } + + private func formatHm(_ minutes: Int) -> String { + String(format: "%02d:%02d", minutes / 60, minutes % 60) + } + + @ViewBuilder + private func lessonBlock(_ lesson: WidgetLesson) -> some View { + let cal = Calendar.current + let comps = cal.dateComponents([.hour, .minute], from: lesson.start) + let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60)) + let virtualStart = realMinutesToVirtual(startMinutes, periods: periods) + let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods) + + if virtualEnd > virtualStart { + let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 + let height = max( + MIN_BLOCK_HEIGHT, + (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP + ) + let subjectSize = subjectFont(forHourHeight: hourHeight) + let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2) + let room = lesson.room + let teacher = lesson.teacher ?? lesson.originalTeacher + let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false) + HStack(alignment: .top, spacing: 4) { + Text(subjectLabel(lesson)) + .font(.system(size: subjectSize, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(hasSecondary ? 1 : 2) + .minimumScaleFactor(0.5) + if hasSecondary { + Spacer(minLength: 0) + VStack(alignment: .trailing, spacing: -1) { + if showRoom && height >= BLOCK_SHOW_ROOM_MIN { + if let room, !room.isEmpty { + Text(room) + .font(.system(size: secondarySize)) + .foregroundStyle(.white.opacity(0.85)) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + if showTeacher, + height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN, + let teacher, + !teacher.isEmpty { + Text(teacher) + .font(.system(size: secondarySize)) + .foregroundStyle(.white.opacity(0.7)) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + } + } + } + } + .padding(.horizontal, horizontalPadding) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .topLeading) + .frame(height: height, alignment: .topLeading) + .background(blockColor(lesson)) + .cornerRadius(6) + .overlay(alignment: .bottomLeading) { + // Separate fixed-size badge so the +N hint stays readable + // when the subject autoshrinks on narrow tiles. + if let count = lesson.siblingCount, count > 0 { + Text("+\(count)") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .padding(.leading, horizontalPadding) + .padding(.bottom, 2) + } + } + .overlay { + // CrossPainter parity: clip cross to the rounded shape so + // the diagonals don't bleed past the corners. + if lesson.status == .cancelled { + ZStack { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.red.opacity(0.78), lineWidth: 1.5) + GeometryReader { geo in + Path { p in + p.move(to: .zero) + p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) + p.move(to: CGPoint(x: geo.size.width, y: 0)) + p.addLine(to: CGPoint(x: 0, y: geo.size.height)) + } + .stroke(Color.red.opacity(0.78), lineWidth: 3) + } + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.horizontal, showRoom ? 2 : 1) + .offset(y: top) + } + } + + private var nowIndicator: some View { + let cal = Calendar.current + let comps = cal.dateComponents([.hour, .minute], from: Date()) + let nowMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + let inside: Bool + if let first = periods.first, let last = periods.last { + inside = nowMinutes >= first.startMinutes && nowMinutes <= last.endMinutes + } else { + inside = true + } + let top = realMinutesToVirtual(nowMinutes, periods: periods) * hourHeight / 60.0 + return Group { + if inside { + Rectangle() + .fill(Color.red) + .frame(height: 2) + .offset(y: top) + } + } + } + + private func subjectLabel(_ lesson: WidgetLesson) -> String { + !lesson.subjectShort.isEmpty + ? lesson.subjectShort + : (lesson.subjectLong ?? "—") + } + + /// Mirrors lesson_color.dart + custom_event_colors.dart so the widget + /// matches the in-app calendar exactly. + private func blockColor(_ lesson: WidgetLesson) -> Color { + if lesson.status == .event, let custom = lesson.customColor { + switch custom { + case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) + case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) + case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0) + case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0) + default: break + } + } + switch lesson.status { + case .regular, .past: return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) + case .ongoing: return Color(red: 200/255.0, green: 51/255.0, blue: 51/255.0) + case .cancelled: return .black + case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0) + case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0) + case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) + } + } +} + +/// Period boundaries deduped: adjacent periods share a line, periods on +/// either side of a break get their own (bracketing the break block). +func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { + var seen = Set() + var result: [Int] = [] + for p in periods { + for v in [p.virtualStartMinutes, p.virtualEndMinutes] { + if seen.insert(v).inserted { result.append(v) } + } + } + return result.sorted() +} + +func dayLabel(for date: Date) -> String { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + let anchor = cal.startOfDay(for: date) + if anchor == today { + return "Heute · \(shortDate(date))" + } + if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow { + return "Morgen · \(shortDate(date))" + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "de_DE") + formatter.dateFormat = "EEEE · dd.MM." + return formatter.string(from: date) +} + +func shortDate(_ date: Date) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateFormat = "dd.MM." + return f.string(from: date) +} + +func freshnessLabel(for fetchedAt: Date) -> String { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + let fetchedDay = cal.startOfDay(for: fetchedAt) + let timeFmt = DateFormatter() + timeFmt.locale = Locale(identifier: "de_DE") + timeFmt.dateFormat = "HH:mm" + if fetchedDay == today { + return timeFmt.string(from: fetchedAt) + } + if let yesterday = cal.date(byAdding: .day, value: -1, to: today), + fetchedDay == yesterday { + return "gestern \(timeFmt.string(from: fetchedAt))" + } + let dateTimeFmt = DateFormatter() + dateTimeFmt.locale = Locale(identifier: "de_DE") + dateTimeFmt.dateFormat = "dd.MM. HH:mm" + return dateTimeFmt.string(from: fetchedAt) +} diff --git a/ios/TimetableWidgetExtension/TimetableWeekView.swift b/ios/TimetableWidgetExtension/TimetableWeekView.swift new file mode 100644 index 0000000..6173097 --- /dev/null +++ b/ios/TimetableWidgetExtension/TimetableWeekView.swift @@ -0,0 +1,161 @@ +import SwiftUI +import WidgetKit + +struct TimetableWeekView: View { + let entry: TimetableEntry + + var body: some View { + ZStack { + if !entry.isLoggedIn { + placeholder("Bitte einloggen, um den Stundenplan zu laden") + } else if let data = entry.data { + content(data: data) + } else { + placeholder("Lade…") + } + } + .background(MarianumWatermark()) + .widgetThemeOverride(entry.themeMode) + } + + @ViewBuilder + private func content(data: WidgetTimetableData) -> some View { + VStack(alignment: .leading, spacing: 4) { + header(data: data) + dayHeaderRow(data: data) + GeometryReader { geo in + let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) + let hourHeight = max( + MIN_HOUR_HEIGHT, + min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) + ) + let dayColumnWidth = (geo.size.width - 28 - 4) / 5 + let subjectOnly = dayColumnWidth < 70 + HStack(alignment: .top, spacing: 0) { + timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) + .frame(width: 28, alignment: .topTrailing) + columnDivider + ForEach(0..<5, id: \.self) { offset in + column( + data: data, + offset: offset, + hourHeight: hourHeight, + subjectOnly: subjectOnly + ) + .frame(maxWidth: .infinity) + if offset < 4 { columnDivider } + } + } + } + } + } + + private var columnDivider: some View { + Rectangle() + .fill(Color.primary.opacity(0.13)) + .frame(width: 1) + } + + private func header(data: WidgetTimetableData) -> some View { + let cal = Calendar.current + let week = cal.component(.weekOfYear, from: data.anchorDate) + let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate + return HStack { + Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + Text("Stand: \(freshnessLabel(for: data.fetchedAt))") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + + private func dayHeaderRow(data: WidgetTimetableData) -> some View { + let cal = Calendar.current + return HStack(spacing: 0) { + Spacer().frame(width: 28) + columnDivider + ForEach(0..<5, id: \.self) { offset in + let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate + VStack(spacing: 0) { + Text(weekday(for: day)) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.primary) + Text(shortDate(day)) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + if offset < 4 { columnDivider } + } + } + } + + private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { + let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES + let totalHeight = CGFloat(totalMin) * hourHeight / 60.0 + return ZStack(alignment: .topTrailing) { + ForEach(periodBoundaries(periods), id: \.self) { virtualMin in + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(height: 1) + .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) + } + ForEach(periods, id: \.startMinutes) { period in + VStack(alignment: .trailing, spacing: -2) { + Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60)) + .font(.system(size: 8)) + .foregroundStyle(.primary) + .lineLimit(1) + Text("\(period.name).") + .font(.system(size: 6, weight: .bold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) + } + } + .frame(height: totalHeight, alignment: .topTrailing) + } + + private func column( + data: WidgetTimetableData, + offset: Int, + hourHeight: CGFloat, + subjectOnly: Bool + ) -> some View { + let cal = Calendar.current + let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate + let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) } + return TimeGridView( + lessons: lessonsForDay, + periods: data.periods, + anchorDate: day, + hourHeight: hourHeight, + showRoom: !subjectOnly, + showTeacher: !subjectOnly, + showTimeLabels: false, + horizontalPadding: 3 + ) + } + + private func weekday(for date: Date) -> String { + let f = DateFormatter() + f.locale = Locale(identifier: "de_DE") + f.dateFormat = "EE" + return f.string(from: date) + } + + private func placeholder(_ message: String) -> some View { + VStack(spacing: 4) { + Text("Marianum Stundenplan") + .font(.system(size: 14, weight: .semibold)) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/ios/TimetableWidgetExtension/TimetableWidgetExtension.entitlements b/ios/TimetableWidgetExtension/TimetableWidgetExtension.entitlements new file mode 100644 index 0000000..cbdd516 --- /dev/null +++ b/ios/TimetableWidgetExtension/TimetableWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.eu.mhsl.marianum.mobile.client.widget + + + diff --git a/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift new file mode 100644 index 0000000..b0540c4 --- /dev/null +++ b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift @@ -0,0 +1,138 @@ +import SwiftUI +import WidgetKit + +@main +struct MarianumWidgetBundle: WidgetBundle { + var body: some Widget { + TimetableDayWidget() + TimetableWeekWidget() + } +} + +// MARK: - Day widget + +struct TimetableDayWidget: Widget { + let kind: String = "TimetableDayWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: TimetableDayProvider()) { entry in + TimetableDayView(entry: entry).widgetContainerBackground() + } + .configurationDisplayName("Marianum · Heute") + .description("Stundenplan und Vertretungen für den anstehenden Schultag.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +struct TimetableDayProvider: TimelineProvider { + func placeholder(in context: Context) -> TimetableEntry { + TimetableEntry.placeholder() + } + + func getSnapshot(in context: Context, completion: @escaping (TimetableEntry) -> Void) { + completion(TimetableEntry.current(variant: .day)) + } + + func getTimeline( + in context: Context, + completion: @escaping (Timeline) -> Void + ) { + let entry = TimetableEntry.current(variant: .day) + // 30 min mirrors the Dart workmanager cadence. iOS treats this as + // advisory; the "Stand:" label tells the user when data is stale. + let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +// MARK: - Week widget + +struct TimetableWeekWidget: Widget { + let kind: String = "TimetableWeekWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: TimetableWeekProvider()) { entry in + TimetableWeekView(entry: entry).widgetContainerBackground() + } + .configurationDisplayName("Marianum · Woche") + .description("Stundenplan und Vertretungen für die ganze Schulwoche.") + .supportedFamilies([.systemMedium, .systemLarge, .systemExtraLarge]) + } +} + +struct TimetableWeekProvider: TimelineProvider { + func placeholder(in context: Context) -> TimetableEntry { + TimetableEntry.placeholder() + } + + func getSnapshot(in context: Context, completion: @escaping (TimetableEntry) -> Void) { + completion(TimetableEntry.current(variant: .week)) + } + + func getTimeline( + in context: Context, + completion: @escaping (Timeline) -> Void + ) { + let entry = TimetableEntry.current(variant: .week) + let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date() + completion(Timeline(entries: [entry], policy: .after(next))) + } +} + +// MARK: - Entry + +enum TimetableVariant { case day, week } + +struct TimetableEntry: TimelineEntry { + let date: Date + let variant: TimetableVariant + let data: WidgetTimetableData? + let isLoggedIn: Bool + let themeMode: String + + static func placeholder() -> TimetableEntry { + TimetableEntry( + date: Date(), + variant: .day, + data: nil, + isLoggedIn: true, + themeMode: "system" + ) + } + + static func current(variant: TimetableVariant) -> TimetableEntry { + let isLoggedIn = WidgetDataLoader.isLoggedIn() + let data = isLoggedIn + ? (variant == .day ? WidgetDataLoader.loadDay() : WidgetDataLoader.loadWeek()) + : nil + return TimetableEntry( + date: Date(), + variant: variant, + data: data, + isLoggedIn: isLoggedIn, + themeMode: WidgetDataLoader.themeMode() + ) + } +} + +extension View { + @ViewBuilder + func widgetThemeOverride(_ mode: String) -> some View { + switch mode { + case "light": self.environment(\.colorScheme, .light) + case "dark": self.environment(\.colorScheme, .dark) + default: self + } + } + + /// `.containerBackground(_:for:)` is iOS 17+. Older iOS uses the + /// implicit `.background(...)` model and renders fine without it. + @ViewBuilder + func widgetContainerBackground() -> some View { + if #available(iOS 17.0, *) { + self.containerBackground(.fill.tertiary, for: .widget) + } else { + self + } + } +} diff --git a/ios/TimetableWidgetExtension/WidgetData.swift b/ios/TimetableWidgetExtension/WidgetData.swift new file mode 100644 index 0000000..49bd54c --- /dev/null +++ b/ios/TimetableWidgetExtension/WidgetData.swift @@ -0,0 +1,128 @@ +import Foundation + +/// Mirrors lib/widget_data/widget_data.dart. JSON keys must stay in sync — +/// the bridge is one-way: Dart writes, Swift reads. +enum WidgetLessonStatus: String, Codable { + case regular + case ongoing + case past + case cancelled + case irregular + case teacherChanged + case event +} + +struct WidgetLesson: Codable { + let start: Date + let end: Date + let subjectShort: String + let subjectLong: String? + let room: String? + let teacher: String? + let originalTeacher: String? + let status: WidgetLessonStatus + let customColor: String? + let siblingCount: Int? +} + +struct WidgetPeriod: Codable { + let name: String + let startMinutes: Int + let endMinutes: Int + let virtualStartMinutes: Int + let virtualEndMinutes: Int +} + +struct WidgetTimetableData: Codable { + let fetchedAt: Date + let anchorDate: Date + let lessons: [WidgetLesson] + let periods: [WidgetPeriod] + let isHoliday: Bool + let holidayName: String? +} + +enum WidgetDataKey { + static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget" + static let dayData = "widget_data_day_v1" + static let weekData = "widget_data_week_v1" + static let loggedIn = "widget_data_logged_in_v1" + static let themeMode = "widget_setting_theme_mode_v1" +} + +enum WidgetDataLoader { + /// Dart's `DateTime.toIso8601String()` on a non-UTC DateTime drops the + /// trailing Z and ships local wall-clock time. ISO8601DateFormatter's + /// default treats that as UTC and shifts every lesson by the local TZ + /// offset — dispatch by suffix instead, mirroring WidgetDataParser.kt. + private static func parseDartDate(_ raw: String) -> Date? { + let hasTzSuffix = raw.hasSuffix("Z") + || raw.range(of: #"[+-]\d{2}:?\d{2}$"#, options: .regularExpression) != nil + if hasTzSuffix { + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds] + if let d = iso.date(from: raw) { return d } + iso.formatOptions = [.withFullDate, .withFullTime] + return iso.date(from: raw) + } + for pattern in [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + ] { + let f = DateFormatter() + f.dateFormat = pattern + f.timeZone = TimeZone.current + f.locale = Locale(identifier: "en_US_POSIX") + if let d = f.date(from: raw) { return d } + } + return nil + } + + private static func decoder() -> JSONDecoder { + let dec = JSONDecoder() + dec.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + if let d = parseDartDate(raw) { return d } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unparseable date: \(raw)" + ) + } + return dec + } + + static func loadDay() -> WidgetTimetableData? { + load(key: WidgetDataKey.dayData) + } + + static func loadWeek() -> WidgetTimetableData? { + load(key: WidgetDataKey.weekData) + } + + static func isLoggedIn() -> Bool { + let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId) + return defaults?.bool(forKey: WidgetDataKey.loggedIn) ?? false + } + + /// "light" / "dark" / "system". The view's `.environment(\.colorScheme)` + /// reads this so the App's theme choice wins over the OS-level setting. + static func themeMode() -> String { + let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId) + return defaults?.string(forKey: WidgetDataKey.themeMode) ?? "system" + } + + private static func load(key: String) -> WidgetTimetableData? { + guard let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId), + let raw = defaults.string(forKey: key), + let data = raw.data(using: .utf8) else { + return nil + } + do { + return try decoder().decode(WidgetTimetableData.self, from: data) + } catch { + return nil + } + } +} diff --git a/lib/api/marianumcloud/talk/share_files_to_chat.dart b/lib/api/marianumcloud/talk/share_files_to_chat.dart new file mode 100644 index 0000000..9409e39 --- /dev/null +++ b/lib/api/marianumcloud/talk/share_files_to_chat.dart @@ -0,0 +1,21 @@ +import '../files_sharing/file_sharing_api.dart'; +import '../files_sharing/file_sharing_api_params.dart'; + +/// WebDAV folder under which Talk-shared files are uploaded before being +/// linked into a chat. +const String talkShareFolder = 'MarianumMobile'; + +/// Posts each already-uploaded WebDAV path as a Talk share (ShareType 10) to +/// the given conversation token. Calls run concurrently — the server accepts +/// parallel posts and the picker UI is blocked anyway, so we shouldn't pay +/// O(n*RTT) latency per share. +Future shareFilesToChat({ + required String token, + required List remoteFilePaths, +}) => Future.wait( + remoteFilePaths.map( + (path) => FileSharingApi().share( + FileSharingApiParams(shareType: 10, shareWith: token, path: path), + ), + ), +); diff --git a/lib/app.dart b/lib/app.dart index 6ca7924..7a9b2f6 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -13,15 +13,20 @@ import 'model/data_cleaner.dart'; import 'notification/notification_controller.dart'; import 'notification/notification_tasks.dart'; import 'notification/notify_updater.dart'; +import 'routing/app_routes.dart'; +import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/app_modules.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import 'state/app/modules/settings/bloc/settings_cubit.dart'; import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; +import 'state/app/modules/timetable/bloc/timetable_state.dart'; import 'storage/settings.dart' as model; import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; import 'widget/breaker/breaker.dart'; +import 'widget_data/widget_navigation.dart'; +import 'widget_data/widget_publisher.dart'; class App extends StatefulWidget { const App({super.key}); @@ -33,6 +38,7 @@ class App extends StatefulWidget { class _AppState extends State with WidgetsBindingObserver { late Timer _refetchChats; late Timer _updateTimings; + StreamSubscription? _timetableWidgetSync; // Tracked via the bottom-nav controller's listener so it always reflects the // user's actual position, even between rapid setting emits where the // controller hasn't caught up to a scheduled jump yet. @@ -52,9 +58,38 @@ class _AppState extends State with WidgetsBindingObserver { log('Refreshing due to LifecycleChange'); NotificationTasks.updateProviders(context); }); + _handlePendingWidgetNavigation(); } } + Future _handlePendingWidgetNavigation() async { + final pending = await WidgetNavigation.consumePendingTimetableTap(); + if (!pending || !mounted) return; + // Routes pushed with `withNavBar: false` (chat views, file viewers, …) + // sit on the root navigator above the bottom-nav, so a bare jumpToTab + // would swap the tab behind them and leave the user staring at the + // previous screen. Reset to the tab root first. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + AppRoutes.goToTab(context, Modules.timetable); + } + + void _handlePendingShare() { + if (!mounted) return; + final share = ShareIntentListener.pending.value; + if (share == null) return; + // A second share arriving while a previous share-flow page is still on + // the stack would otherwise leave the old page sitting on top with stale + // (already-cleared) file paths. Reset to the tab root before pushing. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + AppRoutes.openShareTarget(context, share); + } + @override void initState() { super.initState(); @@ -69,7 +104,40 @@ class _AppState extends State with WidgetsBindingObserver { // App is freshly mounted on every login (BlocConsumer in main.dart // swaps it in for Login), so this also covers the post-logout case // where the bloc was reset to an empty state and needs a fresh fetch. - context.read().refresh(); + final timetable = context.read(); + timetable.refresh(); + // Push the freshest timetable state into the home-screen widget any + // time the BLoC reports new data — without waiting for the periodic + // background refresh. This is the "user just opened the app" path: + // the widget gets the same data the user is looking at on screen. + final settingsCubit = context.read(); + _timetableWidgetSync?.cancel(); + _timetableWidgetSync = timetable.stream.listen((state) { + final data = state.data; + if (data is TimetableState && !state.isLoading) { + unawaited( + WidgetPublisher.publishFromBlocState( + data, + settings: settingsCubit.val(), + ), + ); + } + }); + // Also publish the current state once, in case data is already loaded + // from hydrated storage before the listener attaches. + final initialData = timetable.state.data; + if (initialData is TimetableState) { + unawaited( + WidgetPublisher.publishFromBlocState( + initialData, + settings: settingsCubit.val(), + ), + ); + } + unawaited(_handlePendingWidgetNavigation()); + ShareIntentListener.instance.attach(); + ShareIntentListener.pending.addListener(_handlePendingShare); + _handlePendingShare(); }); _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { @@ -115,6 +183,9 @@ class _AppState extends State with WidgetsBindingObserver { void dispose() { _refetchChats.cancel(); _updateTimings.cancel(); + _timetableWidgetSync?.cancel(); + ShareIntentListener.pending.removeListener(_handlePendingShare); + ShareIntentListener.instance.detach(); Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart new file mode 100644 index 0000000..e43f13c --- /dev/null +++ b/lib/background/widget_background_task.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:workmanager/workmanager.dart'; + +import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart'; +import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; +import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../api/webuntis/queries/authenticate/authenticate.dart'; +import '../api/webuntis/queries/get_holidays/get_holidays.dart'; +import '../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../api/webuntis/queries/get_rooms/get_rooms.dart'; +import '../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../api/webuntis/queries/get_subjects/get_subjects.dart'; +import '../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart'; +import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../api/webuntis/queries/get_timetable/get_timetable.dart'; +import '../api/webuntis/queries/get_timetable/get_timetable_params.dart'; +import '../model/account_data.dart'; +import '../widget_data/widget_data_mapper.dart'; +import '../widget_data/widget_publisher.dart'; +import '../widget_data/widget_sync.dart'; + +/// Periodic widget refresh in a background Dart isolate. Native HTTP would +/// mean reimplementing WebUntis JSON-RPC (auth, session-timeout retry -8520, +/// payload quirks) twice — Dart isolate keeps that logic in one place. +class WidgetBackgroundTask { + static const String periodicTaskName = 'eu.mhsl.marianum.widget.refresh'; + static const String oneOffTaskName = 'eu.mhsl.marianum.widget.refresh.once'; + + static const Duration periodicFrequency = Duration(minutes: 30); + + static Future initialize() async { + await Workmanager().initialize(_callbackDispatcher); + await Workmanager().registerPeriodicTask( + periodicTaskName, + periodicTaskName, + frequency: periodicFrequency, + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, + backoffPolicy: BackoffPolicy.linear, + backoffPolicyDelay: const Duration(minutes: 5), + ); + } + + static Future requestImmediateRefresh() async { + await Workmanager().registerOneOffTask( + '$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}', + oneOffTaskName, + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingWorkPolicy.append, + ); + } + + static Future cancelAll() async { + await Workmanager().cancelAll(); + } +} + +@pragma('vm:entry-point') +void _callbackDispatcher() { + Workmanager().executeTask((task, inputData) async { + try { + WidgetsFlutterBinding.ensureInitialized(); + await AccountData().waitForPopulation(); + if (!AccountData().isPopulated()) { + log('[widget-bg] not logged in, skipping refresh'); + await WidgetSync.setLoggedIn(false); + await WidgetSync.triggerUpdate(); + return true; + } + await _refresh(); + return true; + } on Exception catch (e, s) { + log('[widget-bg] refresh failed: $e', stackTrace: s); + // false → Workmanager retries with backoff. Native side keeps the + // last good snapshot so the user still sees something. + return false; + } + }); +} + +Future _refresh() async { + await WidgetSync.ensureInitialized(); + await Authenticate.createSession(); + + final now = WidgetPublisher.widgetNow(); + final dateFormat = DateFormat('yyyyMMdd'); + // 14-day window so the week-widget rolls forward into next Monday's + // lessons on Friday evening. + final weekStart = _startOfWeek(now); + final weekEndExclusive = weekStart.add(const Duration(days: 14)); + final session = await Authenticate.getSession(); + + final timetable = await GetTimetable( + GetTimetableParams( + options: GetTimetableParamsOptions( + element: GetTimetableParamsOptionsElement( + id: session.personId, + type: session.personType, + keyType: GetTimetableParamsOptionsElementKeyType.id, + ), + startDate: int.parse(dateFormat.format(weekStart)), + endDate: int.parse( + dateFormat.format(weekEndExclusive.subtract(const Duration(days: 1))), + ), + teacherFields: GetTimetableParamsOptionsFields.all, + subjectFields: GetTimetableParamsOptionsFields.all, + roomFields: GetTimetableParamsOptionsFields.all, + klasseFields: GetTimetableParamsOptionsFields.all, + ), + ), + ).run(); + + // Reference data — failures fall through to null in the mapper rather + // than aborting the whole refresh. + final subjects = await _runOrNull(() => GetSubjects().run()); + final rooms = await _runOrNull(() => GetRooms().run()); + final holidays = await _runOrNull(() => GetHolidays().run()); + final timegrid = await _runOrNull( + () => GetTimegridUnits().run(), + ); + final customEvents = await _runOrNull( + () => GetCustomTimetableEvent( + GetCustomTimetableEventParams(AccountData().getUserSecret()), + ).run(), + ); + + final lessons = timetable.result; + + final connectDouble = await WidgetSync.getConnectDoubleLessons(); + final dayData = WidgetDataMapper.buildDayData( + now: now, + lessons: lessons, + subjects: subjects, + rooms: rooms, + holidays: holidays, + timegrid: timegrid, + customEvents: customEvents, + connectDoubleLessons: connectDouble, + ); + final weekData = WidgetDataMapper.buildWeekData( + now: now, + lessons: lessons, + subjects: subjects, + rooms: rooms, + holidays: holidays, + timegrid: timegrid, + customEvents: customEvents, + connectDoubleLessons: connectDouble, + ); + + await WidgetSync.writeDayData(dayData); + await WidgetSync.writeWeekData(weekData); + await WidgetSync.setLoggedIn(true); + await WidgetSync.triggerUpdate(); + log( + '[widget-bg] refreshed: day=${dayData.lessons.length} ' + 'week=${weekData.lessons.length}', + ); +} + +DateTime _startOfWeek(DateTime reference) { + final monday = reference.subtract(Duration(days: reference.weekday - 1)); + return DateTime(monday.year, monday.month, monday.day); +} + +Future _runOrNull(Future Function() task) async { + try { + return await task(); + } on Exception catch (e) { + log('[widget-bg] reference fetch failed: $e'); + return null; + } +} diff --git a/lib/main.dart b/lib/main.dart index 3d02fec..ad7199e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,8 +19,10 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'app.dart'; +import 'background/widget_background_task.dart'; import 'firebase_options.dart'; import 'model/account_data.dart'; +import 'share_intent/share_intent_listener.dart'; import 'state/app/modules/account/bloc/account_bloc.dart'; import 'state/app/modules/account/bloc/account_state.dart'; import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; @@ -35,6 +37,7 @@ import 'view/login/login.dart'; import 'widget/app_progress_indicator.dart'; import 'widget/breaker/breaker.dart'; import 'widget/debug/cache_view.dart'; +import 'widget_data/widget_sync.dart'; Future main() async { log('MarianumMobile started'); @@ -66,12 +69,22 @@ Future main() async { HydratedBloc.storage = storage; }), AccountData().waitForPopulation(), + ShareIntentListener.instance.initialize(), ]; log('starting app initialisation...'); await Future.wait(initialisationTasks); log('app initialisation done!'); + // Wire up the home-screen widget bridge before runApp so any widget render + // triggered during startup hits initialised native storage. + await WidgetSync.ensureInitialized(); + unawaited( + WidgetBackgroundTask.initialize().onError( + (e, _) => log('Workmanager init failed: $e'), + ), + ); + unawaited( FirebaseMessaging.instance.getToken().then( (token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'), @@ -198,6 +211,10 @@ class _MainState extends State
{ previous.status != current.status, listener: (context, accountState) { if (accountState.status != AccountStatus.loggedOut) return; + // A pending share would otherwise survive logout and be + // re-applied after re-login with file paths the OS may + // already have evicted from the cache. + ShareIntentListener.instance.clear(); // Routes pushed via AppRoutes (e.g. Settings) live on the // root navigator and survive the home swap below, so they // would still cover the Login screen after logout. Pop @@ -287,6 +304,12 @@ Future _wipeUserState({ await prefs.clear(); await HydratedBloc.storage.clear(); await const CacheView().clear(); + // Stop the periodic widget refresh job so the background isolate doesn't + // wake up every 30 minutes only to write `loggedIn=false`. Re-registers + // on the next successful login. + await WidgetBackgroundTask.cancelAll(); + await WidgetSync.clear(); + await WidgetSync.triggerUpdate(); } catch (e, s) { log('User state wipe failed: $e', stackTrace: s); } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 6f79929..804d94e 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -6,6 +6,8 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../api/marianumcloud/talk/room/get_room_response.dart'; import '../main.dart'; import '../model/account_data.dart'; +import '../share_intent/pending_share.dart'; +import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/app_modules.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; @@ -17,6 +19,9 @@ import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/settings.dart'; +import '../view/pages/share_intent/share_chat_picker.dart'; +import '../view/pages/share_intent/share_folder_picker.dart'; +import '../view/pages/share_intent/share_target_page.dart'; import '../view/pages/talk/chat_view.dart'; import '../view/pages/talk/details/message_reactions.dart'; import '../view/pages/talk/talk_navigator.dart'; @@ -42,11 +47,16 @@ class AppRoutes { BuildContext context, String localPath, { bool openExternal = false, + RemoteFileRef? remoteFile, }) { pushScreen( context, withNavBar: false, - screen: FileViewer(path: localPath, openExternal: openExternal), + screen: FileViewer( + path: localPath, + openExternal: openExternal, + remoteFile: remoteFile, + ), ); } @@ -90,6 +100,64 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const Roomplan()); } + static void openShareTarget(BuildContext context, PendingShare share) { + pushScreen( + context, + withNavBar: false, + screen: ShareTargetPage(share: share), + ); + } + + static void openShareChatPicker(BuildContext context, PendingShare share) { + pushScreen( + context, + withNavBar: false, + screen: ShareChatPicker.forExternalShare(share: share), + ); + } + + static void openShareFolderPicker(BuildContext context, PendingShare share) { + pushScreen( + context, + withNavBar: false, + screen: ShareFolderPicker.forExternalShare(share: share), + ); + } + + static void openInternalShareToChat( + BuildContext context, + RemoteFileRef file, + ) { + pushScreen( + context, + withNavBar: false, + screen: ShareChatPicker.forInternalShare(file: file), + ); + } + + static void openForwardMessageToChat( + BuildContext context, { + String? text, + RemoteFileRef? file, + }) { + pushScreen( + context, + withNavBar: false, + screen: ShareChatPicker.forMessageForward(text: text, file: file), + ); + } + + static void openInternalSaveToFolder( + BuildContext context, + RemoteFileRef file, + ) { + pushScreen( + context, + withNavBar: false, + screen: ShareFolderPicker.forInternalSave(file: file), + ); + } + static void openMessageReactions( BuildContext context, String token, diff --git a/lib/share_intent/internal_share_actions.dart b/lib/share_intent/internal_share_actions.dart new file mode 100644 index 0000000..f9a5a47 --- /dev/null +++ b/lib/share_intent/internal_share_actions.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; + +import '../api/marianumcloud/webdav/webdav_api.dart'; +import '../widget/confirm_dialog.dart'; +import 'remote_file_ref.dart'; + +/// Server-side WebDAV copy of [source] into [targetFolderPath]. On a 412 +/// conflict the user is asked whether to overwrite; on confirmation the call +/// is retried with `overwrite: true`. Returns true when the file ended up at +/// the target, false when the user cancelled. +Future copyRemoteFileTo({ + required BuildContext context, + required RemoteFileRef source, + required String targetFolderPath, +}) async { + final webdav = await WebdavApi.webdav; + final dst = targetFolderPath.isEmpty + ? source.name + : '${targetFolderPath.replaceAll(RegExp(r'/+$'), '')}/${source.name}'; + final src = PathUri.parse(source.path); + final dstUri = PathUri.parse(dst); + + try { + await webdav.copy(src, dstUri); + return true; + } on DynamiteApiException catch (e) { + if (e.statusCode != 412) rethrow; + if (!context.mounted) return false; + final overwrite = await showDialog( + context: context, + builder: (ctx) => ConfirmDialog( + title: 'Datei existiert bereits', + content: + '"${source.name}" existiert in /$targetFolderPath. Überschreiben?', + confirmButton: 'Überschreiben', + cancelButton: 'Abbrechen', + onConfirm: () => Navigator.of(ctx).pop(true), + ), + ); + if (overwrite != true) return false; + await webdav.copy(src, dstUri, overwrite: true); + return true; + } +} diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart new file mode 100644 index 0000000..226b078 --- /dev/null +++ b/lib/share_intent/pending_share.dart @@ -0,0 +1,15 @@ +class PendingShare { + final List filePaths; + final String? text; + final DateTime receivedAt; + + const PendingShare({ + required this.filePaths, + required this.text, + required this.receivedAt, + }); + + bool get hasFiles => filePaths.isNotEmpty; + bool get hasText => text != null && text!.isNotEmpty; + bool get isEmpty => !hasFiles && !hasText; +} diff --git a/lib/share_intent/remote_file_ref.dart b/lib/share_intent/remote_file_ref.dart new file mode 100644 index 0000000..909c410 --- /dev/null +++ b/lib/share_intent/remote_file_ref.dart @@ -0,0 +1,20 @@ +import '../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +/// References a file that already lives on the Nextcloud server. Used by the +/// in-app share/save flows that operate on remote paths instead of local +/// cache files (no upload needed). +class RemoteFileRef { + final String path; + final String name; + + const RemoteFileRef({required this.path, required this.name}); + + /// Caller must verify `file.path != null` first — Talk message parameters + /// without a path (system events, mentions, polls) are not file refs. + factory RemoteFileRef.fromTalk(RichObjectString file) => + RemoteFileRef(path: file.path!, name: file.name); + + factory RemoteFileRef.fromCacheable(CacheableFile file) => + RemoteFileRef(path: file.path, name: file.name); +} diff --git a/lib/share_intent/share_intent_listener.dart b/lib/share_intent/share_intent_listener.dart new file mode 100644 index 0000000..855ec18 --- /dev/null +++ b/lib/share_intent/share_intent_listener.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +import 'pending_share.dart'; + +/// Bridges native share intents (Android ACTION_SEND, iOS Share Extension) +/// into a single [ValueNotifier] that the app routes off of. +class ShareIntentListener { + ShareIntentListener._(); + static final ShareIntentListener instance = ShareIntentListener._(); + + static final ValueNotifier pending = ValueNotifier(null); + + StreamSubscription>? _streamSub; + bool _initialized = false; + + /// Reads the cold-start payload exactly once. Call from `main()` before + /// `runApp` so the share is queued before the UI mounts. + Future initialize() async { + if (_initialized) return; + _initialized = true; + try { + final initial = await ReceiveSharingIntent.instance.getInitialMedia(); + final share = _toPendingShare(initial); + if (share != null) pending.value = share; + await ReceiveSharingIntent.instance.reset(); + } catch (e) { + debugPrint('ShareIntentListener.initialize failed: $e'); + } + } + + /// Subscribes to warm-share stream events. Safe to call multiple times. + void attach() { + _streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen( + (items) { + final share = _toPendingShare(items); + if (share != null) pending.value = share; + }, + onError: (Object e) => + debugPrint('ShareIntentListener stream error: $e'), + ); + } + + /// Cancels the warm-share subscription. The singleton survives, so a + /// subsequent [attach] re-subscribes. + void detach() { + _streamSub?.cancel(); + _streamSub = null; + } + + /// Discards the current share and removes any temp files the plugin copied + /// into the app cache. Idempotent. + void clear() { + final current = pending.value; + pending.value = null; + if (current != null) { + for (final path in current.filePaths) { + try { + final f = File(path); + if (f.existsSync()) f.deleteSync(); + } catch (_) { + // best-effort cleanup; OS will reclaim cache eventually + } + } + } + unawaited(ReceiveSharingIntent.instance.reset()); + } + + PendingShare? _toPendingShare(List items) { + if (items.isEmpty) return null; + final files = []; + final texts = []; + for (final item in items) { + switch (item.type) { + case SharedMediaType.image: + case SharedMediaType.video: + case SharedMediaType.file: + files.add(item.path); + case SharedMediaType.text: + case SharedMediaType.url: + texts.add(item.path); + } + } + if (files.isEmpty && texts.isEmpty) return null; + return PendingShare( + filePaths: files, + text: texts.isEmpty ? null : texts.join('\n'), + receivedAt: DateTime.now(), + ); + } +} diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index 27e5f5e..e8753c9 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -1,3 +1,5 @@ +import 'package:collection/collection.dart'; + import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../infrastructure/loadable_state/loading_error.dart'; @@ -53,12 +55,24 @@ class FilesBloc Future _query(List path) async { final pathString = path.isEmpty ? '/' : path.join('/'); + // Drop late results when [setPath] has navigated elsewhere or when the + // bloc has been disposed (e.g. share-flow picker closed mid-fetch). Both + // would otherwise corrupt state or hit "add after close" on the stream. + const pathEquality = ListEquality(); + bool isStale() { + if (isClosed) return true; + final inner = innerState; + if (inner == null) return false; + return !pathEquality.equals(inner.currentPath, path); + } + Object? capturedError; ListFilesResponse? listing; try { listing = await repo.data.listFiles( pathString, onCacheData: (cached) { + if (isStale()) return; // Cached payload arrives before the network call settles. Surface it // immediately via Emit so the listing is visible while isLoading // stays true and the top loading bar keeps spinning. @@ -73,6 +87,8 @@ class FilesBloc capturedError = e; } + if (isStale()) return; + if (listing != null) { listing.files.removeWhere( (file) => file.name.isEmpty || file.name == path.lastOrNull, diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 879cf0f..80770b2 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../background/widget_background_task.dart'; import '../../state/app/modules/account/bloc/account_bloc.dart'; import '../../state/app/modules/account/bloc/account_state.dart'; import '../../theming/light_app_theme.dart'; @@ -34,6 +37,11 @@ class _LoginState extends State { void _onLoginSuccess() { context.read().setStatus(AccountStatus.loggedIn); + // Re-register the periodic refresh (cancelAll runs on logout) and kick + // off an immediate one-off so the widget populates within seconds + // instead of waiting up to 30 minutes for the next periodic slot. + unawaited(WidgetBackgroundTask.initialize()); + unawaited(WidgetBackgroundTask.requestImmediateRefresh()); } @override diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index 2d7126f..9669522 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -7,6 +7,7 @@ import '../../api/errors/error_mapper.dart'; import '../../api/marianumcloud/talk/room/get_room.dart'; import '../../api/marianumcloud/talk/room/get_room_params.dart'; import '../../model/account_data.dart'; +import '../../widget_data/widget_sync.dart'; /// Owns the login flow's transient state (loading, last error) so it can be /// driven from a thin Stateful view and unit-tested without a widget tree. @@ -31,6 +32,11 @@ class LoginController extends ChangeNotifier { final user = username.trim().toLowerCase(); try { await AccountData().removeData(); + // Drop any cached widget snapshot from a previous account before the + // new credentials populate it — otherwise a re-login with a different + // user briefly shows the previous owner's timetable on the home screen. + await WidgetSync.clear(); + await WidgetSync.triggerUpdate(); await AccountData().setData(user, password); await GetRoom(GetRoomParams(includeStatus: false)).run(); _loading = false; diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart index 04649c5..b3aad88 100644 --- a/lib/view/login/widgets/login_branding.dart +++ b/lib/view/login/widgets/login_branding.dart @@ -45,7 +45,7 @@ class LoginDisclaimer extends StatelessWidget { Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( - 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', + 'Inoffizieller Marianum-Cloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.75), diff --git a/lib/view/pages/files/widgets/add_file_menu.dart b/lib/view/pages/files/widgets/add_file_menu.dart index 508fe7d..9f8565d 100644 --- a/lib/view/pages/files/widgets/add_file_menu.dart +++ b/lib/view/pages/files/widgets/add_file_menu.dart @@ -21,7 +21,7 @@ void showAddFileSheet( title: const Text('Ordner erstellen'), onTap: () { Navigator.of(sheetCtx).pop(); - _showCreateFolderDialog(context, bloc); + showCreateFolderDialog(context, bloc); }, ), ListTile( @@ -56,7 +56,7 @@ void showAddFileSheet( ); } -void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { +void showCreateFolderDialog(BuildContext context, FilesBloc bloc) { final inputController = TextEditingController(); showDialog( context: context, diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 0e64864..c573ae8 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../extensions/date_time.dart'; import '../../../../model/endpoint_data.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../share_intent/remote_file_ref.dart'; import '../../../../utils/download_manager.dart'; import '../../../../utils/file_clipboard.dart'; import '../../../../widget/centered_leading.dart'; @@ -71,7 +72,11 @@ class _FileElementState extends State { if (status is DownloadDone) { DownloadManager.instance.clear(widget.file.path); _detachJob(); - AppRoutes.openFileViewer(context, status.localPath); + AppRoutes.openFileViewer( + context, + status.localPath, + remoteFile: RemoteFileRef.fromCacheable(widget.file), + ); setState(() {}); } else if (status is DownloadFailed) { final message = status.message; @@ -299,6 +304,18 @@ class _FileElementState extends State { _putOnClipboard(copy: true); }, ), + if (!widget.file.isDirectory) + ListTile( + leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)), + title: const Text('Im Talk-Chat teilen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + AppRoutes.openInternalShareToChat( + context, + RemoteFileRef.fromCacheable(widget.file), + ); + }, + ), ListTile( leading: const CenteredLeading(Icon(Icons.delete_outline)), title: const Text('Löschen'), diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index eeda08b..b2fa812 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -69,7 +69,7 @@ class AboutSection extends StatelessWidget { applicationVersion: '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', applicationLegalese: - 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' + 'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' "${kReleaseMode ? "Production" : "Development"} build\n" 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', @@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget { ListTile( leading: const CenteredLeading(Icon(Icons.school_outlined)), title: const Text('Infos zum Marianum Fulda'), - subtitle: const Text('Für Talk-Chats und Dateien'), + subtitle: const Text('Für Talk-Chats und Cloud-Dateien'), trailing: const Icon(Icons.arrow_right), onTap: () => PrivacyInfo( providerText: 'Marianum', diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index 575f4cc..2aae4fe 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -60,7 +60,7 @@ class TalkSection extends StatelessWidget { context, "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' - 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' + 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', title: 'Info über Push', diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart new file mode 100644 index 0000000..d61e620 --- /dev/null +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -0,0 +1,281 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; + +import '../../../api/errors/error_mapper.dart'; +import '../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../api/marianumcloud/talk/send_message/send_message.dart'; +import '../../../api/marianumcloud/talk/send_message/send_message_params.dart'; +import '../../../api/marianumcloud/talk/share_files_to_chat.dart'; +import '../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../routing/app_routes.dart'; +import '../../../share_intent/pending_share.dart'; +import '../../../share_intent/remote_file_ref.dart'; +import '../../../share_intent/share_intent_listener.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../widget/info_dialog.dart'; +import '../../../widget/placeholder_view.dart'; +import '../files/files_upload_dialog.dart'; +import '../talk/search_chat.dart'; +import '../talk/widgets/chat_tile.dart'; + +typedef _ChatPickedCallback = + Future Function(BuildContext context, GetRoomResponseObject room); + +class ShareChatPicker extends StatelessWidget { + final _ChatPickedCallback _onPicked; + + const ShareChatPicker._({required _ChatPickedCallback onPicked}) + : _onPicked = onPicked; + + /// External share-intent flow: uploads local files into the Talk share + /// folder, then shares them in the chosen chat. Falls back to a draft-only + /// flow when the pending share contains no files. + factory ShareChatPicker.forExternalShare({required PendingShare share}) => + ShareChatPicker._( + onPicked: (ctx, room) => _externalShareFlow(ctx, room, share), + ); + + /// In-app share flow: links an already-uploaded server file into the chosen + /// chat via FileSharingApi (no upload needed). + factory ShareChatPicker.forInternalShare({required RemoteFileRef file}) => + ShareChatPicker._( + onPicked: (ctx, room) => _internalShareFlow(ctx, room, file), + ); + + /// Forward an existing Talk message (text and/or already-uploaded file + /// attachment) into another chat. The attachment is re-shared via the same + /// FileSharingApi path used for [forInternalShare]; plain text is posted + /// with [SendMessage]. + factory ShareChatPicker.forMessageForward({ + String? text, + RemoteFileRef? file, + }) { + assert( + text != null || file != null, + 'forMessageForward requires either text or file', + ); + return ShareChatPicker._( + onPicked: (ctx, room) => _forwardMessageFlow(ctx, room, text, file), + ); + } + + @override + Widget build(BuildContext context) { + final talkSettings = context.watch().val().talkSettings; + return Scaffold( + appBar: AppBar( + title: const Text('Talk-Chat auswählen'), + actions: [ + Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.search), + onPressed: () { + final rooms = ctx.read().state.data?.rooms; + if (rooms == null) return; + showSearch( + context: ctx, + delegate: SearchChat( + rooms.data.where((r) => r.readOnly == 0).toList(), + onTapOverride: (room) { + Navigator.of(ctx).pop(); + _onPicked(ctx, room); + }, + ), + ); + }, + ), + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) { + final rooms = state.rooms; + if (rooms == null) return const SizedBox.shrink(); + final sorted = rooms + .sortBy( + lastActivity: true, + favoritesToTop: talkSettings.sortFavoritesToTop, + unreadToTop: talkSettings.sortUnreadToTop, + ) + // Hide chats the user can't write to (announcement channels, + // archived rooms, …) — uploading there would only fail at the + // share-API call with 403. + .where((r) => r.readOnly == 0) + .toList(); + if (sorted.isEmpty) { + return const PlaceholderView( + icon: Icons.chat_bubble_outline, + text: 'Keine schreibbaren Chats verfügbar', + ); + } + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: sorted.length, + itemBuilder: (context, i) => ChatTile( + data: sorted[i], + disableContextActions: true, + onTapOverride: (room) => _onPicked(context, room), + ), + ); + }, + ), + ); + } +} + +Future _externalShareFlow( + BuildContext context, + GetRoomResponseObject room, + PendingShare share, +) async { + if (share.hasFiles) { + try { + final webdav = await WebdavApi.webdav; + await webdav.mkcol(PathUri.parse('/$talkShareFolder')); + } catch (_) { + // mkcol throws when the folder already exists; ignore. + } + if (!context.mounted) return; + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: talkShareFolder, + uniqueNames: true, + onUploadFinished: (uploaded) => + _afterExternalFilesUploaded(context, room, uploaded, share), + ), + ); + return; + } + if (share.hasText) { + _setExternalDraftAndOpenChat(context, room, share); + } +} + +Future _afterExternalFilesUploaded( + BuildContext context, + GetRoomResponseObject room, + List uploadedRemotePaths, + PendingShare share, +) async { + unawaited(_showBlockingSpinner(context)); + try { + await shareFilesToChat( + token: room.token, + remoteFilePaths: uploadedRemotePaths, + ); + } catch (e) { + if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) { + InfoDialog.show( + context, + errorToUserMessage(e), + title: 'Fehler', + copyable: true, + ); + } + return; + } + if (!context.mounted) return; + _setExternalDraftAndOpenChat(context, room, share); +} + +void _setExternalDraftAndOpenChat( + BuildContext context, + GetRoomResponseObject room, + PendingShare share, +) { + if (share.hasText) { + final settings = context.read(); + settings.val(write: true).talkSettings.drafts[room.token] = share.text!; + } + ShareIntentListener.instance.clear(); + _finishWithChat(context, room); +} + +/// Closes any picker/spinner pages stacked on top of the current tab and +/// jumps to the chosen chat. Shared by external + internal share flows. +void _finishWithChat(BuildContext context, GetRoomResponseObject room) { + Navigator.of(context).popUntil((route) => route.isFirst); + AppRoutes.openChatByToken(context, room.token); +} + +Future _internalShareFlow( + BuildContext context, + GetRoomResponseObject room, + RemoteFileRef file, +) async { + unawaited(_showBlockingSpinner(context)); + try { + await shareFilesToChat( + token: room.token, + remoteFilePaths: [file.path], + ); + } catch (e) { + if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) { + InfoDialog.show( + context, + errorToUserMessage(e), + title: 'Fehler', + copyable: true, + ); + } + return; + } + if (!context.mounted) return; + _finishWithChat(context, room); +} + +Future _forwardMessageFlow( + BuildContext context, + GetRoomResponseObject room, + String? text, + RemoteFileRef? file, +) async { + unawaited(_showBlockingSpinner(context)); + try { + if (file != null) { + await shareFilesToChat( + token: room.token, + remoteFilePaths: [file.path], + ); + } + if (text != null && text.isNotEmpty) { + await SendMessage(room.token, SendMessageParams(text)).run(); + } + } catch (e) { + if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) { + InfoDialog.show( + context, + errorToUserMessage(e), + title: 'Fehler', + copyable: true, + ); + } + return; + } + if (!context.mounted) return; + _finishWithChat(context, room); +} + +/// Modal progress overlay shown during share-API roundtrips. The dialog is +/// popped together with the picker by the subsequent popUntil(isFirst). +Future _showBlockingSpinner(BuildContext context) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const PopScope( + canPop: false, + child: Center(child: CircularProgressIndicator()), + ), +); diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart new file mode 100644 index 0000000..914f156 --- /dev/null +++ b/lib/view/pages/share_intent/share_folder_picker.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; + +import '../../../api/errors/error_mapper.dart'; +import '../../../routing/app_routes.dart'; +import '../../../share_intent/internal_share_actions.dart'; +import '../../../share_intent/pending_share.dart'; +import '../../../share_intent/remote_file_ref.dart'; +import '../../../share_intent/share_intent_listener.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/files/bloc/files_bloc.dart'; +import '../../../state/app/modules/files/bloc/files_state.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../widget/info_dialog.dart'; +import '../../../widget/placeholder_view.dart'; +import '../files/data/sort_options.dart'; +import '../files/files_upload_dialog.dart'; +import '../files/widgets/add_file_menu.dart'; +import '../files/widgets/files_sort_actions.dart'; + +typedef _FolderConfirmedCallback = + Future Function(BuildContext context, List targetPath); + +class ShareFolderPicker extends StatelessWidget { + final String _fabLabel; + final _FolderConfirmedCallback _onConfirm; + + const ShareFolderPicker._({ + required String fabLabel, + required _FolderConfirmedCallback onConfirm, + }) : _fabLabel = fabLabel, + _onConfirm = onConfirm; + + /// External share-intent flow: upload local files into the chosen folder. + factory ShareFolderPicker.forExternalShare({required PendingShare share}) => + ShareFolderPicker._( + fabLabel: 'Hier hochladen', + onConfirm: (ctx, target) => _externalUploadFlow(ctx, target, share), + ); + + /// In-app save flow: server-to-server WebDAV-copy of an existing file into + /// the chosen folder. + factory ShareFolderPicker.forInternalSave({required RemoteFileRef file}) => + ShareFolderPicker._( + fabLabel: 'Hierhin kopieren', + onConfirm: (ctx, target) => _internalCopyFlow(ctx, target, file), + ); + + @override + Widget build(BuildContext context) => + BlocModule>( + create: (_) => FilesBloc(), + child: (context, _, _) => + _ShareFolderPickerView(fabLabel: _fabLabel, onConfirm: _onConfirm), + ); +} + +class _ShareFolderPickerView extends StatefulWidget { + final String fabLabel; + final _FolderConfirmedCallback onConfirm; + const _ShareFolderPickerView({ + required this.fabLabel, + required this.onConfirm, + }); + + @override + State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState(); +} + +class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { + late final SettingsCubit _settings; + late SortOption _currentSort; + late bool _ascending; + + @override + void initState() { + super.initState(); + _settings = context.read(); + _currentSort = _settings.val().fileSettings.sortBy; + _ascending = _settings.val().fileSettings.ascending; + } + + void _enter(FilesBloc bloc, List currentPath, String folderName) { + bloc.setPath([...currentPath, folderName]); + } + + void _goUp(FilesBloc bloc, List currentPath) { + if (currentPath.isEmpty) return; + bloc.setPath(currentPath.sublist(0, currentPath.length - 1)); + } + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + return BlocBuilder>( + buildWhen: (a, b) => a.data?.currentPath != b.data?.currentPath, + builder: (_, outerState) { + final currentPath = outerState.data?.currentPath ?? const []; + return PopScope( + // Back navigates one level up while inside a sub-folder; only the + // root level actually closes the picker. Matches the standard + // files-app pattern and keeps the AppBar back-arrow consistent + // with the chat picker. + canPop: currentPath.isEmpty, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (currentPath.isNotEmpty) _goUp(bloc, currentPath); + }, + child: _buildScaffold(context, bloc, currentPath), + ); + }, + ); + } + + Widget _buildScaffold( + BuildContext context, + FilesBloc bloc, + List currentPath, + ) => Scaffold( + appBar: AppBar( + title: Text( + currentPath.isEmpty ? 'Ordner wählen' : '/${currentPath.join('/')}', + overflow: TextOverflow.ellipsis, + ), + actions: [ + IconButton( + icon: const Icon(Icons.create_new_folder_outlined), + tooltip: 'Ordner erstellen', + onPressed: () => showCreateFolderDialog(context, bloc), + ), + FilesSortActions( + currentSort: _currentSort, + ascending: _ascending, + onDirectionChanged: (e) { + setState(() { + _ascending = e; + _settings.val(write: true).fileSettings.ascending = e; + }); + }, + onSortChanged: (e) { + setState(() { + _currentSort = e; + _settings.val(write: true).fileSettings.sortBy = e; + }); + }, + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + heroTag: 'shareUploadHere', + onPressed: () => widget.onConfirm(context, currentPath), + icon: const Icon(Icons.upload), + label: Text(widget.fabLabel), + ), + body: LoadableStateConsumer( + isReady: (state) => state.listing != null, + child: (state, _) { + final listing = state.listing!; + final entries = listing.sortBy( + sortOption: _currentSort, + foldersToTop: _settings.val().fileSettings.sortFoldersToTop, + reversed: _ascending, + ); + + if (entries.isEmpty) { + return PlaceholderView( + icon: Icons.folder_off_rounded, + text: state.currentPath.isEmpty + ? 'Leer. Du kannst hier direkt hochladen.' + : 'Ordner ist leer. Du kannst hier hochladen.', + ); + } + + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: entries.length, + itemBuilder: (context, i) { + final entry = entries[i]; + if (entry.isDirectory) { + return ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(entry.name), + trailing: const Icon(Icons.chevron_right), + onTap: () => _enter(bloc, state.currentPath, entry.name), + ); + } + return ListTile( + enabled: false, + leading: const Icon(Icons.description_outlined), + title: Text(entry.name), + ); + }, + ); + }, + ), + ); +} + +Future _externalUploadFlow( + BuildContext context, + List targetPath, + PendingShare share, +) async { + await pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: share.filePaths, + remotePath: targetPath.join('/'), + onUploadFinished: (_) => _afterExternalUploaded(context, targetPath), + ), + ); +} + +void _afterExternalUploaded(BuildContext context, List targetPath) { + ShareIntentListener.instance.clear(); + if (!context.mounted) return; + _finishWithFolder(context, targetPath); +} + +/// Closes any picker pages stacked on top of the current tab and jumps to +/// the chosen folder. Shared by external upload + internal copy flows. +void _finishWithFolder(BuildContext context, List targetPath) { + Navigator.of(context).popUntil((route) => route.isFirst); + AppRoutes.openFolder(context, targetPath); +} + +Future _internalCopyFlow( + BuildContext context, + List targetPath, + RemoteFileRef file, +) async { + final bool ok; + try { + ok = await copyRemoteFileTo( + context: context, + source: file, + targetFolderPath: targetPath.join('/'), + ); + } catch (e) { + if (context.mounted) { + InfoDialog.show( + context, + errorToUserMessage(e), + title: 'Kopieren fehlgeschlagen', + copyable: true, + ); + } + return; + } + if (!ok || !context.mounted) return; + _finishWithFolder(context, targetPath); +} diff --git a/lib/view/pages/share_intent/share_target_page.dart b/lib/view/pages/share_intent/share_target_page.dart new file mode 100644 index 0000000..17257a7 --- /dev/null +++ b/lib/view/pages/share_intent/share_target_page.dart @@ -0,0 +1,210 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../../routing/app_routes.dart'; +import '../../../share_intent/pending_share.dart'; +import '../../../share_intent/share_intent_listener.dart'; + +class ShareTargetPage extends StatelessWidget { + final PendingShare share; + + const ShareTargetPage({super.key, required this.share}); + + static const _imageExtensions = { + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', + '.heic', + '.heif', + '.bmp', + }; + + bool _isImagePath(String path) { + final lower = path.toLowerCase(); + return _imageExtensions.any(lower.endsWith); + } + + String _appBarTitle() { + if (share.hasFiles && share.hasText) return 'Inhalte teilen'; + if (share.hasFiles) { + return share.filePaths.length == 1 + ? '1 Datei teilen' + : '${share.filePaths.length} Dateien teilen'; + } + return 'Inhalt teilen'; + } + + @override + Widget build(BuildContext context) => PopScope( + onPopInvokedWithResult: (didPop, _) { + if (didPop) ShareIntentListener.instance.clear(); + }, + child: Scaffold( + appBar: AppBar(title: Text(_appBarTitle())), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (share.hasFiles) _buildFilePreview(context), + if (share.hasFiles && share.hasText) + const SizedBox(height: 12), + if (share.hasText) _buildTextPreview(context), + ], + ), + ), + ), + const Divider(height: 1), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Column( + children: [ + Icon( + Icons.ios_share, + size: 44, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 8), + Text( + 'Wo möchtest du teilen?', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.chat_bubble_outline), + title: const Text('An Talk-Chat senden'), + subtitle: const Text( + 'Datei oder Text in einem Talk-Chat teilen', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => + AppRoutes.openShareChatPicker(context, share), + ), + ListTile( + enabled: share.hasFiles, + leading: const Icon(Icons.cloud_outlined), + title: const Text('In Cloud speichern'), + subtitle: Text( + share.hasFiles + ? 'In einen Cloud-Ordner hochladen' + : 'Nur für Dateien verfügbar', + ), + trailing: const Icon(Icons.chevron_right), + onTap: share.hasFiles + ? () => AppRoutes.openShareFolderPicker(context, share) + : null, + ), + ], + ), + ), + ), + ], + ), + ), + ); + + Widget _buildFilePreview(BuildContext context) { + if (share.filePaths.length == 1) { + final path = share.filePaths.first; + final name = path.split(Platform.pathSeparator).last; + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: _isImagePath(path) + ? Image.file( + File(path), + fit: BoxFit.contain, + // Decode at most ~1080px so 50-MP gallery photos don't + // balloon the decode buffer just to render at <320px high. + cacheWidth: 1080, + errorBuilder: (_, _, _) => _fileFallbackLarge(name), + ) + : _fileFallbackLarge(name), + ), + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: share.filePaths.length, + itemBuilder: (context, i) { + final path = share.filePaths[i]; + final name = path.split(Platform.pathSeparator).last; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: _isImagePath(path) + ? Image.file( + File(path), + fit: BoxFit.cover, + // Grid tiles are ~half-screen wide; 480px decode is + // sharp on 3x displays without blowing up memory when + // many files are shared at once. + cacheWidth: 480, + errorBuilder: (_, _, _) => _fileFallbackLarge(name), + ) + : _fileFallbackLarge(name), + ); + }, + ); + } + + Widget _buildTextPreview(BuildContext context) => Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + share.text!, + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), + ), + ); + + Widget _fileFallbackLarge(String name) => Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.insert_drive_file_outlined, size: 64), + const SizedBox(height: 8), + Text( + name, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ); +} diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 8939266..0eb72c8 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -153,10 +153,10 @@ class _ChatListViewState extends State<_ChatListView> { ) { if (username == null || !context.mounted) return; ConfirmDialog( - title: 'Chat starten', + title: 'Talk-Chat starten', content: - "Möchtest du einen Chat mit Nutzer '$username' starten?", - confirmButton: 'Chat starten', + "Möchtest du einen Talk-Chat mit Nutzer '$username' starten?", + confirmButton: 'Talk-Chat starten', onConfirmAsync: () => bloc.createDirectChat(username), ).asDialog(context); }); diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart index 9f36bf7..768daba 100644 --- a/lib/view/pages/talk/search_chat.dart +++ b/lib/view/pages/talk/search_chat.dart @@ -5,8 +5,9 @@ import 'widgets/chat_tile.dart'; class SearchChat extends SearchDelegate { List chats; + final void Function(GetRoomResponseObject room)? onTapOverride; - SearchChat(this.chats); + SearchChat(this.chats, {this.onTapOverride}); @override List? buildActions(BuildContext context) => [ @@ -34,7 +35,11 @@ class SearchChat extends SearchDelegate { itemCount: items.length, itemBuilder: (context, index) { var item = items.elementAt(index); - return ChatTile(data: item, disableContextActions: true); + return ChatTile( + data: item, + disableContextActions: true, + onTapOverride: onTapOverride, + ); }, ); } diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index 7e50a9e..9e5079f 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../extensions/date_time.dart'; import '../../../../extensions/text.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../utils/download_manager.dart'; import '../../../../widget/confirm_dialog.dart'; @@ -90,7 +91,14 @@ class _ChatBubbleState extends State if (status is DownloadDone) { DownloadManager.instance.clear(job.remotePath); _detachJob(); - AppRoutes.openFileViewer(context, status.localPath); + final talkFile = message.file; + AppRoutes.openFileViewer( + context, + status.localPath, + remoteFile: talkFile != null + ? RemoteFileRef.fromTalk(talkFile) + : null, + ); setState(() {}); } else if (status is DownloadFailed) { final message = status.message; diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 227490b..1435914 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -1,5 +1,4 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -9,15 +8,28 @@ import '../../../../api/marianumcloud/talk/react_message/react_message.dart'; import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; +import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; +RichObjectString? _attachedFile(GetChatResponseObject bubbleData) { + final file = bubbleData.messageParameters?['file']; + if (file == null || + file.path == null || + file.type != RichObjectStringObjectType.file) { + return null; + } + return file; +} + /// Long-press / double-tap options dialog for a single chat message bubble. /// The hosting [ChatBubble] keeps responsibility for rendering the bubble; /// this file owns the modal interactions (react, reply, copy, delete, ...). @@ -36,6 +48,7 @@ void showChatMessageOptionsDialog( DateTime.fromMillisecondsSinceEpoch( bubbleData.timestamp * 1000, ).add(const Duration(hours: 6)).isAfter(DateTime.now()); + final attachedFile = _attachedFile(bubbleData); showDetailsBottomSheet( context, @@ -79,13 +92,52 @@ void showChatMessageOptionsDialog( Navigator.of(sheetCtx).pop(); }, ), - if (!kReleaseMode && + if (attachedFile != null) + ListTile( + leading: const Icon(Icons.cloud_outlined), + title: const Text('In Cloud speichern'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + AppRoutes.openInternalSaveToFolder( + parentContext, + RemoteFileRef.fromTalk(attachedFile), + ); + }, + ), + if (canReact && (bubbleData.message != '{file}' || attachedFile != null)) + ListTile( + leading: const Icon(Icons.forward_outlined), + title: const Text('Weiterleiten'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + AppRoutes.openForwardMessageToChat( + parentContext, + text: bubbleData.message == '{file}' ? null : bubbleData.message, + file: attachedFile != null + ? RemoteFileRef.fromTalk(attachedFile) + : null, + ); + }, + ), + if (canReact && !isSender && - chatData.type != GetRoomResponseObjectConversationType.oneToOne) + chatData.type != GetRoomResponseObjectConversationType.oneToOne && + bubbleData.actorType == + GetRoomResponseObjectMessageActorType.user) ListTile( leading: const Icon(Icons.sms_outlined), - title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), - onTap: () => Navigator.of(sheetCtx).pop(), + title: Text('Private Nachricht an ${bubbleData.actorDisplayName}'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + _openOrCreateDirectChat( + parentContext, + actorId: bubbleData.actorId, + actorDisplayName: bubbleData.actorDisplayName, + ); + }, ), if (canDelete) AsyncListTile( @@ -101,6 +153,60 @@ void showChatMessageOptionsDialog( ); } +void _openOrCreateDirectChat( + BuildContext context, { + required String actorId, + required String actorDisplayName, +}) { + final chatListBloc = context.read(); + + GetRoomResponseObject? findExisting() { + final rooms = chatListBloc.state.data?.rooms; + if (rooms == null) return null; + for (final room in rooms.data) { + if (room.type == GetRoomResponseObjectConversationType.oneToOne && + room.name == actorId) { + return room; + } + } + return null; + } + + void switchToChat(GetRoomResponseObject room) { + // Pop the current ChatView before swapping the global ChatBloc token — + // otherwise the previous group chat stays mounted in the back-stack and + // would render empty after a back-swipe (currentToken no longer matches). + Navigator.of(context).popUntil((route) => route.isFirst); + AppRoutes.openChatByToken(context, room.token); + } + + final existing = findExisting(); + if (existing != null) { + switchToChat(existing); + return; + } + + ConfirmDialog( + title: 'Privatchat starten?', + content: + 'Es existiert noch kein Privatchat mit $actorDisplayName. ' + 'Soll einer erstellt werden?', + confirmButton: 'Erstellen', + onConfirmAsync: () async { + await chatListBloc.createDirectChat(actorId); + final created = findExisting(); + if (created == null) { + throw Exception( + 'Privatchat konnte nach dem Erstellen nicht gefunden werden.', + ); + } + if (context.mounted) { + switchToChat(created); + } + }, + ).asDialog(context); +} + class _ReactionsRow extends StatefulWidget { final String chatToken; final int messageId; diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index f4067b8..26dd9db 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -1,15 +1,13 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import '../../../../api/marianumcloud/files_sharing/file_sharing_api.dart'; -import '../../../../api/marianumcloud/files_sharing/file_sharing_api_params.dart'; import '../../../../api/marianumcloud/talk/send_message/send_message.dart'; import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart'; +import '../../../../api/marianumcloud/talk/share_files_to_chat.dart'; import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -36,30 +34,21 @@ class _ChatTextfieldState extends State { final AsyncActionController _sendController = AsyncActionController(); String? _sendError; - void share(String shareFolder, List filePaths) { - for (final element in filePaths) { - final fileName = element.split(Platform.pathSeparator).last; - FileSharingApi() - .share( - FileSharingApiParams( - shareType: 10, - shareWith: widget.sendToToken, - path: '$shareFolder/$fileName', - ), - ) - .then((_) { - if (mounted) context.read().refresh(); - }); - } + void share(List uploadedRemotePaths) { + shareFilesToChat( + token: widget.sendToToken, + remoteFilePaths: uploadedRemotePaths, + ).then((_) { + if (mounted) context.read().refresh(); + }); } Future mediaUpload(List? paths) async { if (paths == null) return; - const shareFolder = 'MarianumMobile'; unawaited( WebdavApi.webdav.then( - (webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')), + (webdav) => webdav.mkcol(PathUri.parse('/$talkShareFolder')), ), ); @@ -70,8 +59,8 @@ class _ChatTextfieldState extends State { withNavBar: false, screen: FilesUploadDialog( filePaths: paths, - remotePath: shareFolder, - onUploadFinished: (uploaded) => share(shareFolder, uploaded), + remotePath: talkShareFolder, + onUploadFinished: share, uniqueNames: true, ), ), diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index ddb0127..bea3098 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -25,11 +25,17 @@ class ChatTile extends StatefulWidget { final bool disableContextActions; final bool hasDraft; + /// When set, replaces the default tap-into-chat behaviour. Used by the + /// share-intent picker to surface the room selection without opening the + /// chat view itself. + final void Function(GetRoomResponseObject room)? onTapOverride; + const ChatTile({ super.key, required this.data, this.disableContextActions = false, this.hasDraft = false, + this.onTapOverride, }); @override @@ -143,6 +149,10 @@ class _ChatTileState extends State { ), ), onTap: () { + if (widget.onTapOverride != null) { + widget.onTapOverride!(widget.data); + return; + } if (selfUsername == null) return; unawaited(_setCurrentAsRead()); final view = ChatView( @@ -197,11 +207,11 @@ class _ChatTileState extends State { ), ListTile( leading: const Icon(Icons.delete_outline), - title: const Text('Konversation verlassen'), + title: const Text('Talk-Chat verlassen'), onTap: () { Navigator.of(sheetCtx).pop(); ConfirmDialog( - title: 'Chat verlassen', + title: 'Talk-Chat verlassen', content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', confirmButton: 'Verlassen', diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart index 6440bad..9d3ab72 100644 --- a/lib/widget/file_viewer.dart +++ b/lib/widget/file_viewer.dart @@ -11,6 +11,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import '../routing/app_routes.dart'; +import '../share_intent/remote_file_ref.dart'; import '../state/app/modules/settings/bloc/settings_cubit.dart'; import 'info_dialog.dart'; import 'placeholder_view.dart'; @@ -19,13 +20,24 @@ import 'share_position_origin.dart'; class FileViewer extends StatefulWidget { final String path; final bool openExternal; - const FileViewer({super.key, required this.path, this.openExternal = false}); + + /// When set, enables the in-app actions "An Chat senden" and "In Dateien + /// speichern" — these need a server-side reference, not the local cache + /// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer). + final RemoteFileRef? remoteFile; + + const FileViewer({ + super.key, + required this.path, + this.openExternal = false, + this.remoteFile, + }); @override State createState() => _FileViewerState(); } -enum FileViewingActions { openExternal, share, save } +enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud } /// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal /// LayoutBuilder calls `localToGlobal` during build, which asserts when an @@ -110,6 +122,16 @@ class _FileViewerState extends State { context, widget.path, openExternal: true, + remoteFile: widget.remoteFile, + ); + break; + case FileViewingActions.sendToChat: + AppRoutes.openInternalShareToChat(context, widget.remoteFile!); + break; + case FileViewingActions.saveToCloud: + AppRoutes.openInternalSaveToFolder( + context, + widget.remoteFile!, ); break; case FileViewingActions.share: @@ -154,6 +176,24 @@ class _FileViewerState extends State { dense: true, ), ), + if (widget.remoteFile != null) ...[ + const PopupMenuItem( + value: FileViewingActions.sendToChat, + child: ListTile( + leading: Icon(Icons.chat_bubble_outline), + title: Text('An Talk-Chat senden'), + dense: true, + ), + ), + const PopupMenuItem( + value: FileViewingActions.saveToCloud, + child: ListTile( + leading: Icon(Icons.cloud_outlined), + title: Text('In Cloud speichern'), + dense: true, + ), + ), + ], const PopupMenuItem( value: FileViewingActions.share, child: ListTile( diff --git a/lib/widget_data/widget_data.dart b/lib/widget_data/widget_data.dart new file mode 100644 index 0000000..6af2ee2 --- /dev/null +++ b/lib/widget_data/widget_data.dart @@ -0,0 +1,76 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'widget_data.freezed.dart'; +part 'widget_data.g.dart'; + +/// Status mirror of [LessonStatus] in +/// `lib/view/pages/timetable/data/lesson_status.dart`. Native widget code +/// switches on the string form, so the JSON name MUST stay stable. +enum WidgetLessonStatus { + regular, + ongoing, + past, + cancelled, + irregular, + teacherChanged, + event, +} + +@freezed +abstract class WidgetLesson with _$WidgetLesson { + const factory WidgetLesson({ + required DateTime start, + required DateTime end, + required String subjectShort, + String? subjectLong, + String? room, + String? teacher, + String? originalTeacher, + required WidgetLessonStatus status, + String? customColor, + @Default(0) int siblingCount, + }) = _WidgetLesson; + + factory WidgetLesson.fromJson(Map json) => + _$WidgetLessonFromJson(json); +} + +@freezed +abstract class WidgetPeriod with _$WidgetPeriod { + const factory WidgetPeriod({ + /// Webuntis period name — typically the lesson number as string ("1", + /// "2", "3", …). Native renderers append a trailing "." for display. + required String name, + /// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in + /// Kotlin/Swift without re-parsing time strings. + required int startMinutes, + required int endMinutes, + /// Position on the **virtual** time axis used by the widget. Small + /// between-lesson gaps are squeezed out so periods stack flush; only + /// big breaks (> 5 min) remain as visible gaps. Computed by the + /// mapper so native renderers don't have to redo the maths. + required int virtualStartMinutes, + required int virtualEndMinutes, + }) = _WidgetPeriod; + + factory WidgetPeriod.fromJson(Map json) => + _$WidgetPeriodFromJson(json); +} + +@freezed +abstract class WidgetTimetableData with _$WidgetTimetableData { + const factory WidgetTimetableData({ + required DateTime fetchedAt, + /// The day this widget snapshot is "about" — display anchor. + /// For the day variant: the rendered school day. + /// For the week variant: the Monday of the rendered school week. + required DateTime anchorDate, + required List lessons, + @Default([]) List periods, + @Default(false) bool isHoliday, + String? holidayName, + }) = _WidgetTimetableData; + + factory WidgetTimetableData.fromJson(Map json) => + _$WidgetTimetableDataFromJson(json); +} diff --git a/lib/widget_data/widget_data.freezed.dart b/lib/widget_data/widget_data.freezed.dart new file mode 100644 index 0000000..b0faca4 --- /dev/null +++ b/lib/widget_data/widget_data.freezed.dart @@ -0,0 +1,891 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'widget_data.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$WidgetLesson { + + DateTime get start; DateTime get end; String get subjectShort; String? get subjectLong; String? get room; String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor; int get siblingCount; +/// Create a copy of WidgetLesson +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WidgetLessonCopyWith get copyWith => _$WidgetLessonCopyWithImpl(this as WidgetLesson, _$identity); + + /// Serializes this WidgetLesson to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount); + +@override +String toString() { + return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)'; +} + + +} + +/// @nodoc +abstract mixin class $WidgetLessonCopyWith<$Res> { + factory $WidgetLessonCopyWith(WidgetLesson value, $Res Function(WidgetLesson) _then) = _$WidgetLessonCopyWithImpl; +@useResult +$Res call({ + DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount +}); + + + + +} +/// @nodoc +class _$WidgetLessonCopyWithImpl<$Res> + implements $WidgetLessonCopyWith<$Res> { + _$WidgetLessonCopyWithImpl(this._self, this._then); + + final WidgetLesson _self; + final $Res Function(WidgetLesson) _then; + +/// Create a copy of WidgetLesson +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) { + return _then(_self.copyWith( +start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable +as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable +as DateTime,subjectShort: null == subjectShort ? _self.subjectShort : subjectShort // ignore: cast_nullable_to_non_nullable +as String,subjectLong: freezed == subjectLong ? _self.subjectLong : subjectLong // ignore: cast_nullable_to_non_nullable +as String?,room: freezed == room ? _self.room : room // ignore: cast_nullable_to_non_nullable +as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast_nullable_to_non_nullable +as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable +as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [WidgetLesson]. +extension WidgetLessonPatterns on WidgetLesson { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _WidgetLesson value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _WidgetLesson() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _WidgetLesson value) $default,){ +final _that = this; +switch (_that) { +case _WidgetLesson(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _WidgetLesson value)? $default,){ +final _that = this; +switch (_that) { +case _WidgetLesson() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _WidgetLesson() when $default != null: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount) $default,) {final _that = this; +switch (_that) { +case _WidgetLesson(): +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,) {final _that = this; +switch (_that) { +case _WidgetLesson() when $default != null: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _WidgetLesson implements WidgetLesson { + const _WidgetLesson({required this.start, required this.end, required this.subjectShort, this.subjectLong, this.room, this.teacher, this.originalTeacher, required this.status, this.customColor, this.siblingCount = 0}); + factory _WidgetLesson.fromJson(Map json) => _$WidgetLessonFromJson(json); + +@override final DateTime start; +@override final DateTime end; +@override final String subjectShort; +@override final String? subjectLong; +@override final String? room; +@override final String? teacher; +@override final String? originalTeacher; +@override final WidgetLessonStatus status; +@override final String? customColor; +@override@JsonKey() final int siblingCount; + +/// Create a copy of WidgetLesson +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WidgetLessonCopyWith<_WidgetLesson> get copyWith => __$WidgetLessonCopyWithImpl<_WidgetLesson>(this, _$identity); + +@override +Map toJson() { + return _$WidgetLessonToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount); + +@override +String toString() { + return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)'; +} + + +} + +/// @nodoc +abstract mixin class _$WidgetLessonCopyWith<$Res> implements $WidgetLessonCopyWith<$Res> { + factory _$WidgetLessonCopyWith(_WidgetLesson value, $Res Function(_WidgetLesson) _then) = __$WidgetLessonCopyWithImpl; +@override @useResult +$Res call({ + DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount +}); + + + + +} +/// @nodoc +class __$WidgetLessonCopyWithImpl<$Res> + implements _$WidgetLessonCopyWith<$Res> { + __$WidgetLessonCopyWithImpl(this._self, this._then); + + final _WidgetLesson _self; + final $Res Function(_WidgetLesson) _then; + +/// Create a copy of WidgetLesson +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) { + return _then(_WidgetLesson( +start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable +as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable +as DateTime,subjectShort: null == subjectShort ? _self.subjectShort : subjectShort // ignore: cast_nullable_to_non_nullable +as String,subjectLong: freezed == subjectLong ? _self.subjectLong : subjectLong // ignore: cast_nullable_to_non_nullable +as String?,room: freezed == room ? _self.room : room // ignore: cast_nullable_to_non_nullable +as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast_nullable_to_non_nullable +as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable +as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable +as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + + +/// @nodoc +mixin _$WidgetPeriod { + +/// Webuntis period name — typically the lesson number as string ("1", +/// "2", "3", …). Native renderers append a trailing "." for display. + String get name;/// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in +/// Kotlin/Swift without re-parsing time strings. + int get startMinutes; int get endMinutes;/// Position on the **virtual** time axis used by the widget. Small +/// between-lesson gaps are squeezed out so periods stack flush; only +/// big breaks (> 5 min) remain as visible gaps. Computed by the +/// mapper so native renderers don't have to redo the maths. + int get virtualStartMinutes; int get virtualEndMinutes; +/// Create a copy of WidgetPeriod +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WidgetPeriodCopyWith get copyWith => _$WidgetPeriodCopyWithImpl(this as WidgetPeriod, _$identity); + + /// Serializes this WidgetPeriod to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); + +@override +String toString() { + return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; +} + + +} + +/// @nodoc +abstract mixin class $WidgetPeriodCopyWith<$Res> { + factory $WidgetPeriodCopyWith(WidgetPeriod value, $Res Function(WidgetPeriod) _then) = _$WidgetPeriodCopyWithImpl; +@useResult +$Res call({ + String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes +}); + + + + +} +/// @nodoc +class _$WidgetPeriodCopyWithImpl<$Res> + implements $WidgetPeriodCopyWith<$Res> { + _$WidgetPeriodCopyWithImpl(this._self, this._then); + + final WidgetPeriod _self; + final $Res Function(WidgetPeriod) _then; + +/// Create a copy of WidgetPeriod +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? startMinutes = null,Object? endMinutes = null,Object? virtualStartMinutes = null,Object? virtualEndMinutes = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,startMinutes: null == startMinutes ? _self.startMinutes : startMinutes // ignore: cast_nullable_to_non_nullable +as int,endMinutes: null == endMinutes ? _self.endMinutes : endMinutes // ignore: cast_nullable_to_non_nullable +as int,virtualStartMinutes: null == virtualStartMinutes ? _self.virtualStartMinutes : virtualStartMinutes // ignore: cast_nullable_to_non_nullable +as int,virtualEndMinutes: null == virtualEndMinutes ? _self.virtualEndMinutes : virtualEndMinutes // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [WidgetPeriod]. +extension WidgetPeriodPatterns on WidgetPeriod { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _WidgetPeriod value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _WidgetPeriod() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _WidgetPeriod value) $default,){ +final _that = this; +switch (_that) { +case _WidgetPeriod(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _WidgetPeriod value)? $default,){ +final _that = this; +switch (_that) { +case _WidgetPeriod() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _WidgetPeriod() when $default != null: +return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes) $default,) {final _that = this; +switch (_that) { +case _WidgetPeriod(): +return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes)? $default,) {final _that = this; +switch (_that) { +case _WidgetPeriod() when $default != null: +return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _WidgetPeriod implements WidgetPeriod { + const _WidgetPeriod({required this.name, required this.startMinutes, required this.endMinutes, required this.virtualStartMinutes, required this.virtualEndMinutes}); + factory _WidgetPeriod.fromJson(Map json) => _$WidgetPeriodFromJson(json); + +/// Webuntis period name — typically the lesson number as string ("1", +/// "2", "3", …). Native renderers append a trailing "." for display. +@override final String name; +/// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in +/// Kotlin/Swift without re-parsing time strings. +@override final int startMinutes; +@override final int endMinutes; +/// Position on the **virtual** time axis used by the widget. Small +/// between-lesson gaps are squeezed out so periods stack flush; only +/// big breaks (> 5 min) remain as visible gaps. Computed by the +/// mapper so native renderers don't have to redo the maths. +@override final int virtualStartMinutes; +@override final int virtualEndMinutes; + +/// Create a copy of WidgetPeriod +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WidgetPeriodCopyWith<_WidgetPeriod> get copyWith => __$WidgetPeriodCopyWithImpl<_WidgetPeriod>(this, _$identity); + +@override +Map toJson() { + return _$WidgetPeriodToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); + +@override +String toString() { + return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; +} + + +} + +/// @nodoc +abstract mixin class _$WidgetPeriodCopyWith<$Res> implements $WidgetPeriodCopyWith<$Res> { + factory _$WidgetPeriodCopyWith(_WidgetPeriod value, $Res Function(_WidgetPeriod) _then) = __$WidgetPeriodCopyWithImpl; +@override @useResult +$Res call({ + String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes +}); + + + + +} +/// @nodoc +class __$WidgetPeriodCopyWithImpl<$Res> + implements _$WidgetPeriodCopyWith<$Res> { + __$WidgetPeriodCopyWithImpl(this._self, this._then); + + final _WidgetPeriod _self; + final $Res Function(_WidgetPeriod) _then; + +/// Create a copy of WidgetPeriod +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? startMinutes = null,Object? endMinutes = null,Object? virtualStartMinutes = null,Object? virtualEndMinutes = null,}) { + return _then(_WidgetPeriod( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,startMinutes: null == startMinutes ? _self.startMinutes : startMinutes // ignore: cast_nullable_to_non_nullable +as int,endMinutes: null == endMinutes ? _self.endMinutes : endMinutes // ignore: cast_nullable_to_non_nullable +as int,virtualStartMinutes: null == virtualStartMinutes ? _self.virtualStartMinutes : virtualStartMinutes // ignore: cast_nullable_to_non_nullable +as int,virtualEndMinutes: null == virtualEndMinutes ? _self.virtualEndMinutes : virtualEndMinutes // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + + +/// @nodoc +mixin _$WidgetTimetableData { + + DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor. +/// For the day variant: the rendered school day. +/// For the week variant: the Monday of the rendered school week. + DateTime get anchorDate; List get lessons; List get periods; bool get isHoliday; String? get holidayName; +/// Create a copy of WidgetTimetableData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$WidgetTimetableDataCopyWith get copyWith => _$WidgetTimetableDataCopyWithImpl(this as WidgetTimetableData, _$identity); + + /// Serializes this WidgetTimetableData to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName); + +@override +String toString() { + return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)'; +} + + +} + +/// @nodoc +abstract mixin class $WidgetTimetableDataCopyWith<$Res> { + factory $WidgetTimetableDataCopyWith(WidgetTimetableData value, $Res Function(WidgetTimetableData) _then) = _$WidgetTimetableDataCopyWithImpl; +@useResult +$Res call({ + DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName +}); + + + + +} +/// @nodoc +class _$WidgetTimetableDataCopyWithImpl<$Res> + implements $WidgetTimetableDataCopyWith<$Res> { + _$WidgetTimetableDataCopyWithImpl(this._self, this._then); + + final WidgetTimetableData _self; + final $Res Function(WidgetTimetableData) _then; + +/// Create a copy of WidgetTimetableData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) { + return _then(_self.copyWith( +fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable +as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable +as DateTime,lessons: null == lessons ? _self.lessons : lessons // ignore: cast_nullable_to_non_nullable +as List,periods: null == periods ? _self.periods : periods // ignore: cast_nullable_to_non_nullable +as List,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable +as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [WidgetTimetableData]. +extension WidgetTimetableDataPatterns on WidgetTimetableData { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _WidgetTimetableData value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _WidgetTimetableData() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _WidgetTimetableData value) $default,){ +final _that = this; +switch (_that) { +case _WidgetTimetableData(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _WidgetTimetableData value)? $default,){ +final _that = this; +switch (_that) { +case _WidgetTimetableData() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _WidgetTimetableData() when $default != null: +return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName) $default,) {final _that = this; +switch (_that) { +case _WidgetTimetableData(): +return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName)? $default,) {final _that = this; +switch (_that) { +case _WidgetTimetableData() when $default != null: +return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _WidgetTimetableData implements WidgetTimetableData { + const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List lessons, final List periods = const [], this.isHoliday = false, this.holidayName}): _lessons = lessons,_periods = periods; + factory _WidgetTimetableData.fromJson(Map json) => _$WidgetTimetableDataFromJson(json); + +@override final DateTime fetchedAt; +/// The day this widget snapshot is "about" — display anchor. +/// For the day variant: the rendered school day. +/// For the week variant: the Monday of the rendered school week. +@override final DateTime anchorDate; + final List _lessons; +@override List get lessons { + if (_lessons is EqualUnmodifiableListView) return _lessons; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_lessons); +} + + final List _periods; +@override@JsonKey() List get periods { + if (_periods is EqualUnmodifiableListView) return _periods; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_periods); +} + +@override@JsonKey() final bool isHoliday; +@override final String? holidayName; + +/// Create a copy of WidgetTimetableData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$WidgetTimetableDataCopyWith<_WidgetTimetableData> get copyWith => __$WidgetTimetableDataCopyWithImpl<_WidgetTimetableData>(this, _$identity); + +@override +Map toJson() { + return _$WidgetTimetableDataToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName); + +@override +String toString() { + return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)'; +} + + +} + +/// @nodoc +abstract mixin class _$WidgetTimetableDataCopyWith<$Res> implements $WidgetTimetableDataCopyWith<$Res> { + factory _$WidgetTimetableDataCopyWith(_WidgetTimetableData value, $Res Function(_WidgetTimetableData) _then) = __$WidgetTimetableDataCopyWithImpl; +@override @useResult +$Res call({ + DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName +}); + + + + +} +/// @nodoc +class __$WidgetTimetableDataCopyWithImpl<$Res> + implements _$WidgetTimetableDataCopyWith<$Res> { + __$WidgetTimetableDataCopyWithImpl(this._self, this._then); + + final _WidgetTimetableData _self; + final $Res Function(_WidgetTimetableData) _then; + +/// Create a copy of WidgetTimetableData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) { + return _then(_WidgetTimetableData( +fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable +as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable +as DateTime,lessons: null == lessons ? _self._lessons : lessons // ignore: cast_nullable_to_non_nullable +as List,periods: null == periods ? _self._periods : periods // ignore: cast_nullable_to_non_nullable +as List,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable +as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/widget_data/widget_data.g.dart b/lib/widget_data/widget_data.g.dart new file mode 100644 index 0000000..8a7afeb --- /dev/null +++ b/lib/widget_data/widget_data.g.dart @@ -0,0 +1,90 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'widget_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_WidgetLesson _$WidgetLessonFromJson(Map json) => + _WidgetLesson( + start: DateTime.parse(json['start'] as String), + end: DateTime.parse(json['end'] as String), + subjectShort: json['subjectShort'] as String, + subjectLong: json['subjectLong'] as String?, + room: json['room'] as String?, + teacher: json['teacher'] as String?, + originalTeacher: json['originalTeacher'] as String?, + status: $enumDecode(_$WidgetLessonStatusEnumMap, json['status']), + customColor: json['customColor'] as String?, + siblingCount: (json['siblingCount'] as num?)?.toInt() ?? 0, + ); + +Map _$WidgetLessonToJson(_WidgetLesson instance) => + { + 'start': instance.start.toIso8601String(), + 'end': instance.end.toIso8601String(), + 'subjectShort': instance.subjectShort, + 'subjectLong': instance.subjectLong, + 'room': instance.room, + 'teacher': instance.teacher, + 'originalTeacher': instance.originalTeacher, + 'status': _$WidgetLessonStatusEnumMap[instance.status]!, + 'customColor': instance.customColor, + 'siblingCount': instance.siblingCount, + }; + +const _$WidgetLessonStatusEnumMap = { + WidgetLessonStatus.regular: 'regular', + WidgetLessonStatus.ongoing: 'ongoing', + WidgetLessonStatus.past: 'past', + WidgetLessonStatus.cancelled: 'cancelled', + WidgetLessonStatus.irregular: 'irregular', + WidgetLessonStatus.teacherChanged: 'teacherChanged', + WidgetLessonStatus.event: 'event', +}; + +_WidgetPeriod _$WidgetPeriodFromJson(Map json) => + _WidgetPeriod( + name: json['name'] as String, + startMinutes: (json['startMinutes'] as num).toInt(), + endMinutes: (json['endMinutes'] as num).toInt(), + virtualStartMinutes: (json['virtualStartMinutes'] as num).toInt(), + virtualEndMinutes: (json['virtualEndMinutes'] as num).toInt(), + ); + +Map _$WidgetPeriodToJson(_WidgetPeriod instance) => + { + 'name': instance.name, + 'startMinutes': instance.startMinutes, + 'endMinutes': instance.endMinutes, + 'virtualStartMinutes': instance.virtualStartMinutes, + 'virtualEndMinutes': instance.virtualEndMinutes, + }; + +_WidgetTimetableData _$WidgetTimetableDataFromJson(Map json) => + _WidgetTimetableData( + fetchedAt: DateTime.parse(json['fetchedAt'] as String), + anchorDate: DateTime.parse(json['anchorDate'] as String), + lessons: (json['lessons'] as List) + .map((e) => WidgetLesson.fromJson(e as Map)) + .toList(), + periods: + (json['periods'] as List?) + ?.map((e) => WidgetPeriod.fromJson(e as Map)) + .toList() ?? + const [], + isHoliday: json['isHoliday'] as bool? ?? false, + holidayName: json['holidayName'] as String?, + ); + +Map _$WidgetTimetableDataToJson( + _WidgetTimetableData instance, +) => { + 'fetchedAt': instance.fetchedAt.toIso8601String(), + 'anchorDate': instance.anchorDate.toIso8601String(), + 'lessons': instance.lessons, + 'periods': instance.periods, + 'isHoliday': instance.isHoliday, + 'holidayName': instance.holidayName, +}; diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart new file mode 100644 index 0000000..812a057 --- /dev/null +++ b/lib/widget_data/widget_data_mapper.dart @@ -0,0 +1,472 @@ +import 'dart:developer'; + +import 'package:rrule/rrule.dart'; + +import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../view/pages/timetable/data/lesson_period_schedule.dart'; +import '../view/pages/timetable/data/lesson_status.dart'; +import '../view/pages/timetable/data/webuntis_time.dart'; +import 'widget_data.dart'; + +class WidgetDataMapper { + /// After 17:00 the user's question shifts from "what's left today" to + /// "what's tomorrow", so the day-widget rolls forward. + static const int _dayWidgetCutoffHour = 17; + + static const _weekend = {DateTime.saturday, DateTime.sunday}; + + static DateTime resolveDayAnchor(DateTime now) { + var candidate = DateTime(now.year, now.month, now.day); + final shiftToTomorrow = + now.hour >= _dayWidgetCutoffHour || _weekend.contains(now.weekday); + if (shiftToTomorrow) { + candidate = candidate.add(const Duration(days: 1)); + } + while (_weekend.contains(candidate.weekday)) { + candidate = candidate.add(const Duration(days: 1)); + } + return candidate; + } + + static DateTime resolveWeekAnchor(DateTime now) { + final anchor = resolveDayAnchor(now); + final monday = anchor.subtract(Duration(days: anchor.weekday - 1)); + return DateTime(monday.year, monday.month, monday.day); + } + + static WidgetTimetableData buildDayData({ + required DateTime now, + required Iterable lessons, + required GetSubjectsResponse? subjects, + required GetRoomsResponse? rooms, + required GetHolidaysResponse? holidays, + GetTimegridUnitsResponse? timegrid, + GetCustomTimetableEventResponse? customEvents, + bool connectDoubleLessons = true, + }) { + final anchor = resolveDayAnchor(now); + final holiday = _findHoliday(anchor, holidays); + final dayStart = anchor; + final dayEnd = anchor.add(const Duration(days: 1)); + final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList(); + final source = connectDoubleLessons + ? _mergeAdjacentLessons(dayLessons) + : dayLessons; + final mapped = [ + ...source.map((l) => _mapLesson(l, now, subjects, rooms)), + ..._expandCustomEvents(customEvents, dayStart, dayEnd), + ]..sort((a, b) => a.start.compareTo(b.start)); + return WidgetTimetableData( + fetchedAt: now, + anchorDate: anchor, + lessons: _resolveCollisions(mapped), + periods: _resolvePeriods(timegrid), + isHoliday: holiday != null, + holidayName: holiday?.longName, + ); + } + + static WidgetTimetableData buildWeekData({ + required DateTime now, + required Iterable lessons, + required GetSubjectsResponse? subjects, + required GetRoomsResponse? rooms, + required GetHolidaysResponse? holidays, + GetTimegridUnitsResponse? timegrid, + GetCustomTimetableEventResponse? customEvents, + bool connectDoubleLessons = true, + }) { + final anchor = resolveWeekAnchor(now); + final endExclusive = anchor.add(const Duration(days: 5)); + final weekLessons = lessons.where((l) { + final dt = WebuntisTime.parse(l.date, l.startTime); + return !dt.isBefore(anchor) && dt.isBefore(endExclusive); + }).toList(); + // Per-day merge: otherwise a 4th-period lesson on Mon would collapse with + // a 1st-period lesson on Tue if subject/teacher match. + final source = connectDoubleLessons + ? _mergePerDay(weekLessons) + : weekLessons; + final mapped = [ + ...source.map((l) => _mapLesson(l, now, subjects, rooms)), + ..._expandCustomEvents(customEvents, anchor, endExclusive), + ]..sort((a, b) => a.start.compareTo(b.start)); + return WidgetTimetableData( + fetchedAt: now, + anchorDate: anchor, + lessons: _resolveCollisions(mapped), + periods: _resolvePeriods(timegrid), + ); + } + + /// cancelled (0) < event (1) < regular (2) — events replace cancelled + /// lessons but lose to real ones, leaving a `+1` hint on the survivor. + static int _priority(WidgetLessonStatus status) => switch (status) { + WidgetLessonStatus.cancelled => 0, + WidgetLessonStatus.event => 1, + _ => 2, + }; + + static List _resolveCollisions(List lessons) { + if (lessons.length <= 1) return lessons; + + bool overlaps(WidgetLesson l, WidgetLesson other) => + l != other && l.start.isBefore(other.end) && l.end.isAfter(other.start); + + // Index-based: a long event covering several regulars must bump *every* + // covered lesson, not just the first overlap. + final dropped = List.filled(lessons.length, false); + final bumps = List.filled(lessons.length, 0); + for (var i = 0; i < lessons.length; i++) { + final l = lessons[i]; + final myPrio = _priority(l.status); + final overrideIdxs = []; + for (var j = 0; j < lessons.length; j++) { + if (i == j) continue; + if (_priority(lessons[j].status) <= myPrio) continue; + if (!overlaps(l, lessons[j])) continue; + overrideIdxs.add(j); + } + if (overrideIdxs.isNotEmpty) { + dropped[i] = true; + if (l.status == WidgetLessonStatus.event) { + for (final idx in overrideIdxs) { + bumps[idx] += 1; + } + } + } + } + final filtered = []; + for (var i = 0; i < lessons.length; i++) { + if (dropped[i]) continue; + final l = lessons[i]; + filtered.add( + bumps[i] > 0 + ? l.copyWith(siblingCount: l.siblingCount + bumps[i]) + : l, + ); + } + if (filtered.length <= 1) return filtered; + + final groups = >{}; + for (final l in filtered) { + final key = + '${l.start.year}-${l.start.month}-${l.start.day}-${l.start.hour}-${l.start.minute}'; + groups.putIfAbsent(key, () => []).add(l); + } + final result = []; + for (final group in groups.values) { + if (group.length == 1) { + result.add(group.first); + continue; + } + final active = group + .where((l) => l.status != WidgetLessonStatus.cancelled) + .toList(); + if (active.isEmpty) { + result.addAll(group); + continue; + } + active.sort((a, b) => a.subjectShort.compareTo(b.subjectShort)); + // Additive — preserves the event-bump from the priority pass, otherwise + // a slot with another regular lesson AND a hidden event would show +1 + // instead of +2. + final keeper = active.first; + result.add( + keeper.copyWith( + siblingCount: keeper.siblingCount + active.length - 1, + ), + ); + } + return result..sort((a, b) => a.start.compareTo(b.start)); + } + + /// Gaps below this collapse to zero on the virtual axis so 45-min slots + /// stack flush; bigger gaps survive as visible Pause-blocks. + static const int _smallBreakThresholdMinutes = 5; + + static List _resolvePeriods( + GetTimegridUnitsResponse? timegrid, + ) { + final schedule = + (timegrid != null ? LessonPeriodSchedule.fromApi(timegrid) : null) ?? + LessonPeriodSchedule.fallback(); + final raw = schedule.periods + .map( + (p) => ( + name: p.name, + start: p.start.hour * 60 + p.start.minute, + end: p.end.hour * 60 + p.end.minute, + ), + ) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)); + + final result = []; + var virtualOffset = 0; + int? prevEnd; + for (final p in raw) { + if (prevEnd != null) { + final gap = p.start - prevEnd; + if (gap > _smallBreakThresholdMinutes) virtualOffset += gap; + } + final duration = p.end - p.start; + result.add( + WidgetPeriod( + name: p.name, + startMinutes: p.start, + endMinutes: p.end, + virtualStartMinutes: virtualOffset, + virtualEndMinutes: virtualOffset + duration, + ), + ); + virtualOffset += duration; + prevEnd = p.end; + } + return result; + } + + static List _mergePerDay( + List lessons, + ) { + final byDay = >{}; + for (final l in lessons) { + byDay.putIfAbsent(l.date, () => []).add(l); + } + return [for (final group in byDay.values) ..._mergeAdjacentLessons(group)]; + } + + /// Mirrors `TimetableAppointmentFactory._mergeAdjacentLessons` so the + /// widget shows the same merged blocks the in-app calendar does. + static List _mergeAdjacentLessons( + List input, { + Duration maxGap = const Duration(minutes: 5), + }) { + if (input.isEmpty) return const []; + final sorted = [...input]..sort( + (a, b) => WebuntisTime.parse( + a.date, + a.startTime, + ).compareTo(WebuntisTime.parse(b.date, b.startTime)), + ); + final merged = []; + for (final current in sorted) { + if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) { + merged.last.endTime = current.endTime; + } else { + merged.add(GetTimetableResponseObject.fromJson(current.toJson())); + } + } + return merged; + } + + static bool _canMerge( + GetTimetableResponseObject a, + GetTimetableResponseObject b, + Duration maxGap, + ) { + final aSubject = a.su.firstOrNull?.id; + final bSubject = b.su.firstOrNull?.id; + if (aSubject == null || bSubject == null || aSubject != bSubject) { + return false; + } + if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false; + if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false; + if (a.code != b.code) return false; + final gap = WebuntisTime.parse( + b.date, + b.startTime, + ).difference(WebuntisTime.parse(a.date, a.endTime)); + return !gap.isNegative && gap <= maxGap; + } + + static WidgetLesson _mapLesson( + GetTimetableResponseObject lesson, + DateTime now, + GetSubjectsResponse? subjects, + GetRoomsResponse? rooms, + ) { + final start = WebuntisTime.parse(lesson.date, lesson.startTime); + final end = WebuntisTime.parse(lesson.date, lesson.endTime); + final status = _mapStatus( + LessonStatusClassifier.classify(lesson, start, end, now), + ); + final subject = lesson.su.firstOrNull; + // Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall + // back to "Event" so the tile isn't just a dash. + final rawSubjectName = subject?.name.trim() ?? ''; + final subjectShort = rawSubjectName.isEmpty ? 'Event' : rawSubjectName; + String? subjectLong; + if (subjects != null && subject != null) { + final found = subjects.result.where((s) => s.id == subject.id).firstOrNull; + subjectLong = found?.longName; + } + subjectLong ??= subject?.longname; + final room = lesson.ro.firstOrNull; + var roomName = room?.name; + if (rooms != null && room != null) { + final resolved = + rooms.result.where((r) => r.id == room.id).firstOrNull?.name; + roomName = resolved ?? roomName; + } + final teacher = lesson.te.firstOrNull; + final teacherName = teacher?.id == 0 ? null : teacher?.name; + final originalTeacher = teacher?.orgname; + return WidgetLesson( + start: start, + end: end, + subjectShort: subjectShort, + subjectLong: subjectLong, + room: roomName, + teacher: teacherName, + originalTeacher: originalTeacher, + status: status, + ); + } + + static WidgetLessonStatus _mapStatus(LessonStatus status) { + switch (status) { + case LessonStatus.cancelled: + return WidgetLessonStatus.cancelled; + case LessonStatus.event: + return WidgetLessonStatus.event; + case LessonStatus.irregular: + return WidgetLessonStatus.irregular; + case LessonStatus.teacherChanged: + return WidgetLessonStatus.teacherChanged; + case LessonStatus.past: + return WidgetLessonStatus.past; + case LessonStatus.ongoing: + return WidgetLessonStatus.ongoing; + case LessonStatus.regular: + return WidgetLessonStatus.regular; + } + } + + static bool _onSameDay(GetTimetableResponseObject lesson, DateTime day) { + final dt = WebuntisTime.parse(lesson.date, lesson.startTime); + return dt.year == day.year && dt.month == day.month && dt.day == day.day; + } + + static GetHolidaysResponseObject? _findHoliday( + DateTime day, + GetHolidaysResponse? holidays, + ) { + if (holidays == null) return null; + final asInt = WebuntisTime.formatDate(day); + for (final h in holidays.result) { + if (asInt >= h.startDate && asInt <= h.endDate) return h; + } + return null; + } + + static Iterable _expandCustomEvents( + GetCustomTimetableEventResponse? customEvents, + DateTime rangeStart, + DateTime rangeEndExclusive, + ) sync* { + if (customEvents == null) return; + final rangeStartUtc = rangeStart.toUtc(); + final rangeEndUtc = rangeEndExclusive.toUtc(); + for (final event in customEvents.events) { + yield* _expandSingleEvent(event, rangeStartUtc, rangeEndUtc); + } + } + + static Iterable _expandSingleEvent( + CustomTimetableEvent event, + DateTime rangeStartUtc, + DateTime rangeEndUtc, + ) sync* { + final rule = event.rrule; + final duration = event.endDate.difference(event.startDate); + + if (rule.isEmpty) { + final startUtc = event.startDate.toUtc(); + if (startUtc.isBefore(rangeStartUtc) || + !startUtc.isBefore(rangeEndUtc)) { + return; + } + yield* _customEventToWidgetLessons(event, event.startDate, duration); + return; + } + + try { + final parsed = RecurrenceRule.fromString(rule); + final anchorUtc = event.startDate.toUtc(); + for (final occUtc in parsed.getInstances(start: anchorUtc)) { + if (!occUtc.isBefore(rangeEndUtc)) break; + if (occUtc.isBefore(rangeStartUtc)) continue; + final occLocal = occUtc.toLocal(); + final occStart = DateTime( + occLocal.year, + occLocal.month, + occLocal.day, + event.startDate.hour, + event.startDate.minute, + ); + yield* _customEventToWidgetLessons(event, occStart, duration); + } + } on Exception catch (e) { + log('Widget mapper: invalid rrule "$rule" on event ${event.id}: $e'); + } + } + + /// Splits multi-day events into one block per local calendar day, so each + /// affected day on the week-widget shows the event. All-day events + /// (start = end = midnight) collapse to a single 00:00–23:59 block. + static Iterable _customEventToWidgetLessons( + CustomTimetableEvent event, + DateTime occurrenceStart, + Duration duration, + ) sync* { + final title = event.title.trim(); + WidgetLesson buildBlock(DateTime start, DateTime end) => WidgetLesson( + start: start, + end: end, + subjectShort: title.isEmpty ? 'Termin' : title, + subjectLong: title.isEmpty ? null : title, + status: WidgetLessonStatus.event, + customColor: event.color, + ); + + final isAllDay = duration == Duration.zero && _isMidnight(event.startDate); + if (isAllDay) { + yield buildBlock( + occurrenceStart, + DateTime( + occurrenceStart.year, + occurrenceStart.month, + occurrenceStart.day, + 23, + 59, + ), + ); + return; + } + + final actualEnd = occurrenceStart.add(duration); + var segmentStart = occurrenceStart; + while (segmentStart.isBefore(actualEnd)) { + final nextMidnight = DateTime( + segmentStart.year, + segmentStart.month, + segmentStart.day, + ).add(const Duration(days: 1)); + final segmentEnd = actualEnd.isBefore(nextMidnight) + ? actualEnd + : nextMidnight.subtract(const Duration(minutes: 1)); + yield buildBlock(segmentStart, segmentEnd); + segmentStart = nextMidnight; + } + } + + static bool _isMidnight(DateTime d) => + d.hour == 0 && d.minute == 0 && d.second == 0; +} diff --git a/lib/widget_data/widget_navigation.dart b/lib/widget_data/widget_navigation.dart new file mode 100644 index 0000000..bdb8b92 --- /dev/null +++ b/lib/widget_data/widget_navigation.dart @@ -0,0 +1,24 @@ +import 'dart:developer'; + +import 'package:flutter/services.dart'; + +/// Android-only bridge: MainActivity stashes `widget_open_timetable=true` +/// from the launch Intent extra when a widget is tapped, Dart polls once +/// per app-resume to consume and route. iOS widgets simply launch the app +/// without a navigation hint (no widgetURL set) so this returns `false` +/// there via MissingPluginException. +class WidgetNavigation { + static const MethodChannel _channel = MethodChannel('eu.mhsl.marianum.widget'); + + static Future consumePendingTimetableTap() async { + try { + final raw = await _channel.invokeMethod('consumePendingNavigation'); + return raw ?? false; + } on MissingPluginException { + return false; + } on PlatformException catch (e) { + log('WidgetNavigation channel error: $e'); + return false; + } + } +} diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart new file mode 100644 index 0000000..c022260 --- /dev/null +++ b/lib/widget_data/widget_publisher.dart @@ -0,0 +1,77 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../storage/settings.dart'; +import 'widget_data_mapper.dart'; +import 'widget_sync.dart'; + +/// Pushes timetable state to the native widget whenever the foreground bloc +/// has fresh data, so the widget doesn't have to wait for the next periodic +/// background fetch. +class WidgetPublisher { + /// Debug-only "now" offset. Gated by [kDebugMode] so a stray non-zero + /// value cannot ship in release. + static const Duration debugTimeShift = Duration.zero; + + static DateTime widgetNow() => + kDebugMode ? DateTime.now().add(debugTimeShift) : DateTime.now(); + + static Future publishFromBlocState( + TimetableState state, { + Settings? settings, + }) async { + try { + final connectDouble = + settings?.timetableSettings.connectDoubleLessons ?? true; + // Mirror into widget storage so the background isolate sees the same + // value the user just toggled. + await WidgetSync.setConnectDoubleLessons(connectDouble); + await WidgetSync.setThemeMode(_themeName(settings?.appTheme)); + final lessons = state.getAllKnownLessons(); + final now = widgetNow(); + final dayData = WidgetDataMapper.buildDayData( + now: now, + lessons: lessons, + subjects: state.subjects, + rooms: state.rooms, + holidays: state.schoolHolidays, + timegrid: state.timegrid, + customEvents: state.customEvents, + connectDoubleLessons: connectDouble, + ); + final weekData = WidgetDataMapper.buildWeekData( + now: now, + lessons: lessons, + subjects: state.subjects, + rooms: state.rooms, + holidays: state.schoolHolidays, + timegrid: state.timegrid, + customEvents: state.customEvents, + connectDoubleLessons: connectDouble, + ); + await WidgetSync.writeDayData(dayData); + await WidgetSync.writeWeekData(weekData); + await WidgetSync.setLoggedIn(true); + await WidgetSync.triggerUpdate(); + } on Object catch (e, s) { + // Catch Object: non-Exception Errors (RangeError, StateError) from the + // bloc layer must not escape into the stream listener. + log('WidgetPublisher.publishFromBlocState failed: $e', stackTrace: s); + } + } + + static String _themeName(ThemeMode? mode) { + switch (mode) { + case ThemeMode.light: + return 'light'; + case ThemeMode.dark: + return 'dark'; + case ThemeMode.system: + case null: + return 'system'; + } + } +} diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart new file mode 100644 index 0000000..d2ab01b --- /dev/null +++ b/lib/widget_data/widget_sync.dart @@ -0,0 +1,109 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:home_widget/home_widget.dart'; + +import 'widget_data.dart'; + +/// Bridge to the native widget host. All keys/names live here so the Kotlin +/// and Swift sides stay in sync. +class WidgetSync { + static const String iosAppGroupId = + 'group.eu.mhsl.marianum.mobile.client.widget'; + + static const String iosWidgetKind = 'TimetableWidget'; + static const String androidDayProvider = 'TimetableDayWidget'; + static const String androidWeekProvider = 'TimetableWeekWidget'; + + // `_v1` suffix lets a future schema change invalidate stale snapshots + // by bumping the key instead of risking a parse crash. + static const String dayDataKey = 'widget_data_day_v1'; + static const String weekDataKey = 'widget_data_week_v1'; + static const String fetchedAtKey = 'widget_data_fetched_at_v1'; + static const String loggedInKey = 'widget_data_logged_in_v1'; + // Mirrored into widget storage so the background isolate can read it + // without reopening HydratedBloc storage. + static const String connectDoubleLessonsKey = + 'widget_setting_connect_double_lessons_v1'; + static const String themeModeKey = 'widget_setting_theme_mode_v1'; + + static bool _initialised = false; + + static Future ensureInitialized() async { + if (_initialised) return; + await HomeWidget.setAppGroupId(iosAppGroupId); + _initialised = true; + } + + static Future writeDayData(WidgetTimetableData data) async { + await ensureInitialized(); + await HomeWidget.saveWidgetData(dayDataKey, jsonEncode(data.toJson())); + await HomeWidget.saveWidgetData( + fetchedAtKey, + data.fetchedAt.toIso8601String(), + ); + } + + static Future writeWeekData(WidgetTimetableData data) async { + await ensureInitialized(); + await HomeWidget.saveWidgetData( + weekDataKey, + jsonEncode(data.toJson()), + ); + await HomeWidget.saveWidgetData( + fetchedAtKey, + data.fetchedAt.toIso8601String(), + ); + } + + static Future setLoggedIn(bool loggedIn) async { + await ensureInitialized(); + await HomeWidget.saveWidgetData(loggedInKey, loggedIn); + } + + static Future setConnectDoubleLessons(bool value) async { + await ensureInitialized(); + await HomeWidget.saveWidgetData(connectDoubleLessonsKey, value); + } + + /// Default `true` matches `default_settings.dart` — fresh install behaves + /// like the in-app calendar. + static Future getConnectDoubleLessons() async { + await ensureInitialized(); + final value = await HomeWidget.getWidgetData( + connectDoubleLessonsKey, + defaultValue: true, + ); + return value ?? true; + } + + static Future setThemeMode(String mode) async { + await ensureInitialized(); + await HomeWidget.saveWidgetData(themeModeKey, mode); + } + + static Future clear() async { + await ensureInitialized(); + await HomeWidget.saveWidgetData(dayDataKey, null); + await HomeWidget.saveWidgetData(weekDataKey, null); + await HomeWidget.saveWidgetData(fetchedAtKey, null); + await HomeWidget.saveWidgetData(loggedInKey, false); + } + + static Future triggerUpdate() async { + await ensureInitialized(); + try { + await HomeWidget.updateWidget( + androidName: androidDayProvider, + iOSName: iosWidgetKind, + ); + await HomeWidget.updateWidget( + androidName: androidWeekProvider, + iOSName: iosWidgetKind, + ); + } on Exception catch (e) { + log('WidgetSync.triggerUpdate failed: $e'); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e6cd8c4..98ddb1e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration publish_to: 'none' -version: 0.1.7+46 +version: 1.0.0+47 environment: sdk: ">=3.8.0 <4.0.0" @@ -34,6 +34,8 @@ dependencies: flutter_app_badge: ^2.0.2 flutter_bloc: ^9.0.0 flutter_secure_storage: ^10.0.0 + home_widget: ^0.7.0 + workmanager: ^0.9.0+3 intl: ^0.20.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^21.0.0 @@ -69,6 +71,7 @@ dependencies: time_range_picker: ^2.3.0 url_launcher: ^6.3.1 enough_icalendar: ^0.17.0 + receive_sharing_intent: ^1.8.1 dev_dependencies: flutter_test: diff --git a/test/widget_data/widget_data_mapper_test.dart b/test/widget_data/widget_data_mapper_test.dart new file mode 100644 index 0000000..c9e2626 --- /dev/null +++ b/test/widget_data/widget_data_mapper_test.dart @@ -0,0 +1,385 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import 'package:marianum_mobile/widget_data/widget_data.dart'; +import 'package:marianum_mobile/widget_data/widget_data_mapper.dart'; + +CustomTimetableEvent _event({ + required String id, + required String title, + required DateTime start, + required DateTime end, +}) => CustomTimetableEvent( + id: id, + title: title, + description: '', + startDate: start, + endDate: end, + color: 'orange', + rrule: '', + createdAt: start, + updatedAt: start, +); + +GetTimetableResponseObject _lesson({ + required int date, + required int startTime, + required int endTime, + String? code, + String? subjectName, + String? room, + int teacherId = 1, + String? teacherName, + String? teacherOrgname, + String? substText, +}) => GetTimetableResponseObject( + id: 1, + date: date, + startTime: startTime, + endTime: endTime, + code: code, + substText: substText, + kl: const [], + te: teacherName != null + ? [ + GetTimetableResponseObjectTeacher( + teacherId, + teacherName, + teacherName, + teacherOrgname == null ? null : 9, + teacherOrgname, + null, + ), + ] + : const [], + su: subjectName != null + ? [ + GetTimetableResponseObjectSubject( + 5, + subjectName, + subjectName, + ), + ] + : const [], + ro: room != null + ? [GetTimetableResponseObjectRoom(7, room, room)] + : const [], +); + +void main() { + group('resolveDayAnchor', () { + test('weekday before cutoff stays today', () { + final anchor = WidgetDataMapper.resolveDayAnchor( + DateTime(2026, 5, 6, 10), + ); + expect(anchor, DateTime(2026, 5, 6)); + }); + + test('weekday after cutoff jumps to next school day', () { + final anchor = WidgetDataMapper.resolveDayAnchor( + DateTime(2026, 5, 6, 19), + ); + expect(anchor, DateTime(2026, 5, 7)); + }); + + test('Friday after cutoff jumps to Monday', () { + final anchor = WidgetDataMapper.resolveDayAnchor( + DateTime(2026, 5, 8, 18), + ); + expect(anchor, DateTime(2026, 5, 11)); + }); + + test('Saturday morning jumps to Monday', () { + final anchor = WidgetDataMapper.resolveDayAnchor( + DateTime(2026, 5, 9, 10), + ); + expect(anchor, DateTime(2026, 5, 11)); + }); + + test('Sunday evening jumps to Monday', () { + final anchor = WidgetDataMapper.resolveDayAnchor( + DateTime(2026, 5, 10, 22), + ); + expect(anchor, DateTime(2026, 5, 11)); + }); + }); + + group('resolveWeekAnchor', () { + test('Tuesday returns the Monday of that week', () { + final anchor = WidgetDataMapper.resolveWeekAnchor( + DateTime(2026, 5, 5, 10), + ); + expect(anchor, DateTime(2026, 5, 4)); + }); + + test('Sunday returns next Monday', () { + final anchor = WidgetDataMapper.resolveWeekAnchor( + DateTime(2026, 5, 10, 10), + ); + expect(anchor, DateTime(2026, 5, 11)); + }); + }); + + group('buildDayData', () { + final now = DateTime(2026, 5, 6, 10); + + test('only includes lessons on the anchor day', () { + final lessons = [ + _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'MA'), + _lesson(date: 20260507, startTime: 800, endTime: 845, subjectName: 'EN'), + ]; + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: lessons, + subjects: null, + rooms: null, + holidays: null, + ); + expect(data.lessons, hasLength(1)); + expect(data.lessons.first.subjectShort, 'MA'); + }); + + test('classifies cancelled and irregular lessons', () { + final lessons = [ + _lesson( + date: 20260506, + startTime: 800, + endTime: 845, + subjectName: 'MA', + code: 'cancelled', + ), + _lesson( + date: 20260506, + startTime: 900, + endTime: 945, + subjectName: 'EN', + code: 'irregular', + ), + _lesson( + date: 20260506, + startTime: 1000, + endTime: 1045, + subjectName: 'BIO', + teacherName: 'Müller', + teacherOrgname: 'Schmidt', + ), + ]; + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: lessons, + subjects: null, + rooms: null, + holidays: null, + ); + expect( + data.lessons.map((l) => l.status).toList(), + [ + WidgetLessonStatus.cancelled, + WidgetLessonStatus.irregular, + WidgetLessonStatus.teacherChanged, + ], + ); + }); + + test('marks day as holiday when in holiday range', () { + final holidays = GetHolidaysResponse({ + GetHolidaysResponseObject( + 1, + 'Pfingsten', + 'Pfingstferien', + 20260506, + 20260510, + ), + }); + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: const [], + subjects: null, + rooms: null, + holidays: holidays, + ); + expect(data.isHoliday, isTrue); + expect(data.holidayName, 'Pfingstferien'); + }); + + test('lessons are sorted by start time', () { + final lessons = [ + _lesson( + date: 20260506, + startTime: 1000, + endTime: 1045, + subjectName: 'BIO', + ), + _lesson( + date: 20260506, + startTime: 800, + endTime: 845, + subjectName: 'MA', + ), + _lesson( + date: 20260506, + startTime: 900, + endTime: 945, + subjectName: 'EN', + ), + ]; + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: lessons, + subjects: null, + rooms: null, + holidays: null, + ); + expect( + data.lessons.map((l) => l.subjectShort).toList(), + ['MA', 'EN', 'BIO'], + ); + }); + }); + + group('event collision bumping', () { + final now = DateTime(2026, 5, 6, 10); + + test('long event bumps every regular lesson it covers', () { + final lessons = [ + _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'MA'), + _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'), + _lesson(date: 20260506, startTime: 1000, endTime: 1045, subjectName: 'BIO'), + ]; + final events = GetCustomTimetableEventResponse([ + _event( + id: 'a', + title: 'Wandertag', + start: DateTime(2026, 5, 6, 8), + end: DateTime(2026, 5, 6, 11), + ), + ]); + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: lessons, + subjects: null, + rooms: null, + holidays: null, + customEvents: events, + ); + expect(data.lessons, hasLength(3)); + for (final l in data.lessons) { + expect(l.siblingCount, 1, reason: '${l.subjectShort} should be bumped'); + } + }); + + test('event + same-slot duplicate regular: kept lesson shows +2', () { + // User scenario: a long custom event covers the slot, and Webuntis + // reports two regular lessons starting at the same time (different + // class group). The user wants "+2" — one for the hidden event, one + // for the parallel regular lesson — not just "+1". + final lessons = [ + _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'), + _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'MA'), + ]; + final events = GetCustomTimetableEventResponse([ + _event( + id: 'long', + title: 'Wandertag', + start: DateTime(2026, 5, 6, 8), + end: DateTime(2026, 5, 6, 12), + ), + ]); + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: lessons, + subjects: null, + rooms: null, + holidays: null, + customEvents: events, + ); + expect(data.lessons, hasLength(1)); + expect(data.lessons.first.siblingCount, 2); + }); + + test('multi-day event splits into one block per calendar day', () { + final events = GetCustomTimetableEventResponse([ + _event( + id: 'multi', + title: 'Klassenfahrt', + start: DateTime(2026, 5, 4, 8), + end: DateTime(2026, 5, 6, 14), + ), + ]); + final data = WidgetDataMapper.buildWeekData( + now: DateTime(2026, 5, 5, 10), + lessons: const [], + subjects: null, + rooms: null, + holidays: null, + customEvents: events, + ); + expect(data.lessons, hasLength(3)); + expect(data.lessons[0].start, DateTime(2026, 5, 4, 8)); + expect(data.lessons[0].end, DateTime(2026, 5, 4, 23, 59)); + expect(data.lessons[1].start, DateTime(2026, 5, 5, 0)); + expect(data.lessons[1].end, DateTime(2026, 5, 5, 23, 59)); + expect(data.lessons[2].start, DateTime(2026, 5, 6, 0)); + expect(data.lessons[2].end, DateTime(2026, 5, 6, 14)); + }); + + test('two events covering the same regular lesson bump it twice', () { + final lessons = [ + _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'), + ]; + final events = GetCustomTimetableEventResponse([ + _event( + id: 'long', + title: 'Termin lang', + start: DateTime(2026, 5, 6, 8), + end: DateTime(2026, 5, 6, 12), + ), + _event( + id: 'short', + title: 'Termin kurz', + start: DateTime(2026, 5, 6, 9), + end: DateTime(2026, 5, 6, 10), + ), + ]); + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: lessons, + subjects: null, + rooms: null, + holidays: null, + customEvents: events, + ); + expect(data.lessons, hasLength(1)); + expect(data.lessons.first.subjectShort, 'EN'); + expect(data.lessons.first.siblingCount, 2); + }); + }); + + group('buildWeekData', () { + final now = DateTime(2026, 5, 5, 10); // Tuesday + + test('contains lessons across the school week', () { + final lessons = [ + _lesson(date: 20260504, startTime: 800, endTime: 845, subjectName: 'MO'), + _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'WE'), + _lesson(date: 20260508, startTime: 800, endTime: 845, subjectName: 'FR'), + _lesson(date: 20260511, startTime: 800, endTime: 845, subjectName: 'NEXT'), + ]; + final data = WidgetDataMapper.buildWeekData( + now: now, + lessons: lessons, + subjects: null, + rooms: null, + holidays: null, + ); + // Anchor is Mon 04.05.; week ends Fri 08.05. exclusive of next Mon + expect(data.anchorDate, DateTime(2026, 5, 4)); + expect( + data.lessons.map((l) => l.subjectShort).toList(), + ['MO', 'WE', 'FR'], + ); + }); + }); +}