24 Commits

Author SHA1 Message Date
MineTec 8c76f2d816 removed now indicator from android and ios widgets 2026-05-11 13:55:16 +02:00
MineTec c46f14f6a6 updated kotlin gradle plugin version to 2.2.20 2026-05-10 20:27:50 +02:00
MineTec b2b00d321e Merge pull request 'implemented chat long-polling and optimistic updates, centralized notification management, optimized avatar caching, cleanup and bugfixes' (#99) from develop-notifications into develop
Reviewed-on: #99
2026-05-10 15:02:12 +00:00
MineTec 1a11b9ac60 refactored internal documentation and simplified comments across chat BLoCs, file viewer, and navigation components 2026-05-10 17:01:50 +02:00
MineTec a0bc46f522 optimized avatar and linkify performance, refined navigation to preserve popups, implemented read marker caching, and added file size limits for saving, minor timetable details changes 2026-05-10 16:40:39 +02:00
MineTec 1458d8ce49 implemented chat long-polling and optimistic updates, centralized notification management, optimized avatar caching 2026-05-10 15:47:55 +02:00
MineTec 6ae396e605 Merge pull request 'general search, talk enhancements, overhauled fileviewer' (#98) from develop-search into develop
Reviewed-on: #98
2026-05-09 22:55:11 +00:00
MineTec ed2badfd35 fixed chat bubble link styling and gesture handling, and added android package visibility for common schemes 2026-05-10 00:54:13 +02:00
MineTec 1ff57b29f9 overhauled file viewer with video, audio, text, and SVG support, added media player and line-numbered text views, and fixed search controller recursion 2026-05-10 00:33:09 +02:00
MineTec c50a850ac9 reordered files app bar actions by moving search icon 2026-05-09 23:43:29 +02:00
MineTec 15833f3685 implemented disposal guard in files search controller to safely handle async listener notifications 2026-05-09 23:40:04 +02:00
MineTec bf28a678c9 implemented background prefetching for files root, added 24-hour caching for root directory listing, and enabled cache renewal for manual refreshes 2026-05-09 23:39:06 +02:00
MineTec 14090b96f4 implemented file search with local cache and server-side support, added result highlighting, and integrated search delegate into files page 2026-05-09 23:20:11 +02:00
MineTec 8e6b1877cc implemented search for marianum messages with name and date filtering 2026-05-09 22:35:20 +02:00
MineTec 9accb488f2 added delete confirmation dialog for chat messages and refined deletion logic flow 2026-05-09 22:32:45 +02:00
MineTec 79a6d9a594 filtered deleted messages from search and chat view, refactored chat bubble styling for deleted comments, and updated tests 2026-05-09 22:28:26 +02:00
MineTec 7d02e70459 implemented short relative date formatting for chat and added unit tests 2026-05-09 22:23:25 +02:00
MineTec 4c190de479 implemented in-chat search with text highlighting, added search navigation UI, and integrated scrollable list for message jumping 2026-05-09 22:21:36 +02:00
MineTec b36d1e02f5 Merge pull request 'added native features like homescreen-widgets and share intents' (#97) from develop-native into develop
Reviewed-on: #97
2026-05-09 19:35:32 +00:00
MineTec 53b290ab49 ensured timetable visibility on widget navigation by resetting root navigator 2026-05-09 21:31:39 +02:00
MineTec b422430994 implemented message forwarding and direct chat creation from group members, and added specialized share picker for forwarded content 2026-05-09 20:39:19 +02:00
MineTec 151678f0fe implemented internal file sharing and saving, added server-side file references, refactored share pickers for unified flows, and updated UI branding labels 2026-05-09 20:18:52 +02:00
MineTec cb2c38aaa1 implemented native share intent support for android and ios with chat and folder pickers 2026-05-09 19:42:51 +02:00
MineTec 00664c66a8 added base homescreen-widget setup, working on Android, iOS in progress 2026-05-09 18:01:05 +02:00
129 changed files with 10357 additions and 461 deletions
+70 -6
View File
@@ -2,7 +2,9 @@
<application
android:label="Marianum Fulda"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -23,23 +25,85 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="application/*" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
<data android:mimeType="video/*" />
<data android:mimeType="application/*" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<!-- Receiver classes live at the package root (NOT under .widgets) because
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
android:label="@string/widget_day_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/timetable_day_widget_info" />
</receiver>
<receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
android:label="@string/widget_week_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/timetable_week_widget_info" />
</receiver>
</application>
<!-- Required so url_launcher / can_launch can actually see browsers,
mail clients and dialers under Android 11+ package-visibility rules
(otherwise UrlLauncher logs "component name for ... is null" and
link taps in Talk silently do nothing). The PROCESS_TEXT intent is
needed by io.flutter.plugin.text.ProcessTextPlugin (selection
menu).
See https://developer.android.com/training/package-visibility -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="http"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="mailto"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="tel"/>
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
</manifest>
@@ -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
}
}
}
@@ -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 `<app-package>.<androidName>`.
*/
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)
}
}
@@ -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)
}
}
@@ -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<WidgetLesson>,
val periods: List<WidgetPeriod>,
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<WidgetLesson>()
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<WidgetPeriod>()
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)
}
}
@@ -0,0 +1,740 @@
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,
)
}
}
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,
)
}
}
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<WidgetPeriod>,
): 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<WidgetPeriod>,
) {
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<WidgetPeriod>,
) {
// 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<Int>()
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<WidgetPeriod>,
) {
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<Int, Int> {
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<WidgetPeriod>,
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)
}
}
/// 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"
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Plain rounded rectangle. We deliberately avoid ?attr/appWidgetRadius
because RemoteViews inflation does not resolve custom theme attributes
reliably across launchers. Hard-coded 20dp matches the system home-screen
radius closely on stock Android. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="20dp" />
<solid android:color="@color/widget_background" />
</shape>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Direct port of /home/elias/Bilder/marianum_m_white.svg.
- viewport matches the source SVG (70mm × 82.2mm).
- Transforms recreate the SVG's three nested matrices: outer scale+offset,
inner translate, and a final scale+y-flip that pulls the path data into
the visible coordinate space.
- Tinted at runtime via setColorFilter so light/dark themes can share one
drawable.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="70dp"
android:height="82dp"
android:viewportWidth="70.000168"
android:viewportHeight="82.227348">
<group
android:scaleX="0.26458333"
android:scaleY="0.26458334"
android:translateX="107.44411"
android:translateY="-80.482198">
<group
android:translateX="-749.41293"
android:translateY="290.52252">
<group
android:scaleX="0.13333333"
android:scaleY="-0.13333333"
android:translateX="0"
android:translateY="632.14667">
<path
android:fillColor="#FFFFFF"
android:pathData="M 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06 z" />
</group>
</group>
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_divider" />
</shape>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_cancelled" />
<stroke android:width="1.5dp" android:color="#C8FF0000" />
</shape>
</item>
<item
android:left="3dp"
android:top="3dp"
android:right="3dp"
android:bottom="3dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF2196F3" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF4CAF50" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FFEF6C00" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF993333" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_irregular" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_ongoing" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_past" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_regular" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_teacher_changed" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:strokeColor="#C8FF0000"
android:strokeWidth="5"
android:pathData="M0,0 L100,100 M100,0 L0,100" />
</vector>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_break_block_root"
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_marginTop="0dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp" />
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/app_widget_background">
<!-- Bottom-leading so the mark looks like it's peeking out of the lower
left corner. Width/height/start+bottom margins are overridden in the
renderer so the mark scales with the widget size. -->
<ImageView
android:id="@+id/widget_watermark"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="bottom|end"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.025"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/widget_day_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:ellipsize="end"
tools:text="Heute · 08.05." />
<TextView
android:id="@+id/widget_day_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
tools:text="Stand: 14:32" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/widget_day_time_labels"
android:layout_width="32dp"
android:layout_height="match_parent" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_day_grid"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
<TextView
android:id="@+id/widget_day_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:textSize="13sp"
android:textColor="@color/widget_text_secondary"
android:gravity="center"
android:padding="12dp"
tools:text="Keine Stunden" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,185 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/widget_background">
<ImageView
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="bottom|end"
android:layout_marginEnd="-28dp"
android:layout_marginBottom="-28dp"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.018"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="Heute · 06.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout
android:layout_width="32dp"
android:layout_height="match_parent">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="22dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="1." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="50dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="2." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="79dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="3." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="115dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="4." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="142dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="5." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="169dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="6." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="29dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="MA-LK" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="55dp"
android:layout_marginTop="83dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="DE-GK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" />
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="143dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="BIO" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="170dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_teacher_changed">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="GE" />
</FrameLayout>
</FrameLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_grid_line_root"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="0dp"
android:background="@drawable/widget_grid_line" />
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_lesson_block_root"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:layout_marginTop="0dp"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="top"
android:baselineAligned="false">
<TextView
android:id="@+id/widget_lesson_subject"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="top|start"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="MA-LK" />
<LinearLayout
android:id="@+id/widget_lesson_secondary_stack"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="top|end"
android:layout_marginStart="4dp">
<TextView
android:id="@+id/widget_lesson_room"
android:layout_width="wrap_content"
android:layout_height="12dp"
android:textColor="#CCFFFFFF"
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="A12" />
<TextView
android:id="@+id/widget_lesson_teacher"
android:layout_width="wrap_content"
android:layout_height="12dp"
android:textColor="#B3FFFFFF"
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="Müller" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/widget_lesson_sibling_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:textStyle="bold"
android:textSize="12sp"
android:textColor="@android:color/white"
android:maxLines="1"
android:includeFontPadding="false"
android:visibility="gone"
tools:text="+1" />
</FrameLayout>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/app_widget_background">
<ImageView
android:id="@+id/widget_watermark"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="bottom|end"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.025"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="16dp">
<TextView
android:id="@+id/widget_placeholder_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
tools:text="Marianum Vertretungsplan" />
<TextView
android:id="@+id/widget_placeholder_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="12sp"
android:layout_marginTop="6dp"
android:textColor="@color/widget_text_secondary"
tools:text="Bitte einloggen, um den Stundenplan zu laden" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_time_label_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="0dp"
android:orientation="vertical"
android:gravity="end"
android:paddingEnd="3dp">
<TextView
android:id="@+id/widget_time_label_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="9sp"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:lineSpacingExtra="-2dp"
tools:text="07:55" />
<TextView
android:id="@+id/widget_time_label_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="7sp"
android:textColor="@color/widget_text_secondary"
android:singleLine="true"
android:lineSpacingExtra="-2dp"
tools:text="1." />
</LinearLayout>
@@ -0,0 +1,185 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/app_widget_background">
<ImageView
android:id="@+id/widget_watermark"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="bottom|end"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.025"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/widget_week_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:ellipsize="end"
tools:text="KW 19 · 06.05.10.05." />
<TextView
android:id="@+id/widget_week_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
tools:text="Stand: 14:32" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout
android:layout_width="28dp"
android:layout_height="wrap_content" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_mon"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_tue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_wed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_thu"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_fri"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/widget_week_time_labels"
android:layout_width="28dp"
android:layout_height="match_parent" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_mon"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_tue"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_wed"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_thu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_fri"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_week_day_header_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="2dp"
android:paddingBottom="3dp">
<TextView
android:id="@+id/widget_week_day_header_weekday"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
tools:text="Mo" />
<TextView
android:id="@+id/widget_week_day_header_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="9sp"
android:textColor="@color/widget_text_secondary"
tools:text="06.05." />
</LinearLayout>
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static preview for the week widget. Five day columns with two short
demo blocks each so the structure is recognisable in the picker. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/widget_background">
<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="bottom|end"
android:layout_marginEnd="-36dp"
android:layout_marginBottom="-36dp"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.018"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="KW 19 · 06.05.10.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<!-- Day-name + date row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." />
</LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." />
</LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." />
</LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." />
</LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." />
</LinearLayout>
</LinearLayout>
<!-- Time grid: time-label column + 5 day columns -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginTop="2dp">
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" />
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Mon -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Tue -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Wed -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Thu -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Fri -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
</FrameLayout>
</FrameLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widget_background">#FF1F1716</color>
<color name="widget_text_primary">#FFF1F1F1</color>
<color name="widget_text_secondary">#FFB0B0B0</color>
<color name="widget_divider">#33FFFFFF</color>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="widget_day_label">Marianum · Heute</string>
<string name="widget_week_label">Marianum · Woche</string>
<string name="widget_day_description">Stundenplan und Vertretungen für den anstehenden Schultag.</string>
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
<string name="widget_no_lessons">Keine Stunden</string>
<string name="widget_holiday">Ferien</string>
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
<string name="widget_loading">Lade…</string>
<string name="widget_status_label">Stand: %1$s</string>
<string name="widget_today">Heute</string>
<string name="widget_tomorrow">Morgen</string>
<string name="widget_placeholder_title">Marianum Stundenplan</string>
<string name="widget_calendar_week_prefix">KW</string>
<string name="widget_yesterday_prefix">gestern</string>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
exactly so widget tiles match the in-app calendar. -->
<color name="widget_lesson_regular">#FF993333</color>
<color name="widget_lesson_ongoing">#FFC83333</color>
<color name="widget_lesson_past">#FF993333</color>
<color name="widget_lesson_cancelled">#FF000000</color>
<color name="widget_lesson_irregular">#FF8F19B3</color>
<color name="widget_lesson_teacher_changed">#FF29639B</color>
<color name="widget_lesson_event">#FF2E7D32</color>
<color name="widget_background">#FFFCF7F5</color>
<color name="widget_text_primary">#FF111111</color>
<color name="widget_text_secondary">#FF555555</color>
<color name="widget_divider">#22000000</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Auto-Backup rules for Android 11 and below.
Excludes the home_widget plugin's SharedPreferences file
(HomeWidgetPreferences) so the cached timetable — which contains
teacher names, room numbers and personal custom events — is not
uploaded to the user's Google Drive. -->
<full-backup-content>
<exclude domain="sharedpref" path="HomeWidgetPreferences.xml"/>
</full-backup-content>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Backup + device-transfer rules for Android 12+.
Excludes the home_widget plugin's SharedPreferences file
(HomeWidgetPreferences) so the cached timetable — which contains
teacher names, room numbers and personal custom events — is not
uploaded to the user's Google Drive or transferred to a new device.
The widget repopulates from a fresh Webuntis fetch after sign-in. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="HomeWidgetPreferences.xml"/>
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="HomeWidgetPreferences.xml"/>
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_placeholder"
android:minWidth="110dp"
android:minHeight="180dp"
android:targetCellWidth="2"
android:targetCellHeight="5"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:updatePeriodMillis="0"
android:description="@string/widget_day_description"
android:previewLayout="@layout/widget_day_preview" />
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_placeholder"
android:minWidth="320dp"
android:minHeight="240dp"
android:targetCellWidth="5"
android:targetCellHeight="5"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:updatePeriodMillis="0"
android:description="@string/widget_week_description"
android:previewLayout="@layout/widget_week_preview" />
+23
View File
@@ -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')
}
+1 -1
View File
@@ -21,7 +21,7 @@ plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.13.2' apply false
id "com.android.library" version '8.13.2' apply false
id "org.jetbrains.kotlin.android" version "2.1.10" apply false
id "org.jetbrains.kotlin.android" version "2.2.20" apply false
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.10.0'
}
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="70.000168mm"
height="82.227348mm"
viewBox="0 0 70.000168 82.227348"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="g1"
transform="matrix(0.26458333,0,0,0.26458334,107.44411,-80.482198)"><g
id="group-R5"
transform="translate(-749.41293,290.52252)"><path
id="path3"
d="m 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06"
style="fill:#d3d2d2;fill-opacity:1;fill-rule:evenodd;stroke:none"
transform="matrix(0.13333333,0,0,-0.13333333,0,632.14667)" /></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+4
View File
@@ -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
+13
View File
@@ -2,6 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
+5
View File
@@ -4,5 +4,10 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
<string>group.eu.mhsl.marianum.mobile.client.share</string>
</array>
</dict>
</plist>
+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Marianum Fulda</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>10</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>10</integer>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>10</integer>
</dict>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22504"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target">
<view key="view" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="bcg-RR-FT9"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CzN-xT-EUl" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+93
View File
@@ -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 18 oben (~15 Min).
- Auf physischem iPhone testen — Simulator-Share-Sheet ist eingeschränkt.
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.share</string>
</array>
</dict>
</plist>
@@ -0,0 +1,8 @@
import UIKit
import receive_sharing_intent
class ShareViewController: RSIShareViewController {
override func shouldAutoRedirect() -> Bool {
return true
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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"
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="70.000168mm"
height="82.227348mm"
viewBox="0 0 70.000168 82.227348"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="g1"
transform="matrix(0.26458333,0,0,0.26458334,107.44411,-80.482198)"><g
id="group-R5"
transform="translate(-749.41293,290.52252)"><path
id="path3"
d="m 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06"
style="fill:#d3d2d2;fill-opacity:1;fill-rule:evenodd;stroke:none"
transform="matrix(0.13333333,0,0,-0.13333333,0,632.14667)" /></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Marianum Stundenplan</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -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()
}
}
+72
View File
@@ -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 12 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 15 oben in Xcode durchklicken (1015 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.
@@ -0,0 +1,425 @@
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..<periods.count {
let curr = periods[i]
if realMin > 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,
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 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])
}
}
.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..<max(0, periods.count - 1), id: \.self) { i in
let curr = periods[i]
let next = periods[i + 1]
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
if virtualGap > 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 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<Int>()
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)
}
@@ -0,0 +1,160 @@
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,
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)
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
</dict>
</plist>
@@ -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<TimetableEntry>) -> 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<TimetableEntry>) -> 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
}
}
}
@@ -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
}
}
}
@@ -0,0 +1,36 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
import 'search_files_response.dart';
/// Wraps the Nextcloud OCS Search Provider API for the `files` provider.
/// Endpoint: `/ocs/v2.php/search/providers/files/search`.
class SearchFiles {
Future<SearchFilesResponse> run({
required String term,
int limit = 50,
int? cursor,
}) async {
final endpoint = NextcloudOcs.uri(
'search/providers/files/search',
queryParameters: {
'term': term,
'limit': limit.toString(),
if (cursor != null) 'cursor': cursor.toString(),
},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw Exception(
'Files search failed with ${response.statusCode}: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final ocs = decoded['ocs'] as Map<String, dynamic>;
final data = ocs['data'] as Map<String, dynamic>;
return SearchFilesResponse.fromJson(data);
}
}
@@ -0,0 +1,91 @@
import 'package:json_annotation/json_annotation.dart';
import '../webdav/queries/list_files/cacheable_file.dart';
part 'search_files_response.g.dart';
/// Subset of the OCS Search Provider API response we actually consume.
/// The provider (`files`) returns one object per match plus pagination state.
@JsonSerializable(explicitToJson: true)
class SearchFilesResponse {
final String name;
final bool isPaginated;
final int? cursor;
final List<SearchFilesEntry> entries;
SearchFilesResponse({
required this.name,
required this.isPaginated,
required this.cursor,
required this.entries,
});
factory SearchFilesResponse.fromJson(Map<String, dynamic> json) =>
_$SearchFilesResponseFromJson(json);
Map<String, dynamic> toJson() => _$SearchFilesResponseToJson(this);
}
@JsonSerializable()
class SearchFilesEntry {
final String title;
final String? subline;
final String? icon;
final String? resourceUrl;
final Map<String, dynamic>? attributes;
SearchFilesEntry({
required this.title,
this.subline,
this.icon,
this.resourceUrl,
this.attributes,
});
factory SearchFilesEntry.fromJson(Map<String, dynamic> json) =>
_$SearchFilesEntryFromJson(json);
Map<String, dynamic> toJson() => _$SearchFilesEntryToJson(this);
/// Heuristic — the files provider sets icon classes containing "folder" for
/// directories. Falls back to false when missing or unrecognised.
bool get isDirectory => (icon ?? '').toLowerCase().contains('folder');
String? _stringAttribute(String key) {
final raw = attributes?[key];
return raw is String && raw.isNotEmpty ? raw : null;
}
String? _dirFromResourceUrl() {
final url = resourceUrl;
if (url == null) return null;
return Uri.tryParse(url)?.queryParameters['dir'];
}
/// Reconstructs the WebDAV-relative path used elsewhere (matching
/// [CacheableFile.path] — no leading slash, trailing slash for
/// directories). Prefers the explicit `path` attribute set by Nextcloud's
/// files search provider (28+); falls back to the `dir` query parameter
/// in [resourceUrl]. Returns `null` when neither is available — `subline`
/// is intentionally **not** parsed because it is localized UI text
/// ("in {folder}"), not a path, and using it produced bogus duplicate
/// folder headers like "/in Alte-Notebooks".
String? get webdavPath {
final attrPath = _stringAttribute('path');
if (attrPath != null) {
final stripped = attrPath.replaceAll(RegExp(r'^/+|/+$'), '');
return isDirectory ? '$stripped/' : stripped;
}
final dir = _dirFromResourceUrl();
if (dir != null) {
final stripped = dir.replaceAll(RegExp(r'^/+|/+$'), '');
final base = stripped.isEmpty ? title : '$stripped/$title';
return isDirectory ? '$base/' : base;
}
return null;
}
CacheableFile? toCacheable() {
final path = webdavPath;
if (path == null) return null;
return CacheableFile(path: path, isDirectory: isDirectory, name: title);
}
}
@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_files_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SearchFilesResponse _$SearchFilesResponseFromJson(Map<String, dynamic> json) =>
SearchFilesResponse(
name: json['name'] as String,
isPaginated: json['isPaginated'] as bool,
cursor: (json['cursor'] as num?)?.toInt(),
entries: (json['entries'] as List<dynamic>)
.map((e) => SearchFilesEntry.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SearchFilesResponseToJson(
SearchFilesResponse instance,
) => <String, dynamic>{
'name': instance.name,
'isPaginated': instance.isPaginated,
'cursor': instance.cursor,
'entries': instance.entries.map((e) => e.toJson()).toList(),
};
SearchFilesEntry _$SearchFilesEntryFromJson(Map<String, dynamic> json) =>
SearchFilesEntry(
title: json['title'] as String,
subline: json['subline'] as String?,
icon: json['icon'] as String?,
resourceUrl: json['resourceUrl'] as String?,
attributes: json['attributes'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$SearchFilesEntryToJson(SearchFilesEntry instance) =>
<String, dynamic>{
'title': instance.title,
'subline': instance.subline,
'icon': instance.icon,
'resourceUrl': instance.resourceUrl,
'attributes': instance.attributes,
};
@@ -66,7 +66,7 @@ class GetChatResponseObject {
static GetChatResponseObject getDateDummy(int timestamp) {
var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return getTextDummy(elementDate.formatDate());
return getTextDummy(elementDate.formatDateRelativeShort());
}
static GetChatResponseObject getTextDummy(String text) =>
@@ -0,0 +1,69 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi]
/// because that layer treats non-2xx as errors, and we need 304 to be a
/// normal "no new messages" outcome. `setReadMarker=on` lets the server
/// move the read cursor whenever the call returns messages.
class LongPollChat {
final String chatToken;
final int lastKnownMessageId;
final int timeoutSeconds;
LongPollChat({
required this.chatToken,
required this.lastKnownMessageId,
this.timeoutSeconds = 30,
});
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() async {
final params = GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on,
limit: 100,
);
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15));
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
}
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
);
}
}
@@ -26,11 +26,5 @@ class SetReadMarker extends TalkApi {
Uri uri,
Object? body,
Map<String, String>? headers,
) {
if (readState) {
return http.post(uri, headers: headers);
} else {
return http.delete(uri, headers: headers);
}
}
) => readState ? http.post(uri, headers: headers) : http.delete(uri, headers: headers);
}
@@ -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<void> shareFilesToChat({
required String token,
required List<String> remoteFilePaths,
}) => Future.wait(
remoteFilePaths.map(
(path) => FileSharingApi().share(
FileSharingApiParams(shareType: 10, shareWith: token, path: path),
),
),
);
@@ -16,8 +16,9 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
super.onNetworkData,
super.onError,
required String path,
super.renew = false,
}) : super(
cacheTime: RequestCache.cacheNothing,
cacheTime: _cacheTimeFor(path),
loader: () => ListFiles(ListFilesParams(path)).run(),
fromJson: ListFilesResponse.fromJson,
onUpdate: onUpdate,
@@ -25,6 +26,44 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
start(_documentId(path));
}
/// The Nextcloud root listing is significantly slower than subfolders on
/// our instance and frequently returns HTTP 500. Since its content rarely
/// changes, the root payload is cached for a full day so app-resume and
/// connectivity-change auto-refetch triggers do not re-hit the slow root
/// endpoint within the same day. To avoid a long wait on the very first
/// open of the Files page, `prefetchRootListing` (called from `main`)
/// kicks off an async warm-up fetch in the background while the user is
/// still on the launch screen / other modules. Subfolders keep the
/// previous "always refetch on visit" TTL because their content changes
/// more often. Explicit user refreshes (rename, delete, copy/move,
/// upload) bypass the TTL via the inherited [renew] flag or via
/// [invalidate].
static int _cacheTimeFor(String path) {
final stripped = path.replaceAll('/', '').trim();
return stripped.isEmpty
? RequestCache.cacheDay
: RequestCache.cacheNothing;
}
/// Triggers a root-listing fetch in the background if no cached payload
/// exists yet. Intended to be called once after login from `main` so the
/// (slow) root listing is already populated by the time the user
/// navigates to the Files module.
///
/// No-ops when a cached root payload is already present in localstore —
/// the regular TTL handling in [RequestCache] takes over from there.
static Future<void> prefetchRootListing() async {
const rootPath = '';
final cached = await Localstore.instance
.collection(RequestCache.collection)
.doc(_documentId(rootPath))
.get();
if (cached != null) return;
// Drive the same code path as a regular fetch so the result lands in
// the cache; we don't care about the in-memory callback here.
ListFilesCache(path: rootPath, onUpdate: (_) {});
}
static String _documentId(String path) {
final cacheName = md5
.convert(utf8.encode('MarianumMobile-$path'))
+112 -41
View File
@@ -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});
@@ -31,16 +36,33 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _refetchChats;
late Timer _updateTimings;
// 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.
StreamSubscription<dynamic>? _timetableWidgetSync;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1;
bool _userOnLastTab = false;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
void _onTabControllerChanged() {
_userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1;
_syncChatListPolling();
}
void _syncChatListPolling() {
if (!mounted) return;
final modules = AppModule.getBottomBarModules(context);
final talkSlot = modules.indexWhere((m) => m.module == Modules.talk);
final talkIsActive =
talkSlot >= 0 && Main.bottomNavigator.index == talkSlot;
final bloc = context.read<ChatListBloc>();
bloc.setAutoRefreshInterval(
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
);
if (talkIsActive) bloc.refresh();
}
@override
@@ -52,9 +74,36 @@ class _AppState extends State<App> with WidgetsBindingObserver {
log('Refreshing due to LifecycleChange');
NotificationTasks.updateProviders(context);
});
_handlePendingWidgetNavigation();
}
}
Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return;
// `withNavBar: false` routes sit on the root navigator above the
// bottom-nav; pop them so jumpToTab is actually visible. Stop at
// popups so open dialogs/sheets stay alive.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
AppRoutes.goToTab(context, Modules.timetable);
}
void _handlePendingShare() {
if (!mounted) return;
final share = ShareIntentListener.pending.value;
if (share == null) return;
// A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths.
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
AppRoutes.openShareTarget(context, share);
}
@override
void initState() {
super.initState();
@@ -66,32 +115,56 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (!mounted) return;
context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh();
// 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<TimetableBloc>().refresh();
// Re-mounts on every login, so this also covers post-logout state reset.
final timetable = context.read<TimetableBloc>();
timetable.refresh();
// Mirror BLoC updates into the home-screen widget without waiting
// for the periodic background refresh.
final settingsCubit = context.read<SettingsCubit>();
_timetableWidgetSync?.cancel();
_timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data;
if (data is TimetableState && !state.isLoading) {
unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
),
);
}
});
// Initial publish in case hydrated storage already has data.
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();
_syncChatListPolling();
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {});
});
_refetchChats = Timer.periodic(const Duration(seconds: 60), (_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<ChatListBloc>().refresh();
});
});
UpdateUserIndex.index();
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
void update() => NotifyUpdater.registerToServer();
FirebaseMessaging.instance.onTokenRefresh.listen((_) => update());
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen(
(_) => update(),
);
update();
}
FirebaseMessaging.onMessage.listen((message) {
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return;
NotificationController.onForegroundMessageHandler(message, context);
});
@@ -99,7 +172,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationController.onBackgroundMessageHandler,
);
FirebaseMessaging.onMessageOpenedApp.listen((message) {
_onMessageOpenedAppSub = FirebaseMessaging.onMessageOpenedApp.listen((
message,
) {
if (!mounted) return;
NotificationController.onAppOpenedByNotification(message, context);
});
@@ -113,8 +188,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
@override
void dispose() {
_refetchChats.cancel();
_updateTimings.cancel();
_timetableWidgetSync?.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
@@ -129,17 +209,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final totalTabs = bottomBarModules.length + 1;
final currentIndex = Main.bottomNavigator.index;
// The bottom-bar layout is identified by the ordered list of module
// names plus the trailing 'more' slot. Whenever this layout changes
// — slot count, reordering, or hiding a module — we recreate the
// entire PersistentTabView via the [layoutKey] below. The package
// caches per-tab navigator state by index in `_navigatorKeys`, and
// its internal `alignLength` only ever appends or trims at the end.
// So when the module sitting at e.g. index 3 changes, the navigator
// at that index still serves the old screen's route stack and the
// user sees stale content. Re-mounting clears those stacks; the
// trade-off (losing in-tab pushed routes on a settings change) is
// acceptable since the user explicitly re-shaped the bar.
// PersistentTabView caches per-tab navigators by index and only
// appends/trims at the end, so reordering/hiding leaves stale
// route stacks under the wrong tabs. Re-key on layout to remount.
final layoutKey = ValueKey(
'${bottomBarModules.map((m) => m.module.name).join('|')}|more',
);
@@ -151,12 +223,8 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} else if (currentIndex >= totalTabs) {
targetIndex = totalTabs - 1;
}
// Re-mounting PTV with a new key constructs fresh internals from
// its controller's current index. If the controller still points
// past the new tab list, Style6BottomNavBar (and others) crash on
// out-of-range access during initState. Replace the controller
// atomically with one initialised at the safe target index so the
// new PTV mounts cleanly.
// Replace the controller atomically: a stale index past the new
// tab list crashes Style6BottomNavBar's initState.
if (targetIndex != currentIndex) {
Main.bottomNavigator.removeListener(_onTabControllerChanged);
Main.bottomNavigator = PersistentTabController(
@@ -192,14 +260,17 @@ class _AppState extends State<App> with WidgetsBindingObserver {
),
],
navBarBuilder: (config) => Style6BottomNavBar(
// Style6BottomNavBar builds its internal animation controller list
// in initState and never grows it on didUpdateWidget. Keying by the
// item count forces a fresh State whenever the slot count changes,
// which avoids a RangeError when more tabs slide in.
// Animation controllers are built once in initState and never
// grown — re-key on item count to avoid RangeError on growth.
key: ValueKey(config.items.length),
navBarConfig: config,
navBarDecoration: NavBarDecoration(
border: const Border(top: BorderSide(width: 1, color: Colors.grey)),
border: Border(
top: BorderSide(
width: 1,
color: Theme.of(context).colorScheme.outlineVariant,
),
),
color: Theme.of(context).colorScheme.surface,
),
),
+178
View File
@@ -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<void> 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<void> requestImmediateRefresh() async {
await Workmanager().registerOneOffTask(
'$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
oneOffTaskName,
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.append,
);
}
static Future<void> 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<void> _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<GetSubjectsResponse>(() => GetSubjects().run());
final rooms = await _runOrNull<GetRoomsResponse>(() => GetRooms().run());
final holidays = await _runOrNull<GetHolidaysResponse>(() => GetHolidays().run());
final timegrid = await _runOrNull<GetTimegridUnitsResponse>(
() => GetTimegridUnits().run(),
);
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>(
() => 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<T?> _runOrNull<T>(Future<T> Function() task) async {
try {
return await task();
} on Exception catch (e) {
log('[widget-bg] reference fetch failed: $e');
return null;
}
}
+14
View File
@@ -46,4 +46,18 @@ extension DateTimeFormatting on DateTime {
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
String formatDateRelativeShort({DateTime? now}) {
final reference = now ?? DateTime.now();
final today = DateTime(reference.year, reference.month, reference.day);
final self = DateTime(year, month, day);
final diff = today.difference(self).inDays;
if (diff == 0) return 'Heute';
if (diff == 1) return 'Gestern';
if (diff > 1 && diff <= 6) {
return Jiffy.parseFromDateTime(this).format(pattern: 'EEEE');
}
return formatDate();
}
}
+44 -1
View File
@@ -17,10 +17,14 @@ import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.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 'routing/app_routes.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 +39,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<void> main() async {
log('MarianumMobile started');
@@ -66,18 +71,42 @@ Future<void> 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!"}'),
),
);
// Warm up the Nextcloud root listing in the background while the user is
// still on the launch screen / other modules — the root endpoint is slow
// on our instance, so kicking it off early means the Files page already
// has data ready by the time the user navigates to it. No-op when a
// cached payload is already present, so this does not undo the day-long
// root cache TTL.
if (AccountData().isPopulated()) {
unawaited(
ListFilesCache.prefetchRootListing().onError(
(e, _) => log('Files root prefetch failed: $e'),
),
);
}
if (kReleaseMode) {
ErrorWidget.builder = (error) => Material(
color: Colors.white,
@@ -125,7 +154,9 @@ Future<void> main() async {
),
BlocProvider<BreakerBloc>(create: (_) => BreakerBloc()),
BlocProvider<ChatListBloc>(create: (_) => ChatListBloc()),
BlocProvider<ChatBloc>(create: (_) => ChatBloc()),
BlocProvider<ChatBloc>(
create: (ctx) => ChatBloc(chatListBloc: ctx.read<ChatListBloc>()),
),
BlocProvider<TimetableBloc>(create: (_) => TimetableBloc()),
],
child: const Main(),
@@ -171,6 +202,8 @@ class _MainState extends State<Main> {
checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
navigatorObservers: [AppRoutes.chatRouteObserver],
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
@@ -198,6 +231,10 @@ class _MainState extends State<Main> {
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 +324,12 @@ Future<void> _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);
}
+26 -2
View File
@@ -1,13 +1,19 @@
import 'dart:async';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart';
import 'notification_tasks.dart';
// `vm:entry-point` keeps this alive through AOT tree-shaking — the FCM
// background isolate looks the class up by name from native code.
@pragma('vm:entry-point')
class NotificationController {
// Notification display is handled by the Firebase SDK using server-generated payloads.
@pragma('vm:entry-point')
static Future<void> onBackgroundMessageHandler(RemoteMessage message) async {
NotificationTasks.updateBadgeCount(message);
@@ -17,8 +23,26 @@ class NotificationController {
RemoteMessage message,
BuildContext context,
) async {
NotificationTasks.updateProviders(context);
final pushToken = _extractChatToken(message);
final chatBloc = context.read<ChatBloc>();
// hasOpenChat, not currentToken: currentToken sticks around after
// leaveChat so didPopNext can re-claim a stacked chat.
final activeToken = chatBloc.state.data?.currentToken ?? '';
final chatIsOpen =
chatBloc.hasOpenChat &&
pushToken != null &&
pushToken.isNotEmpty &&
pushToken == activeToken;
NotificationTasks.updateBadgeCount(message);
if (chatIsOpen) {
// Long-poll handles the message; just dismiss any stray tray entry.
unawaited(NotificationTasks.clearNotificationsForChat(pushToken));
return;
}
NotificationTasks.updateProviders(context);
}
static Future<void> onAppOpenedByNotification(
+40 -2
View File
@@ -1,11 +1,15 @@
import 'dart:developer';
import 'package:eraser/eraser.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../routing/app_routes.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'notification_service.dart';
class NotificationTasks {
static void updateBadgeCount(RemoteMessage notification) {
@@ -14,9 +18,43 @@ class NotificationTasks {
);
}
/// Per-chat tag scheme. MUST match the Notify backend, which sets this
/// value on `AndroidNotification.setTag` AND `apns-collapse-id`.
static String chatTag(String chatToken) => 'talk_$chatToken';
/// Removes tray notifications belonging to [chatToken]. Eraser handles
/// iOS (where the plugin's `getActiveNotifications` returns null ids
/// for FCM posts and can't cancel them); the local-notifications sweep
/// handles Android and acts as a fallback while Eraser's native side
/// isn't built in yet.
static Future<void> clearNotificationsForChat(String chatToken) async {
final tag = chatTag(chatToken);
try {
await Eraser.clearAppNotificationsByTag(tag);
} on MissingPluginException {
// Eraser native code not yet linked — needs flutter clean + run.
} on Object catch (e) {
log('Eraser($tag) failed: $e');
}
try {
final plugin = NotificationService().flutterLocalNotificationsPlugin;
final actives = await plugin.getActiveNotifications();
for (final n in actives) {
final id = n.id;
if (id == null) continue;
if (n.tag == tag) await plugin.cancel(id: id, tag: n.tag);
}
} on Object catch (e) {
log('Active-notification sweep failed: $e');
}
}
/// Refreshes the chat list. Deliberately does NOT touch [ChatBloc] —
/// the open chat view manages its own state via long-poll, and refreshing
/// it here would re-fetch the last-opened chat with setReadMarker=on
/// even if the user has already left.
static void updateProviders(BuildContext context) {
context.read<ChatListBloc>().refresh();
context.read<ChatBloc>().refresh();
}
/// Switches to the Talk tab. If [chatToken] is provided, also schedules
+81 -1
View File
@@ -6,6 +6,9 @@ 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 '../notification/notification_tasks.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 +20,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';
@@ -34,6 +40,11 @@ class AppRoutes {
/// by `ChatList` once the matching room is loaded.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
/// Root-navigator observer used by [ChatView] to reclaim the global
/// [ChatBloc] on `didPopNext` after a stacked chat is popped.
static final RouteObserver<PageRoute<dynamic>> chatRouteObserver =
RouteObserver<PageRoute<dynamic>>();
static void openFolder(BuildContext context, List<String> path) {
pushScreen(context, withNavBar: false, screen: Files(path: path));
}
@@ -42,11 +53,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 +106,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,
@@ -109,6 +183,12 @@ class AppRoutes {
required UserAvatar avatar,
bool overrideToSingleSubScreen = true,
}) {
// Local mark only. Server-side mark is sent later from
// ChatBloc._loadChat with the freshly-fetched maxId — sending one
// here too with the chat list's possibly-stale room.lastMessage.id
// would race the fresh one and could regress the server cursor.
context.read<ChatListBloc>().markRoomAsRead(room.token, room.lastMessage.id);
NotificationTasks.clearNotificationsForChat(room.token);
TalkNavigator.pushSplitView(
context,
ChatView(room: room, selfId: selfId, avatar: avatar),
@@ -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<bool> 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<bool>(
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;
}
}
+15
View File
@@ -0,0 +1,15 @@
class PendingShare {
final List<String> 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;
}
+20
View File
@@ -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);
}
@@ -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<PendingShare?> pending = ValueNotifier(null);
StreamSubscription<List<SharedMediaFile>>? _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<void> 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<SharedMediaFile> items) {
if (items.isEmpty) return null;
final files = <String>[];
final texts = <String>[];
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(),
);
}
}
+208 -11
View File
@@ -1,15 +1,53 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../chat_list/bloc/chat_list_bloc.dart';
import '../repository/chat_repository.dart';
import 'chat_event.dart';
import 'chat_state.dart';
class ChatBloc
extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository> {
extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository>
with WidgetsBindingObserver {
final ChatListBloc? _chatListBloc;
String? _pollingToken;
int _backoffMs = 0;
int _lastKnownMessageId = 0;
bool _appResumed = true;
/// True only while a ChatView is mounted. Can't reuse `currentToken` —
/// clearing it on leaveChat races with setToken from didPopNext when
/// popping a stacked chat, causing spurious server read-markers on resume.
bool _chatViewActive = false;
bool get hasOpenChat => _chatViewActive;
DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0);
ChatBloc({ChatListBloc? chatListBloc}) : _chatListBloc = chatListBloc {
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> close() {
WidgetsBinding.instance.removeObserver(this);
_stopLongPoll();
return super.close();
}
@override
ChatRepository repository() => ChatRepository();
@@ -33,24 +71,70 @@ class ChatBloc
}
void setToken(String token) {
_chatViewActive = true;
if (token == (innerState?.currentToken ?? '')) {
refresh();
return;
}
_stopLongPoll();
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
add(RefetchStarted<ChatState>());
_loadChat(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
_scheduleLoad(token);
}
void refresh() {
final token = innerState?.currentToken ?? '';
if (token.isEmpty) return;
add(RefetchStarted<ChatState>());
_loadChat(token);
_scheduleLoad(token);
}
void setReferenceMessageId(int? messageId) {
add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
}
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
_stopLongPoll();
}
Future<void> sendServerReadMarker(String token, int messageId) async {
try {
await SetReadMarker(
token,
true,
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: messageId),
).run();
} on Object catch (e) {
log('Server read-marker for $token failed: $e');
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final wasResumed = _appResumed;
_appResumed = state == AppLifecycleState.resumed;
if (!_appResumed) {
_stopLongPoll();
return;
}
if (wasResumed) return;
final token = innerState?.currentToken ?? '';
if (token.isNotEmpty && _chatViewActive) refresh();
}
/// Microtask hop so the Bloc worker drains the preceding Emit before
/// any cache callback fires — a quick cache hit otherwise runs with
/// the previous token in state and fails stillCurrent().
void _scheduleLoad(String token) {
Future<void>.microtask(() {
if (isClosed) return;
_loadChat(token).then((_) => _startLongPoll(token));
});
}
Future<void> _loadChat(String token) async {
@@ -69,14 +153,25 @@ class ChatBloc
token: token,
onCacheData: (data) {
if (!stillCurrent()) return;
// Cache hit: show data immediately but preserve lastFetch — the
// cached payload may be stale and we don't want the UI to claim a
// fresh fetch just happened.
// Skip cache paint over already-merged long-poll data — would
// visibly drop those messages until the network call resolves.
if (innerState?.chatResponse != null) return;
add(Emit((s) => s.copyWith(chatResponse: data)));
},
onNetworkData: (data) {
// Mark runs even if no longer current — otherwise a quick
// navigation away leaves the server cursor stale. Cache check
// skips the POST when the cursor is already at maxId.
final maxId = _maxMessageId(data);
if (maxId > 0) {
final cached = _chatListBloc?.lastReadMessageFor(token);
if (cached == null || cached < maxId) {
unawaited(sendServerReadMarker(token, maxId));
}
}
if (!stillCurrent()) return;
add(DataGathered((s) => s.copyWith(chatResponse: data)));
_applyChatResponse(data);
if (maxId > 0) _chatListBloc?.markRoomAsRead(token, maxId);
},
onError: (e) => capturedError = e,
);
@@ -98,4 +193,106 @@ class ChatBloc
);
}
}
void _startLongPoll(String token) {
if (!_appResumed) return;
if (_pollingToken == token) return;
_stopLongPoll();
_pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token));
}
void _stopLongPoll() {
_pollingToken = null;
_backoffMs = 0;
}
Future<void> _pollLoop(String token) async {
while (_pollingToken == token && !isClosed) {
try {
final response = await LongPollChat(
chatToken: token,
lastKnownMessageId: _lastKnownMessageId,
).run();
if (_pollingToken != token || isClosed) return;
_backoffMs = 0;
if (response == null) continue;
final headerId = int.tryParse(
response.headers?[_kLongPollLastGivenHeader] ?? '',
);
if (headerId != null && headerId > _lastKnownMessageId) {
_lastKnownMessageId = headerId;
}
if (response.data.isEmpty) continue;
_applyChatResponse(response);
final maxId = _maxMessageId(response);
if (maxId > _lastKnownMessageId) _lastKnownMessageId = maxId;
// Long-poll's setReadMarker=on moved the server cursor; mirror locally.
final preview = _pickDisplayMessage(response);
if (preview != null) {
_chatListBloc?.applyIncomingMessage(token, preview);
} else {
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
}
} on Object catch (e) {
if (_pollingToken != token || isClosed) return;
log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs));
}
}
}
/// Dedups by id with newer-wins so server edits/deletes propagate.
void _applyChatResponse(GetChatResponse incoming) {
final current = innerState?.chatResponse;
if (current == null) {
add(DataGathered((s) => s.copyWith(chatResponse: incoming)));
return;
}
final byId = <int, GetChatResponseObject>{};
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in incoming.data) {
byId[m.id] = m;
}
final merged = GetChatResponse(byId.values.toSet())
..headers = incoming.headers;
add(DataGathered((s) => s.copyWith(chatResponse: merged)));
}
int _maxMessageId(GetChatResponse? response) {
if (response == null) return 0;
var max = 0;
for (final m in response.data) {
if (m.id > max) max = m.id;
}
return max;
}
/// Mirrors the server's own `lastMessage` selection (comments + voice only).
GetChatResponseObject? _pickDisplayMessage(GetChatResponse response) {
GetChatResponseObject? best;
for (final m in response.data) {
switch (m.messageType) {
case GetRoomResponseObjectMessageType.comment:
case GetRoomResponseObjectMessageType.voiceMessage:
if (best == null || m.id > best.id) best = m;
case GetRoomResponseObjectMessageType.deletedComment:
case GetRoomResponseObjectMessageType.system:
case GetRoomResponseObjectMessageType.command:
break;
}
}
return best;
}
}
const _kLongPollLastGivenHeader = 'x-chat-last-given';
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -15,6 +17,8 @@ class ChatListBloc
extends
LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
bool _forceRenew = false;
Timer? _autoRefreshTimer;
Duration? _autoRefreshInterval;
@override
void retry() {
@@ -22,6 +26,25 @@ class ChatListBloc
super.retry();
}
@override
Future<void> close() {
_autoRefreshTimer?.cancel();
return super.close();
}
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
_autoRefreshInterval = interval;
_autoRefreshTimer?.cancel();
_autoRefreshTimer = null;
if (interval == null) return;
_autoRefreshTimer = Timer.periodic(interval, (_) {
if (isClosed) return;
refresh(silent: true);
});
}
@override
ChatListRepository repository() => ChatListRepository();
@@ -51,8 +74,8 @@ class ChatListBloc
if (capturedError != null) throw capturedError!;
}
Future<void> refresh({bool renew = true}) async {
add(RefetchStarted<ChatListState>());
Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError;
try {
final rooms = await repo.data.getRooms(
@@ -82,6 +105,65 @@ class ChatListBloc
await refresh();
}
int? lastReadMessageFor(String token) {
final rooms = innerState?.rooms;
if (rooms == null) return null;
for (final room in rooms.data) {
if (room.token == token) return room.lastReadMessage;
}
return null;
}
/// Optimistic — server-side mark-as-read is the caller's job.
void markRoomAsRead(String token, int lastMessageId) {
_mutateRoom(token, (r) {
if (r.unreadMessages == 0 && r.lastReadMessage >= lastMessageId) {
return false;
}
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (lastMessageId > r.lastReadMessage) r.lastReadMessage = lastMessageId;
return true;
});
}
/// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
final wasRead =
r.unreadMessages == 0 && r.lastReadMessage >= message.id;
final hasNewer = r.lastMessage.id >= message.id;
if (wasRead && hasNewer) return false;
r.unreadMessages = 0;
r.unreadMention = false;
r.unreadMentionDirect = false;
if (message.id > r.lastReadMessage) r.lastReadMessage = message.id;
if (message.id > r.lastMessage.id) r.lastMessage = message;
if (message.timestamp > r.lastActivity) r.lastActivity = message.timestamp;
return true;
});
}
/// Re-wraps in a fresh [GetRoomResponse] so identity-based equality picks it up.
void _mutateRoom(
String token,
bool Function(GetRoomResponseObject room) mutator,
) {
final rooms = innerState?.rooms;
if (rooms == null) return;
var changed = false;
final updated = rooms.data.map((r) {
if (r.token != token) return r;
if (mutator(r)) changed = true;
return r;
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}
void _updateAppBadge(GetRoomResponse rooms) {
try {
final unread = rooms.data.fold<int>(
@@ -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';
@@ -35,7 +37,9 @@ class FilesBloc
Future<void> refresh() async {
add(RefetchStarted<FilesState>());
final path = innerState?.currentPath ?? initialPath;
await _query(path);
// Explicit user action — bypass the cache TTL so the root listing also
// refetches even though it is otherwise cached for a day.
await _query(path, renew: true);
}
Future<void> setPath(List<String> path) async {
@@ -50,15 +54,28 @@ class FilesBloc
await refresh();
}
Future<void> _query(List<String> path) async {
Future<void> _query(List<String> path, {bool renew = false}) 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<String>();
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,
renew: renew,
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 +90,8 @@ class FilesBloc
capturedError = e;
}
if (isStale()) return;
if (listing != null) {
listing.files.removeWhere(
(file) => file.name.isEmpty || file.name == path.lastOrNull,
@@ -11,16 +11,23 @@ class FilesDataProvider {
/// network call is still pending. The Future itself resolves once both the
/// cache lookup and the network attempt have settled, throwing if no payload
/// could be obtained at all.
///
/// Pass [renew] for explicit user-triggered reloads (pull-to-refresh, after
/// a rename / delete / move / upload). It bypasses the per-path TTL in
/// [ListFilesCache] so the root listing — which is otherwise cached for a
/// full day — still refetches when the user actively asks for it.
Future<ListFilesResponse> listFiles(
String path, {
void Function(ListFilesResponse)? onCacheData,
void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<ListFilesResponse>(
(onUpdate, onError) => ListFilesCache(
path: path,
onUpdate: onUpdate,
onCacheData: onCacheData,
onError: onError,
renew: renew,
),
onError: onError,
operationName: 'listFiles',
+8
View File
@@ -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<Login> {
void _onLoginSuccess() {
context.read<AccountBloc>().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
+6
View File
@@ -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;
+1 -1
View File
@@ -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),
+10
View File
@@ -14,6 +14,7 @@ import '../../../utils/cache_invalidation_bus.dart';
import '../../../widget/placeholder_view.dart';
import 'data/sort_options.dart';
import 'files_upload_dialog.dart';
import 'search/files_search_delegate.dart';
import 'widgets/add_file_menu.dart';
import 'widgets/clipboard_banner.dart';
import 'widgets/file_element.dart';
@@ -117,6 +118,15 @@ class _FilesViewState extends State<_FilesView> {
});
},
),
IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search),
onPressed: () async {
final delegate = FilesSearchDelegate(pathScope: widget.path);
await showSearch<void>(context: context, delegate: delegate);
delegate.disposeController();
},
),
],
),
floatingActionButton: FloatingActionButton(
@@ -0,0 +1,157 @@
import 'package:flutter/foundation.dart';
import '../../../../api/marianumcloud/search/search_files.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../utils/debouncer.dart';
import 'local_cache_search.dart';
/// Holds the live state of a Files-search session: current query, the latest
/// local-cache hits (synchronous), the latest server hits (asynchronous,
/// debounced), and loading/error flags. Notifies listeners whenever any of
/// these change so the UI can rebuild incrementally as results stream in.
class FilesSearchController extends ChangeNotifier {
FilesSearchController({List<String>? initialPathScope})
: _pathScope = List<String>.from(initialPathScope ?? const []);
static const Duration _serverDebounce = Duration(seconds: 1);
final String _debounceTag =
'files-search-${DateTime.now().microsecondsSinceEpoch}';
final SearchFiles _api = SearchFiles();
String _query = '';
List<String> _pathScope;
List<CacheableFile> _cacheResults = const [];
List<CacheableFile> _serverResults = const [];
bool _serverLoading = false;
Object? _serverError;
int _serverEpoch = 0;
bool _disposed = false;
/// Guards against the race where the search delegate is closed (and the
/// controller disposed) while a debounced cache scan or server call is
/// still in flight: their late `notifyListeners()` would otherwise throw
/// on a disposed `ChangeNotifier`.
void _safeNotify() {
if (_disposed) return;
notifyListeners();
}
String get query => _query;
List<String> get pathScope => List.unmodifiable(_pathScope);
bool get isScoped => _pathScope.isNotEmpty;
List<CacheableFile> get cacheResults => _cacheResults;
List<CacheableFile> get serverResults => _serverResults;
bool get serverLoading => _serverLoading;
Object? get serverError => _serverError;
/// Combined, deduplicated result list (cache hits first, then any
/// server-only hits) — handy for empty-state checks. Dedup key is the
/// WebDAV path.
List<CacheableFile> get combinedResults {
if (_cacheResults.isEmpty) return _serverResults;
if (_serverResults.isEmpty) return _cacheResults;
final seen = <String>{for (final f in _cacheResults) f.path};
return [
..._cacheResults,
..._serverResults.where((f) => seen.add(f.path)),
];
}
Future<void> setQuery(String value) async {
if (value == _query) return;
_query = value;
// Bumping the epoch up front invalidates any in-flight server call from
// a previous query, so its late response cannot toggle `_serverLoading`
// off while a fresh search is queued behind the debounce.
final epoch = ++_serverEpoch;
if (_query.trim().isEmpty) {
Debouncer.cancel(_debounceTag);
_cacheResults = const [];
_serverResults = const [];
_serverLoading = false;
_serverError = null;
_safeNotify();
return;
}
// Show loading immediately — even before the (typically fast) cache
// scan resolves — so the indicator is visible the moment the user
// starts typing rather than after the first await hop.
_serverLoading = true;
_serverError = null;
_safeNotify();
final cacheHits = await searchLocalCaches(_query, pathScope: _pathScope);
if (epoch != _serverEpoch) return;
_cacheResults = cacheHits;
_safeNotify();
_scheduleServerCall();
}
/// Drops the path filter and re-runs the current search globally. Used by
/// the empty-state "Im Hauptverzeichnis suchen" button.
Future<void> searchEverywhere() async {
if (!isScoped) return;
_pathScope = const [];
final epoch = ++_serverEpoch;
if (_query.trim().isEmpty) {
_safeNotify();
return;
}
_serverLoading = true;
_serverError = null;
_safeNotify();
final cacheHits = await searchLocalCaches(_query);
if (epoch != _serverEpoch) return;
_cacheResults = cacheHits;
_safeNotify();
_scheduleServerCall();
}
/// Re-runs the current server query immediately, bypassing the debounce.
/// Wired to the `LoadableStateErrorScreen` "Erneut versuchen" button.
void retry() {
if (_query.trim().isEmpty) return;
++_serverEpoch;
Debouncer.cancel(_debounceTag);
_serverLoading = true;
_serverError = null;
_safeNotify();
_runServerCall();
}
void _scheduleServerCall() {
Debouncer.debounce(_debounceTag, _serverDebounce, _runServerCall);
}
Future<void> _runServerCall() async {
final epoch = _serverEpoch;
final term = _query;
final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/';
try {
final response = await _api.run(term: term);
if (epoch != _serverEpoch) return;
_serverResults = response.entries
.map((e) => e.toCacheable())
.whereType<CacheableFile>()
.where((f) => scopePrefix.isEmpty || f.path.startsWith(scopePrefix))
.toList();
_serverLoading = false;
_serverError = null;
_safeNotify();
} on Object catch (e) {
if (epoch != _serverEpoch) return;
_serverResults = const [];
_serverLoading = false;
_serverError = e;
_safeNotify();
}
}
@override
void dispose() {
_disposed = true;
Debouncer.cancel(_debounceTag);
super.dispose();
}
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'files_search_controller.dart';
import 'files_search_results.dart';
/// Material `SearchDelegate` for the Files module — opens via the magnifier
/// in `FilesPage`'s AppBar (mirroring `SearchMarianumMessages`). Owns one
/// [FilesSearchController]; cache + server hits stream into the result list
/// as the user types.
class FilesSearchDelegate extends SearchDelegate<void> {
final FilesSearchController _controller;
FilesSearchDelegate({required List<String> pathScope})
: _controller = FilesSearchController(initialPathScope: pathScope),
super(searchFieldLabel: 'Dateien suchen');
/// Must be called by the host widget after `showSearch` returns so the
/// controller's listeners and pending debounce timers are released.
void disposeController() => _controller.dispose();
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(
tooltip: 'Suche leeren',
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
},
),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
@override
Widget buildResults(BuildContext context) {
_controller.setQuery(query);
return FilesSearchResults(
controller: _controller,
onResultTap: () => close(context, null),
);
}
@override
Widget buildSuggestions(BuildContext context) {
_controller.setQuery(query);
return FilesSearchResults(
controller: _controller,
onResultTap: () => close(context, null),
);
}
}
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart';
import '../../../../state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart';
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart';
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart';
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart';
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart';
import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../../widget/placeholder_view.dart';
import '../widgets/file_element.dart';
import 'files_search_controller.dart';
/// Renders the live state of a [FilesSearchController]. Wraps everything in a
/// `LoadableStateBloc` module so the search reuses the standard primary /
/// background loading and error views from the rest of the app.
class FilesSearchResults extends StatelessWidget {
final FilesSearchController controller;
final VoidCallback? onResultTap;
const FilesSearchResults({
required this.controller,
this.onResultTap,
super.key,
});
@override
Widget build(BuildContext context) =>
BlocModule<LoadableStateBloc, LoadableStateState>(
create: (_) => LoadableStateBloc(),
child: (context, bloc, _) {
bloc.reFetch = controller.retry;
return ListenableBuilder(
listenable: controller,
builder: (context, _) => _buildBody(context),
);
},
);
Widget _buildBody(BuildContext context) {
if (controller.query.trim().isEmpty) {
return const PlaceholderView(
icon: Icons.search,
text: 'Tippen, um in Dateien zu suchen.',
);
}
final combined = controller.combinedResults;
final hasContent = combined.isNotEmpty;
final hasError = controller.serverError != null;
final isLoading = controller.serverLoading;
final showPrimaryLoading = isLoading && !hasContent;
final showBackgroundLoading = isLoading && hasContent;
final showErrorScreen = hasError && !hasContent && !isLoading;
final showErrorBar = hasError && hasContent;
final showEmpty = !hasContent && !hasError && !isLoading;
final errorMessage = hasError ? errorToUserMessage(controller.serverError) : null;
return Column(
children: [
LoadableStateErrorBar(
visible: showErrorBar,
hasContent: hasContent,
message: errorMessage,
),
// Background loading sits *outside* the result Stack so the linear
// progress bar is not painted over by the opaque ListView/ListTiles
// when cache hits are already on screen and the server is still
// working. The widget collapses to zero height when invisible.
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
Expanded(
child: Stack(
children: [
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStateErrorScreen(
visible: showErrorScreen,
message: errorMessage,
),
if (showEmpty) _emptyState(context),
if (hasContent) _resultList(context, combined),
],
),
),
],
);
}
Widget _emptyState(BuildContext context) => PlaceholderView(
icon: Icons.search_off_outlined,
text: 'Keine Treffer gefunden.',
button: controller.isScoped
? FilledButton.icon(
onPressed: controller.searchEverywhere,
icon: const Icon(Icons.travel_explore),
label: const Text('Im Hauptverzeichnis suchen'),
)
: null,
);
Widget _resultList(BuildContext context, List<CacheableFile> combined) {
final groups = _groupByParent(combined);
final orderedKeys = groups.keys.toList()..sort();
final items = <Widget>[];
for (final folder in orderedKeys) {
final segments = _segmentsOf(folder);
items.add(
_FolderHeader(
folder: folder,
onOpen: () {
onResultTap?.call();
AppRoutes.openFolder(context, segments);
},
),
);
for (final file in groups[folder]!) {
items.add(
FileElement(
file,
segments,
controller.retry,
highlight: controller.query,
),
);
}
}
return ListView(padding: EdgeInsets.zero, children: items);
}
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
final map = <String, List<CacheableFile>>{};
for (final file in files) {
map.putIfAbsent(_parentOf(file), () => []).add(file);
}
return map;
}
String _parentOf(CacheableFile file) {
final stripped = file.path.replaceAll(RegExp(r'^/+|/+$'), '');
final segments = stripped.split('/');
if (segments.length <= 1) return '/';
segments.removeLast();
return '/${segments.join('/')}';
}
List<String> _segmentsOf(String folder) {
final stripped = folder.replaceAll(RegExp(r'^/+|/+$'), '');
if (stripped.isEmpty) return const [];
return stripped.split('/');
}
}
class _FolderHeader extends StatelessWidget {
final String folder;
final VoidCallback onOpen;
const _FolderHeader({required this.folder, required this.onOpen});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
height: 38,
color: theme.colorScheme.surfaceContainer,
padding: const EdgeInsets.only(left: 16),
child: Row(
children: [
Expanded(
child: Text(
folder,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: 'Ordner öffnen',
iconSize: 20,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.folder_open_outlined),
onPressed: onOpen,
),
],
),
);
}
}
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:localstore/localstore.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../../api/request_cache.dart';
/// Document key prefix used by `ListFilesCache._documentId`.
const String _folderCachePrefix = 'wd-folder-';
/// Scans every cached folder listing in Localstore and returns files/folders
/// whose name contains [query] (case-insensitive).
///
/// [pathScope] restricts results to entries whose WebDAV path starts with
/// the given folder. Pass an empty list (or null) to search globally.
///
/// [docs] is an injection seam for tests — production callers leave it null
/// so the helper reads from the real Localstore.
Future<List<CacheableFile>> searchLocalCaches(
String query, {
List<String>? pathScope,
Map<String, dynamic>? docs,
}) async {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final needle = trimmed.toLowerCase();
final scopePrefix = pathScope == null || pathScope.isEmpty
? ''
: '${pathScope.join('/')}/';
final raw =
docs ??
await Localstore.instance.collection(RequestCache.collection).get();
if (raw == null || raw.isEmpty) return const [];
final results = <String, CacheableFile>{};
for (final entry in raw.entries) {
final docKey = entry.key.split('/').last;
if (!docKey.startsWith(_folderCachePrefix)) continue;
final value = entry.value;
if (value is! Map) continue;
final json = value['json'];
if (json is! String) continue;
final ListFilesResponse listing;
try {
listing = ListFilesResponse.fromJson(
jsonDecode(json) as Map<String, dynamic>,
);
} on Object {
continue;
}
for (final file in listing.files) {
if (!file.name.toLowerCase().contains(needle)) continue;
if (scopePrefix.isNotEmpty && !file.path.startsWith(scopePrefix)) {
continue;
}
results[file.path] ??= file;
}
}
return results.values.toList();
}
@@ -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,
+67 -8
View File
@@ -7,19 +7,32 @@ 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';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/info_dialog.dart';
import '../../talk/widgets/highlighted_linkify.dart';
import 'file_details_sheet.dart';
class FileElement extends StatefulWidget {
final CacheableFile file;
final List<String> path;
final void Function() refetch;
const FileElement(this.file, this.path, this.refetch, {super.key});
/// When non-null, occurrences of this string in the file name are visually
/// highlighted in the tile title. Used by the Files search delegate.
final String? highlight;
const FileElement(
this.file,
this.path,
this.refetch, {
this.highlight,
super.key,
});
@override
State<FileElement> createState() => _FileElementState();
@@ -71,7 +84,11 @@ class _FileElementState extends State<FileElement> {
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;
@@ -113,7 +130,7 @@ class _FileElementState extends State<FileElement> {
);
}
Widget _subtitle() {
Widget? _subtitle() {
final status = _job?.status.value;
if (status is DownloadInProgress) {
return Row(
@@ -130,10 +147,16 @@ class _FileElementState extends State<FileElement> {
],
);
}
final modified = widget.file.modifiedAt ?? DateTime.now();
return widget.file.isDirectory
? Text('geändert ${modified.formatRelative()}')
: Text('${filesize(widget.file.size)}, ${modified.formatRelative()}');
final modified = widget.file.modifiedAt;
final size = widget.file.size;
if (widget.file.isDirectory) {
if (modified == null) return null;
return Text('geändert ${modified.formatRelative()}');
}
if (size == null && modified == null) return null;
if (size == null) return Text(modified!.formatRelative());
if (modified == null) return Text(filesize(size));
return Text('${filesize(size)}, ${modified.formatRelative()}');
}
void _onTap() {
@@ -299,6 +322,18 @@ class _FileElementState extends State<FileElement> {
_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'),
@@ -311,12 +346,36 @@ class _FileElementState extends State<FileElement> {
);
}
Widget _title(BuildContext context) {
final base =
Theme.of(context).textTheme.bodyLarge ??
DefaultTextStyle.of(context).style;
if (widget.highlight == null || widget.highlight!.trim().isEmpty) {
return Text(
widget.file.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
return Text.rich(
TextSpan(
children: buildHighlightedSpans(
text: widget.file.name,
query: widget.highlight,
baseStyle: base,
),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
@override
Widget build(BuildContext context) => ListTile(
leading: CenteredLeading(
Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined),
),
title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
title: _title(context),
subtitle: _subtitle(),
trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null),
onTap: _onTap,
@@ -102,6 +102,7 @@ class MarianumDateRow extends StatelessWidget {
initialDescription: event.description,
initialStart: event.start,
initialEnd: event.end,
initialAllDay: event.isAllDay,
),
barrierDismissible: false,
),
@@ -6,6 +6,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import 'search_marianum_messages.dart';
class MarianumMessageListView extends StatelessWidget {
const MarianumMessageListView({super.key});
@@ -16,7 +17,25 @@ class MarianumMessageListView extends StatelessWidget {
) => BlocModule<MarianumMessageBloc, LoadableState<MarianumMessageState>>(
create: (context) => MarianumMessageBloc(),
child: (context, bloc, state) => Scaffold(
appBar: AppBar(title: const Text('Marianum Message')),
appBar: AppBar(
title: const Text('Marianum Message'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
final list = bloc.state.data?.messageList;
if (list == null) return;
showSearch(
context: context,
delegate: SearchMarianumMessages(
base: list.base,
messages: list.messages,
),
);
},
),
],
),
body: LoadableStateConsumer<MarianumMessageBloc, MarianumMessageState>(
child: (state, loading) => ListView.builder(
itemCount: state.messageList.messages.length,
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/placeholder_view.dart';
class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
final String base;
final List<MarianumMessage> messages;
SearchMarianumMessages({required this.base, required this.messages});
List<MarianumMessage> _matches() {
final q = query.trim().toLowerCase();
if (q.isEmpty) return messages;
return messages.where((m) {
return m.name.toLowerCase().contains(q) ||
m.date.toLowerCase().contains(q);
}).toList();
}
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
@override
Widget buildResults(BuildContext context) {
final matches = _matches();
if (matches.isEmpty) {
return const PlaceholderView(
icon: Icons.search_off_outlined,
text: 'Keine Treffer',
);
}
return ListView.builder(
itemCount: matches.length,
itemBuilder: (_, i) {
final message = matches[i];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Text('vom ${message.date}'),
trailing: const Icon(Icons.arrow_right),
onTap: () {
close(context, message);
AppRoutes.openMarianumMessage(context, base, message);
},
);
},
);
}
@override
Widget buildSuggestions(BuildContext context) => buildResults(context);
}
@@ -67,12 +67,12 @@ class AboutSection extends StatelessWidget {
applicationIcon: const Icon(Icons.apps),
applicationName: 'MarianumMobile',
applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
'${appInfo.appName}\n\n${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild/Relase-nummer: ${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',
"${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
'Marianum Fulda 2019-2020, 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',
@@ -92,7 +92,7 @@ class AboutSection extends StatelessWidget {
),
ListTile(
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
title: const Text('Infos zu Web-/ Untis'),
title: const Text('Infos zu (Web) Untis'),
subtitle: const Text('Für den Stundenplan'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
@@ -106,7 +106,7 @@ class AboutSection extends StatelessWidget {
Icon(Icons.send_time_extension_outlined),
),
title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Countdowns, Marianum Message und mehr'),
subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'mhsl',
@@ -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',
@@ -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<void> 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<SettingsCubit>().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<ChatListBloc>().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<ChatListBloc, ChatListState>(
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<void> _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<void> _afterExternalFilesUploaded(
BuildContext context,
GetRoomResponseObject room,
List<String> 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<SettingsCubit>();
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<void> _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<void> _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<void> _showBlockingSpinner(BuildContext context) => showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const PopScope(
canPop: false,
child: Center(child: CircularProgressIndicator()),
),
);
@@ -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<void> Function(BuildContext context, List<String> 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<FilesBloc, LoadableState<FilesState>>(
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<SettingsCubit>();
_currentSort = _settings.val().fileSettings.sortBy;
_ascending = _settings.val().fileSettings.ascending;
}
void _enter(FilesBloc bloc, List<String> currentPath, String folderName) {
bloc.setPath([...currentPath, folderName]);
}
void _goUp(FilesBloc bloc, List<String> currentPath) {
if (currentPath.isEmpty) return;
bloc.setPath(currentPath.sublist(0, currentPath.length - 1));
}
@override
Widget build(BuildContext context) {
final bloc = context.read<FilesBloc>();
return BlocBuilder<FilesBloc, LoadableState<FilesState>>(
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<String> 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<FilesBloc, FilesState>(
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<void> _externalUploadFlow(
BuildContext context,
List<String> 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<String> 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<String> targetPath) {
Navigator.of(context).popUntil((route) => route.isFirst);
AppRoutes.openFolder(context, targetPath);
}
Future<void> _internalCopyFlow(
BuildContext context,
List<String> 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);
}
@@ -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),
),
],
),
);
}

Some files were not shown because too many files have changed in this diff Show More